diff --git a/VERSION b/VERSION index 0ea3a94..7dff5b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index b48ac4b..8b5b81f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "dequel-api", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { @@ -10,6 +10,7 @@ }, "dependencies": { "@elysiajs/cors": "^1.1.1", + "@sinclair/typebox": "^0.34.13", "drizzle-orm": "^0.45.2", "elysia": "^1.1.26", "ioredis": "^5.4.1", diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index 99b1cf0..7cf7d66 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -5,6 +5,7 @@ import { createDeployment, countDeployments, getDeploymentById, + getProjectById, getLogs, listDeployments, } from "../../db/repo"; @@ -66,6 +67,7 @@ export const deploymentsRoutes = new Elysia() String(form.get("commitSha") ?? "").trim() || undefined; const clearCache = form.get("clearCache") === "true"; + const resolvedBranch = branch || (projectId ? (await getProjectById(projectId))?.repoBranch || undefined : undefined); if ( sourceType !== "git" && sourceType !== "upload" && @@ -88,7 +90,7 @@ export const deploymentsRoutes = new Elysia() projectId, sourceType: "git", sourceRef: gitUrl, - branch, + branch: resolvedBranch, environment, commitSha, clearCache, @@ -122,7 +124,7 @@ export const deploymentsRoutes = new Elysia() projectId, sourceType: "upload", sourceRef: uploadPath, - branch, + branch: resolvedBranch, environment, clearCache, }); @@ -186,11 +188,12 @@ export const deploymentsRoutes = new Elysia() error: "Cannot redeploy an image-based (rollback) deployment — rollback to an earlier source deployment instead", }; } + const project = original.projectId ? await getProjectById(original.projectId) : null; const deployment = await createDeployment({ projectId: original.projectId || undefined, sourceType: original.sourceType, sourceRef: original.sourceRef, - branch: original.branch || undefined, + branch: original.branch || project?.repoBranch || undefined, environment: original.environment || undefined, }); orchestrator.enqueue(deployment.id); diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index 11e5bcf..92fad65 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -34,16 +34,6 @@ export const projectsRoutes = new Elysia() set.status = 400; return { error: "name is required" }; } - if (body?.repoUrl) { - const projects = await listProjects(); - const normalize = (u: string) => u.replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase(); - const incoming = normalize(body.repoUrl); - const duplicate = projects.find((p) => p.repoUrl && normalize(p.repoUrl) === incoming); - if (duplicate) { - set.status = 409; - return { error: `A project with this repository URL already exists: "${duplicate.name}"` }; - } - } const project = await createProject({ name: body.name, description: body.description, @@ -55,6 +45,9 @@ export const projectsRoutes = new Elysia() port: body.port ? Number(body.port) : null, sourceDir: body.sourceDir || null, sourceType: body.sourceType || "git", + projectType: body.projectType || "web", + buildCommand: body.buildCommand || undefined, + startCommand: body.startCommand || undefined, }); return project; }, @@ -62,16 +55,6 @@ export const projectsRoutes = new Elysia() .patch( "/projects/:id", async ({ params: { id }, body, set }: any) => { - if (body?.repoUrl) { - const projects = await listProjects(); - const normalize = (u: string) => u.replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase(); - const incoming = normalize(body.repoUrl); - const duplicate = projects.find((p) => p.id !== id && p.repoUrl && normalize(p.repoUrl) === incoming); - if (duplicate) { - set.status = 409; - return { error: `A project with this repository URL already exists: "${duplicate.name}"` }; - } - } const project = await updateProject(id, { name: body?.name, description: body?.description, @@ -82,6 +65,9 @@ export const projectsRoutes = new Elysia() memoryLimitMb: body?.memoryLimitMb, port: body?.port ? Number(body.port) : body?.port === null ? null : undefined, sourceDir: body?.sourceDir ?? undefined, + projectType: body?.projectType ?? undefined, + buildCommand: "buildCommand" in (body ?? {}) ? (body.buildCommand ?? "") || null : undefined, + startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined, }); if (!project) { set.status = 404; diff --git a/apps/api/src/api/scaling/index.ts b/apps/api/src/api/scaling/index.ts index f0c5292..2914cc2 100644 --- a/apps/api/src/api/scaling/index.ts +++ b/apps/api/src/api/scaling/index.ts @@ -3,6 +3,7 @@ import { deleteScalingPolicy, getScalingPolicy, upsertScalingPolicy, + getProjectById, } from "../../db/repo"; export const scalingRoutes = new Elysia() @@ -24,6 +25,15 @@ export const scalingRoutes = new Elysia() set.status = 400; return { error: "body is required" }; } + const project = await getProjectById(params.id); + if (!project) { + set.status = 404; + return { error: "Project not found" }; + } + if (body.enabled !== false && (!project.cpuLimit || project.cpuLimit <= 0)) { + set.status = 400; + return { error: "Cannot enable autoscaling on a project without CPU resource limits configured." }; + } return upsertScalingPolicy({ projectId: params.id, ...body, diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 70797f7..f7e8cc4 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -30,6 +30,7 @@ export const migrate = async () => { drizzleMigrate(db, { migrationsFolder }); await addClearCacheColumn(db); + await addFinishedAtColumn(db); await seedFromConfig(); }; @@ -45,6 +46,18 @@ const addClearCacheColumn = async (db: ReturnType) => { } }; +const addFinishedAtColumn = async (db: ReturnType) => { + try { + db.run(sql`ALTER TABLE deployments ADD COLUMN finished_at text`); + console.log("[Migrate] Added finished_at column to deployments table"); + } catch (err) { + const cause = err instanceof Error && "cause" in err ? err.cause : err; + if (cause instanceof Error && cause.message.includes("duplicate column name")) return; + console.error("[Migrate] Failed to add finished_at column:", err); + throw err; + } +}; + const seedFromConfig = async () => { if (config.githubClientId && config.githubClientSecret) { const existing = await getGithubIntegration(); diff --git a/apps/api/src/db/migrations/0002_colossal_flatman.sql b/apps/api/src/db/migrations/0002_colossal_flatman.sql new file mode 100644 index 0000000..107987e --- /dev/null +++ b/apps/api/src/db/migrations/0002_colossal_flatman.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD `project_type` text DEFAULT 'web' NOT NULL; diff --git a/apps/api/src/db/migrations/0003_aberrant_viper.sql b/apps/api/src/db/migrations/0003_aberrant_viper.sql new file mode 100644 index 0000000..cebfba7 --- /dev/null +++ b/apps/api/src/db/migrations/0003_aberrant_viper.sql @@ -0,0 +1,2 @@ +ALTER TABLE `projects` ADD `build_command` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `start_command` text; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0002_snapshot.json b/apps/api/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..9b18484 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1311 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a478725b-8dd8-478f-add2-60b5385e91dc", + "prevId": "00000000-0000-0000-0000-000000000000", + "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'" + }, + "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/0003_snapshot.json b/apps/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..1fe1ebe --- /dev/null +++ b/apps/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1325 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b951d02c-f63a-48e5-925c-aac34aa7a759", + "prevId": "a478725b-8dd8-478f-add2-60b5385e91dc", + "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_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 d7da1e3..f2fdd56 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1782100000000, "tag": "0001_refresh_tokens", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1784587675597, + "tag": "0002_colossal_flatman", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1784681960309, + "tag": "0003_aberrant_viper", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/deployments.ts b/apps/api/src/db/repo/deployments.ts index bdaf080..843ac82 100644 --- a/apps/api/src/db/repo/deployments.ts +++ b/apps/api/src/db/repo/deployments.ts @@ -21,6 +21,7 @@ const mapDeployment = (row: typeof deployments.$inferSelect): Deployment => ({ environment: row.environment, failureReason: row.failureReason, clearCache: Boolean(row.clearCache), + finishedAt: row.finishedAt ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -74,18 +75,41 @@ export const updateDeploymentCommitSha = async (id: string, commitSha: string) = db.update(deployments).set({ commitSha, updatedAt: now() }).where(eq(deployments.id, id)).run(); }; +const ACTIVE_STATUSES: DeploymentStatus[] = [ + "pending", + "building", + "deploying", +]; + +const STAMP_FINISHED_UNCONDITIONALLY: DeploymentStatus[] = [ + "running", + "failed", +]; + export const updateDeploymentStatus = async ( id: string, status: DeploymentStatus, patch: Partial> = {}, ) => { const db = await getDrizzle(); + const existing = db + .select({ finishedAt: deployments.finishedAt }) + .from(deployments) + .where(eq(deployments.id, id)) + .get(); const updates: Record = { status, updatedAt: now() }; if (patch.imageTag !== undefined) updates.imageTag = patch.imageTag; if (patch.containerName !== undefined) updates.containerName = patch.containerName; if (patch.liveUrl !== undefined) updates.liveUrl = patch.liveUrl; if (patch.failureReason !== undefined) updates.failureReason = patch.failureReason; if (patch.replicas !== undefined) updates.replicas = patch.replicas; + if (!ACTIVE_STATUSES.includes(status)) { + if (!existing?.finishedAt) { + updates.finishedAt = now(); + } else if (STAMP_FINISHED_UNCONDITIONALLY.includes(status)) { + updates.finishedAt = now(); + } + } db.update(deployments).set(updates).where(eq(deployments.id, id)).run(); }; diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts index 27fbee5..a7086a2 100644 --- a/apps/api/src/db/repo/projects.ts +++ b/apps/api/src/db/repo/projects.ts @@ -28,6 +28,9 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({ port: row.port ?? null, sourceDir: row.sourceDir ?? null, sourceType: row.sourceType, + projectType: row.projectType, + buildCommand: row.buildCommand ?? null, + startCommand: row.startCommand ?? null, githubTokenEncrypted: row.githubTokenEncrypted ?? null, githubTokenIv: row.githubTokenIv ?? null, githubTokenTag: row.githubTokenTag ?? null, @@ -51,6 +54,9 @@ export const createProject = async (input: CreateProjectInput): Promise port: input.port ?? null, sourceDir: input.sourceDir ?? null, sourceType: input.sourceType ?? "git", + projectType: input.projectType ?? "web", + buildCommand: input.buildCommand ?? null, + startCommand: input.startCommand ?? null, createdAt: timestamp, updatedAt: timestamp, }).run(); @@ -88,6 +94,9 @@ export const updateProject = async (id: string, patch: Partial Promise, +): Promise => { + const ignoreDirs = new Set([ + "node_modules", + "target", + ".git", + ".cargo", + "dist", + "build", + ".next", + ".svelte-kit", + "vendor", + ]); + const allowedExts = new Set([ + "rs", + "go", + "js", + "ts", + "py", + "java", + "json", + "yaml", + "yml", + "toml", + ]); + + try { + const entries = await readdir(dir, { + withFileTypes: true, + }); + for (const entry of entries) { + const fullPath = join( + dir, + entry.name, + ); + if (entry.isDirectory()) { + if (ignoreDirs.has(entry.name)) { + continue; + } + await rewriteLocalhostBinding( + fullPath, + onLog, + ); + } else if (entry.isFile()) { + const ext = entry.name + .split(".") + .pop(); + if (ext && allowedExts.has(ext)) { + try { + let content = + await Bun.file( + fullPath, + ).text(); + if ( + content.includes( + "127.0.0.1", + ) + ) { + content = + content.replaceAll( + "127.0.0.1", + "0.0.0.0", + ); + await Bun.write( + fullPath, + content, + ); + await onLog( + `Auto-rewrote 127.0.0.1 to 0.0.0.0 in ${entry.name} for container compatibility.`, + ); + } + } catch { + // Ignore read errors + } + } + } + } + } catch { + // Ignore readdir/directory access errors + } +}; + +export const generateDynamicRailpackJson = async ( + workspace: string, + sourceDir: string | null, + projectType: string | null, + buildCommandOverride: string | null, + startCommandOverride: string | null, + onLog: (line: string) => Promise, +): Promise => { + const cleanSourceDir = sourceDir + ? sourceDir.replace(/^\//, "") + : ""; + const buildDir = cleanSourceDir + ? join(workspace, cleanSourceDir) + : workspace; + await rewriteLocalhostBinding( + buildDir, + onLog, + ); + const configPath = join( + workspace, + "railpack.json", + ); + + // Default configuration template + const config: Record = { + caches: {}, + steps: {}, + deploy: {}, + }; + + let configured = false; + + // 1. Check if user provided manual build / start command overrides + if (buildCommandOverride || startCommandOverride) { + await onLog("Applying custom build/start settings"); + if (buildCommandOverride) { + config.steps.build = { + commands: [ + cleanSourceDir + ? `cd ${cleanSourceDir} && ${buildCommandOverride}` + : buildCommandOverride, + ], + }; + } + if (startCommandOverride) { + config.deploy.startCommand = cleanSourceDir + ? `cd ${cleanSourceDir} && ${startCommandOverride}` + : startCommandOverride; + } + configured = true; + } + + const hasPackageJson = await Bun.file( + join(buildDir, "package.json"), + ).exists(); + if (hasPackageJson && !configured) { + try { + const packageJson = await Bun.file( + join(buildDir, "package.json"), + ).json(); + const scripts = + packageJson.scripts || {}; + + let pm = "npm"; + if ( + await Bun.file( + join( + workspace, + "pnpm-lock.yaml", + ), + ).exists() + ) { + pm = "pnpm"; + } else if ( + await Bun.file( + join(workspace, "yarn.lock"), + ).exists() + ) { + pm = "yarn"; + } else if ( + await Bun.file( + join(workspace, "bun.lockb"), + ).exists() + ) { + pm = "bun"; + } + + await onLog( + `Configuring Node.js project using ${pm}`, + ); + + const hasNext = (packageJson.dependencies && packageJson.dependencies.next) || + (packageJson.devDependencies && packageJson.devDependencies.next); + + if (pm === "pnpm") { + config.caches.pnpm_store = { + directory: "/root/.local/share/pnpm/store", + type: "shared", + }; + } else if (pm === "yarn") { + config.caches.yarn_cache = { + directory: "/root/.cache/yarn", + type: "shared", + }; + } else if (pm === "bun") { + config.caches.bun_cache = { + directory: "/root/.bun/install/cache", + type: "shared", + }; + } else { + config.caches.npm_cache = { + directory: "/root/.npm", + type: "shared", + }; + } + + + + if (scripts.build) { + const buildCmds = [ + cleanSourceDir + ? `cd ${cleanSourceDir} && ${pm} run build` + : `${pm} run build`, + ]; + if (hasNext) { + const nextCacheDir = cleanSourceDir + ? `${cleanSourceDir}/.next/cache` + : ".next/cache"; + config.caches.next_cache = { + directory: nextCacheDir, + type: "shared", + }; + config.steps.build = { + commands: buildCmds, + caches: ["next_cache"], + }; + } else { + config.steps.build = { + commands: buildCmds, + }; + } + } + + const hasServerScript = !!scripts.server; + const isStatic = projectType === "static" || (!scripts.start && !hasServerScript && (scripts.build || hasPackageJson)); + + if (isStatic) { + const serveScript = ` +const fs = require("fs"); +const path = require("path"); +const PORT = Number(process.env.PORT || 3000); +const cleanSourceDir = "${cleanSourceDir}"; +let staticDir = "."; +const candidates = [ + path.join(cleanSourceDir, "dist"), + path.join(cleanSourceDir, "build"), + path.join(cleanSourceDir, "out"), + path.join(cleanSourceDir, "public"), + "dist", + "build", + "out", + "public", + "." +]; +for (const dir of candidates) { + const fullPath = path.join(process.cwd(), dir); + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { + if (fs.existsSync(path.join(fullPath, "index.html"))) { + staticDir = dir; + break; + } + } +} +console.log("Serving static directory:", staticDir, "on port", PORT); +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + let decodedPathname = "/"; + try { + decodedPathname = decodeURIComponent(url.pathname); + } catch { + decodedPathname = url.pathname; + } + let filePath = path.join(staticDir, decodedPathname); + if (decodedPathname.endsWith("/")) { + filePath = path.join(filePath, "index.html"); + } + let file = Bun.file(filePath); + if (await file.exists()) { + return new Response(file, { + headers: { + "content-type": file.type || "application/octet-stream" + } + }); + } + const fallbackPath = path.join(staticDir, "index.html"); + const fallbackFile = Bun.file(fallbackPath); + if (await fallbackFile.exists()) { + return new Response(fallbackFile, { + headers: { + "content-type": fallbackFile.type || "text/html" + } + }); + } + return new Response("Not Found", { status: 404 }); + } +}); +`; + await Bun.write(join(workspace, "dequel-serve.js"), serveScript); + config.deploy.startCommand = "bun dequel-serve.js"; + } else if (scripts.start) { + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && ${pm} run start` + : `${pm} run start`; + } else if (scripts.server) { + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && ${pm} run server` + : `${pm} run server`; + } else { + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && node dist/index.js` + : "node dist/index.js"; + } + + configured = true; + } catch (err) { + await onLog( + `Error parsing package.json: ${err}`, + ); + } + } + + const hasCargoToml = await Bun.file( + join(buildDir, "Cargo.toml"), + ).exists(); + if (hasCargoToml && !configured) { + try { + let cargoContent = await Bun.file( + join(buildDir, "Cargo.toml"), + ).text(); + + let hasWorkspaceRoot = false; + let currentDir = buildDir; + while ( + currentDir.startsWith(workspace) + ) { + const parentCargo = join( + currentDir, + "Cargo.toml", + ); + if ( + currentDir !== buildDir && + (await Bun.file( + parentCargo, + ).exists()) + ) { + const content = + await Bun.file( + parentCargo, + ).text(); + if ( + content.includes( + "[workspace]", + ) + ) { + hasWorkspaceRoot = true; + break; + } + } + const nextDir = join( + currentDir, + "..", + ); + if ( + nextDir === currentDir || + !nextDir.startsWith(workspace) + ) + break; + currentDir = nextDir; + } + + if ( + !hasWorkspaceRoot && + (cargoContent.includes( + ".workspace = true", + ) || + cargoContent.includes( + "workspace = true", + )) + ) { + await onLog( + `Detected workspace inheritance in Cargo.toml without workspace root. Resolving workspace variables...`, + ); + const commonDeps: Record< + string, + string + > = { + "actix-web": '"4"', + "actix-files": '"0.6"', + "actix-rt": '"2.9"', + serde: '{"version": "1", "features": ["derive"]}', + serde_json: '"1"', + tokio: '{"version": "1", "features": ["full"]}', + futures: '"0.3"', + log: '"0.4"', + env_logger: '"0.11"', + uuid: '{"version": "1", "features": ["v4"]}', + chrono: '"0.4"', + reqwest: + '{"version": "0.12", "features": ["json"]}', + anyhow: '"1"', + thiserror: '"1"', + }; + + cargoContent = + cargoContent.replace( + /edition\.workspace\s*=\s*true/g, + 'edition = "2021"', + ); + cargoContent = + cargoContent.replace( + /rust-version\.workspace\s*=\s*true/g, + 'rust-version = "1.89"', + ); + cargoContent = + cargoContent.replace( + /version\.workspace\s*=\s*true/g, + 'version = "0.1.0"', + ); + + const lines = + cargoContent.split("\n"); + for ( + let i = 0; + i < lines.length; + i++ + ) { + const line = lines[i]; + const matchSimple = + line.match( + /^(\s*)([a-zA-Z0-9_-]+)\.workspace\s*=\s*true/, + ); + if (matchSimple) { + const indent = + matchSimple[1]; + const name = + matchSimple[2]; + const ver = + commonDeps[name] || + '"*"'; + lines[i] = + `${indent}${name} = ${ver}`; + continue; + } + const matchComplex = + line.match( + /^(\s*)([a-zA-Z0-9_-]+)\s*=\s*\{\s*workspace\s*=\s*true\s*,?\s*(.*)\}/, + ); + if (matchComplex) { + const indent = + matchComplex[1]; + const name = + matchComplex[2]; + const rest = + matchComplex[3].trim(); + const restComma = rest + ? `, ${rest}` + : ""; + lines[i] = + `${indent}${name} = { version = "*"${restComma} }`; + continue; + } + } + cargoContent = lines.join("\n"); + await Bun.write( + join(buildDir, "Cargo.toml"), + cargoContent, + ); + await onLog( + `Wrote standalone Cargo.toml to resolve workspace inheritance.`, + ); + } + + const match = cargoContent.match( + /\[package\][^]*?name\s*=\s*"([^"]+)"/, + ); + const pkgName = match + ? match[1] + : null; + + if (pkgName) { + await onLog( + `Configuring Rust project: ${pkgName}`, + ); + config.caches = { + cargo_registry: { + directory: + "/root/.cargo/registry", + type: "shared", + }, + cargo_git: { + directory: + "/root/.cargo/git", + type: "shared", + }, + cargo_target: { + directory: "target", + type: "shared", + }, + }; + config.steps.build = { + commands: [ + `cargo build --release -p ${pkgName}`, + "mkdir -p bin", + `cp target/release/${pkgName} bin/`, + ], + caches: [ + "cargo_registry", + "cargo_git", + "cargo_target", + ], + }; + config.deploy.startCommand = `./bin/${pkgName}`; + configured = true; + } + } catch (err) { + await onLog( + `Error parsing Cargo.toml: ${err}`, + ); + } + } + + let hasGoMod = false; + let currentDir = buildDir; + while (currentDir.startsWith(workspace)) { + if ( + await Bun.file( + join(currentDir, "go.mod"), + ).exists() + ) { + hasGoMod = true; + break; + } + const nextDir = join(currentDir, ".."); + if ( + nextDir === currentDir || + !nextDir.startsWith(workspace) + ) + break; + currentDir = nextDir; + } + if (hasGoMod && !configured) { + await onLog(`Configuring Go project`); + config.caches = { + go_build: { + directory: + "/root/.cache/go-build", + type: "shared", + }, + go_mod: { + directory: "/go/pkg/mod", + type: "shared", + }, + }; + config.steps.build = { + commands: [ + cleanSourceDir + ? `cd ${cleanSourceDir} && go build -o bin/app .` + : "go build -o bin/app .", + ], + caches: ["go_build", "go_mod"], + }; + config.deploy.startCommand = + cleanSourceDir + ? `cd ${cleanSourceDir} && ./bin/app` + : "./bin/app"; + configured = true; + } + + if (!configured && projectType === "static") { + const serveScript = ` +const fs = require("fs"); +const path = require("path"); +const PORT = Number(process.env.PORT || 3000); +const cleanSourceDir = "${cleanSourceDir}"; +let staticDir = "."; +const candidates = [ + path.join(cleanSourceDir, "dist"), + path.join(cleanSourceDir, "build"), + path.join(cleanSourceDir, "out"), + path.join(cleanSourceDir, "public"), + "dist", + "build", + "out", + "public", + "." +]; +for (const dir of candidates) { + const fullPath = path.join(process.cwd(), dir); + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { + if (fs.existsSync(path.join(fullPath, "index.html"))) { + staticDir = dir; + break; + } + } +} +console.log("Serving static directory:", staticDir, "on port", PORT); +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + let decodedPathname = "/"; + try { + decodedPathname = decodeURIComponent(url.pathname); + } catch { + decodedPathname = url.pathname; + } + let filePath = path.join(staticDir, decodedPathname); + if (decodedPathname.endsWith("/")) { + filePath = path.join(filePath, "index.html"); + } + let file = Bun.file(filePath); + if (await file.exists()) { + return new Response(file, { + headers: { + "content-type": file.type || "application/octet-stream" + } + }); + } + const fallbackPath = path.join(staticDir, "index.html"); + const fallbackFile = Bun.file(fallbackPath); + if (await fallbackFile.exists()) { + return new Response(fallbackFile, { + headers: { + "content-type": fallbackFile.type || "text/html" + } + }); + } + return new Response("Not Found", { status: 404 }); + } +}); +`; + await Bun.write(join(workspace, "dequel-serve.js"), serveScript); + config.deploy.startCommand = "bun dequel-serve.js"; + configured = true; + } + + if (!configured) { + await onLog( + `Relying on default Railpack language auto-detection`, + ); + } + + await Bun.write( + configPath, + JSON.stringify(config, null, 2), + ); + await onLog( + `Wrote dynamic railpack.json for caching and monorepo resolution`, + ); +}; diff --git a/apps/api/src/orchestrator/railpack.ts b/apps/api/src/orchestrator/railpack.ts index 541d64c..cc6ac61 100644 --- a/apps/api/src/orchestrator/railpack.ts +++ b/apps/api/src/orchestrator/railpack.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; -import { readdir } from "node:fs/promises"; import { dockerBin } from "../utils/docker-bin"; +import { generateDynamicRailpackJson } from "./railpack-config-utils"; export interface RailpackBuildResult { imageTag: string; @@ -270,449 +270,6 @@ const ensureBuilder = async (): Promise => { } }; -const rewriteLocalhostBinding = async ( - dir: string, - onLog: (line: string) => Promise, -): Promise => { - const ignoreDirs = new Set([ - "node_modules", - "target", - ".git", - ".cargo", - "dist", - "build", - ".next", - ".svelte-kit", - "vendor", - ]); - const allowedExts = new Set([ - "rs", - "go", - "js", - "ts", - "py", - "java", - "json", - "yaml", - "yml", - "toml", - ]); - - try { - const entries = await readdir(dir, { - withFileTypes: true, - }); - for (const entry of entries) { - const fullPath = join( - dir, - entry.name, - ); - if (entry.isDirectory()) { - if (ignoreDirs.has(entry.name)) { - continue; - } - await rewriteLocalhostBinding( - fullPath, - onLog, - ); - } else if (entry.isFile()) { - const ext = entry.name - .split(".") - .pop(); - if (ext && allowedExts.has(ext)) { - try { - let content = - await Bun.file( - fullPath, - ).text(); - if ( - content.includes( - "127.0.0.1", - ) - ) { - content = - content.replaceAll( - "127.0.0.1", - "0.0.0.0", - ); - await Bun.write( - fullPath, - content, - ); - await onLog( - `Auto-rewrote 127.0.0.1 to 0.0.0.0 in ${entry.name} for container compatibility.`, - ); - } - } catch { - // Ignore read errors - } - } - } - } - } catch { - // Ignore readdir/directory access errors - } -}; - -const generateDynamicRailpackJson = async ( - workspace: string, - sourceDir: string | null, - onLog: (line: string) => Promise, -): Promise => { - const cleanSourceDir = sourceDir - ? sourceDir.replace(/^\//, "") - : ""; - const buildDir = cleanSourceDir - ? join(workspace, cleanSourceDir) - : workspace; - await rewriteLocalhostBinding( - buildDir, - onLog, - ); - const configPath = join( - workspace, - "railpack.json", - ); - - // Default configuration template - const config: Record = { - caches: {}, - steps: {}, - deploy: {}, - }; - - let configured = false; - - const hasPackageJson = await Bun.file( - join(buildDir, "package.json"), - ).exists(); - if (hasPackageJson) { - try { - const packageJson = await Bun.file( - join(buildDir, "package.json"), - ).json(); - const scripts = - packageJson.scripts || {}; - - let pm = "npm"; - if ( - await Bun.file( - join( - workspace, - "pnpm-lock.yaml", - ), - ).exists() - ) { - pm = "pnpm"; - } else if ( - await Bun.file( - join(workspace, "yarn.lock"), - ).exists() - ) { - pm = "yarn"; - } else if ( - await Bun.file( - join(workspace, "bun.lockb"), - ).exists() - ) { - pm = "bun"; - } - - await onLog( - `Configuring Node.js project using ${pm}`, - ); - - if (scripts.build) { - config.steps.build = { - commands: [ - cleanSourceDir - ? `cd ${cleanSourceDir} && ${pm} run build` - : `${pm} run build`, - ], - }; - } - - if (scripts.start) { - config.deploy.startCommand = - cleanSourceDir - ? `cd ${cleanSourceDir} && ${pm} run start` - : `${pm} run start`; - } else { - config.deploy.startCommand = - cleanSourceDir - ? `cd ${cleanSourceDir} && node dist/index.js` - : "node dist/index.js"; - } - - configured = true; - } catch (err) { - await onLog( - `Error parsing package.json: ${err}`, - ); - } - } - - const hasCargoToml = await Bun.file( - join(buildDir, "Cargo.toml"), - ).exists(); - if (hasCargoToml && !configured) { - try { - let cargoContent = await Bun.file( - join(buildDir, "Cargo.toml"), - ).text(); - - let hasWorkspaceRoot = false; - let currentDir = buildDir; - while ( - currentDir.startsWith(workspace) - ) { - const parentCargo = join( - currentDir, - "Cargo.toml", - ); - if ( - currentDir !== buildDir && - (await Bun.file( - parentCargo, - ).exists()) - ) { - const content = - await Bun.file( - parentCargo, - ).text(); - if ( - content.includes( - "[workspace]", - ) - ) { - hasWorkspaceRoot = true; - break; - } - } - const nextDir = join( - currentDir, - "..", - ); - if ( - nextDir === currentDir || - !nextDir.startsWith(workspace) - ) - break; - currentDir = nextDir; - } - - if ( - !hasWorkspaceRoot && - (cargoContent.includes( - ".workspace = true", - ) || - cargoContent.includes( - "workspace = true", - )) - ) { - await onLog( - `Detected workspace inheritance in Cargo.toml without workspace root. Resolving workspace variables...`, - ); - const commonDeps: Record< - string, - string - > = { - "actix-web": '"4"', - "actix-files": '"0.6"', - "actix-rt": '"2.9"', - serde: '{"version": "1", "features": ["derive"]}', - serde_json: '"1"', - tokio: '{"version": "1", "features": ["full"]}', - futures: '"0.3"', - log: '"0.4"', - env_logger: '"0.11"', - uuid: '{"version": "1", "features": ["v4"]}', - chrono: '"0.4"', - reqwest: - '{"version": "0.12", "features": ["json"]}', - anyhow: '"1"', - thiserror: '"1"', - }; - - cargoContent = - cargoContent.replace( - /edition\.workspace\s*=\s*true/g, - 'edition = "2021"', - ); - cargoContent = - cargoContent.replace( - /rust-version\.workspace\s*=\s*true/g, - 'rust-version = "1.89"', - ); - cargoContent = - cargoContent.replace( - /version\.workspace\s*=\s*true/g, - 'version = "0.1.0"', - ); - - const lines = - cargoContent.split("\n"); - for ( - let i = 0; - i < lines.length; - i++ - ) { - const line = lines[i]; - const matchSimple = - line.match( - /^(\s*)([a-zA-Z0-9_-]+)\.workspace\s*=\s*true/, - ); - if (matchSimple) { - const indent = - matchSimple[1]; - const name = - matchSimple[2]; - const ver = - commonDeps[name] || - '"*"'; - lines[i] = - `${indent}${name} = ${ver}`; - continue; - } - const matchComplex = - line.match( - /^(\s*)([a-zA-Z0-9_-]+)\s*=\s*\{\s*workspace\s*=\s*true\s*,?\s*(.*)\}/, - ); - if (matchComplex) { - const indent = - matchComplex[1]; - const name = - matchComplex[2]; - const rest = - matchComplex[3].trim(); - const restComma = rest - ? `, ${rest}` - : ""; - lines[i] = - `${indent}${name} = { version = "*"${restComma} }`; - continue; - } - } - cargoContent = lines.join("\n"); - await Bun.write( - join(buildDir, "Cargo.toml"), - cargoContent, - ); - await onLog( - `Wrote standalone Cargo.toml to resolve workspace inheritance.`, - ); - } - - const match = cargoContent.match( - /\[package\][^]*?name\s*=\s*"([^"]+)"/, - ); - const pkgName = match - ? match[1] - : null; - - if (pkgName) { - await onLog( - `Configuring Rust project: ${pkgName}`, - ); - config.caches = { - cargo_registry: { - directory: - "/root/.cargo/registry", - type: "shared", - }, - cargo_git: { - directory: - "/root/.cargo/git", - type: "shared", - }, - cargo_target: { - directory: "target", - type: "shared", - }, - }; - config.steps.build = { - commands: [ - `cargo build --release -p ${pkgName}`, - "mkdir -p bin", - `cp target/release/${pkgName} bin/`, - ], - caches: [ - "cargo_registry", - "cargo_git", - "cargo_target", - ], - }; - config.deploy.startCommand = `./bin/${pkgName}`; - configured = true; - } - } catch (err) { - await onLog( - `Error parsing Cargo.toml: ${err}`, - ); - } - } - - let hasGoMod = false; - let currentDir = buildDir; - while (currentDir.startsWith(workspace)) { - if ( - await Bun.file( - join(currentDir, "go.mod"), - ).exists() - ) { - hasGoMod = true; - break; - } - const nextDir = join(currentDir, ".."); - if ( - nextDir === currentDir || - !nextDir.startsWith(workspace) - ) - break; - currentDir = nextDir; - } - if (hasGoMod && !configured) { - await onLog(`Configuring Go project`); - config.caches = { - go_build: { - directory: - "/root/.cache/go-build", - type: "shared", - }, - go_mod: { - directory: "/go/pkg/mod", - type: "shared", - }, - }; - config.steps.build = { - commands: [ - cleanSourceDir - ? `cd ${cleanSourceDir} && go build -o bin/app .` - : "go build -o bin/app .", - ], - caches: ["go_build", "go_mod"], - }; - config.deploy.startCommand = - cleanSourceDir - ? `cd ${cleanSourceDir} && ./bin/app` - : "./bin/app"; - configured = true; - } - - if (!configured) { - await onLog( - `Relying on default Railpack language auto-detection`, - ); - } - - await Bun.write( - configPath, - JSON.stringify(config, null, 2), - ); - await onLog( - `Wrote dynamic railpack.json for caching and monorepo resolution`, - ); -}; export const buildWithRailpack = async ( workspace: string, @@ -721,6 +278,10 @@ export const buildWithRailpack = async ( opts?: { cacheKey?: string; sourceDir?: string | null; + projectType?: string | null; + buildCommand?: string | null; + startCommand?: string | null; + environmentVariables?: { key: string; value: string }[]; signal?: AbortSignal; clearCache?: boolean; }, @@ -753,6 +314,9 @@ export const buildWithRailpack = async ( await generateDynamicRailpackJson( workspace, cleanSourceDir, + opts?.projectType ?? null, + opts?.buildCommand ?? null, + opts?.startCommand ?? null, onLog, ); @@ -773,10 +337,31 @@ export const buildWithRailpack = async ( "--env", "RUSTUP_AUTO_SELF_UPDATE=off", "--env", - "NPM_CONFIG_TIMEOUT=120000", - workspace, + "NPM_CONFIG_TIMEOUT=600000", + "--env", + "NPM_CONFIG_AUDIT=false", + "--env", + "NPM_CONFIG_FUND=false", + "--env", + "PNPM_CONFIG_TRUST_LOCKFILE=true", + "--env", + "NPM_CONFIG_MAXSOCKETS=4", + "--env", + "PNPM_CONFIG_NETWORK_CONCURRENCY=4", + "--env", + "PNPM_CONFIG_CHILD_CONCURRENCY=4", + "--env", + "PNPM_CONFIG_FETCH_RETRIES=10", ]; + if (opts?.environmentVariables) { + for (const env of opts.environmentVariables) { + args.push("--env", `${env.key}=${env.value}`); + } + } + + args.push(workspace); + const build = await spawnAsync( "railpack", args, diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 3592523..10ca24f 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -54,7 +54,14 @@ const waitForRunningContainer = async ( ) => { for (let attempt = 0; attempt < retries; attempt++) { try { - const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); + const exists = await run(dockerBin, ['inspect', '-f', '{{.Id}}', containerName]) + .then(() => true) + .catch(() => false); + if (!exists) { + await new Promise(r => setTimeout(r, 500)); + continue; + } + const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); if (status === 'running') { await new Promise(r => setTimeout(r, 2000)); const stabilityStatus = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); @@ -83,6 +90,11 @@ const waitForRunningContainer = async ( export const ensureContainerRunning = async (containerName: string) => { try { + const exists = await run(dockerBin, ['inspect', '-f', '{{.Id}}', containerName]).then(() => true).catch(() => false); + if (!exists) { + console.warn(`Container ${containerName} not found — skipping reconciliation`); + return; + } const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); if (status !== 'running') { await tryRun(dockerBin, ['network', 'disconnect', '-f', config.dockerNetwork, containerName]); diff --git a/apps/api/src/scaling/__tests__/engine.test.ts b/apps/api/src/scaling/__tests__/engine.test.ts index e4cf27a..40f6dbf 100644 --- a/apps/api/src/scaling/__tests__/engine.test.ts +++ b/apps/api/src/scaling/__tests__/engine.test.ts @@ -92,7 +92,7 @@ mock.module('ioredis', () => ({ // Mock db/repo mock.module(fileUrl('../../db/repo'), () => ({ - getProjectById: mock(() => Promise.resolve(null)), + getProjectById: mock(() => Promise.resolve({ id: "proj-1", name: "proj-1", cpuLimit: 0.5, memoryLimitMb: 512 })), listDeployments: mock(() => Promise.resolve([])), getScalingPolicy: mock((id: string) => mockGetScalingPolicyResult ? Promise.resolve(mockGetScalingPolicyResult(id)) : Promise.resolve(null) diff --git a/apps/api/src/scaling/engine.ts b/apps/api/src/scaling/engine.ts index afdc173..b527321 100644 --- a/apps/api/src/scaling/engine.ts +++ b/apps/api/src/scaling/engine.ts @@ -5,7 +5,7 @@ import { config } from '../utils/config'; import { dockerBin } from '../utils/docker-bin'; import { DEQUEL_MANAGED_LABEL } from '../utils/dequel-labels'; import { run, tryRun } from './docker-utils'; -import { getScalingPolicy, listDeployments, updateDeploymentStatus, listEnvironmentVariablesForDeploy } from '../db/repo'; +import { getScalingPolicy, listDeployments, updateDeploymentStatus, listEnvironmentVariablesForDeploy, getProjectById } from '../db/repo'; interface ContainerStats { containerName: string; @@ -57,8 +57,12 @@ class ScalingEngine { const policy = await getScalingPolicy(dep.projectId); if (!policy || !policy.enabled) return; + const project = await getProjectById(dep.projectId); + if (!project || !project.cpuLimit || project.cpuLimit <= 0) return; + const stats = await this.getContainerStats(dep.containerName); if (!stats) return; + const slug = project ? project.name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) : dep.projectId; const state = await this.getCooldownState(dep.id); const now = Date.now(); @@ -69,7 +73,7 @@ class ScalingEngine { state.highCpuSince = now; } else if (now - state.highCpuSince > policy.cooldownSeconds * 1000) { if (now - state.lastScaleUp > policy.cooldownSeconds * 1000) { - await this.scaleUp(dep, policy.maxReplicas); + await this.scaleUp(dep, policy.maxReplicas, slug, project?.cpuLimit, project?.memoryLimitMb); state.lastScaleUp = now; state.highCpuSince = null; } @@ -85,7 +89,7 @@ class ScalingEngine { state.lowCpuSince = now; } else if (now - state.lowCpuSince > 300_000) { // 5 min if (now - state.lastScaleDown > policy.cooldownSeconds * 1000) { - await this.scaleDown(dep, policy.minReplicas); + await this.scaleDown(dep, policy.minReplicas, slug); state.lastScaleDown = now; state.lowCpuSince = null; } @@ -166,8 +170,10 @@ class ScalingEngine { private async scaleUp( dep: { id: string; projectId: string | null; containerName: string | null }, maxReplicas: number, + slug: string, + cpuLimit?: number | null, + memoryLimitMb?: number | null, ) { - const slug = dep.projectId || dep.id; const currentReplicas = await this.getCurrentReplicas(slug); if (currentReplicas >= maxReplicas) return; @@ -198,11 +204,20 @@ class ScalingEngine { } } catch {} + const resources: string[] = []; + if (cpuLimit && cpuLimit > 0) { + resources.push('--cpus', String(cpuLimit)); + } + if (memoryLimitMb && memoryLimitMb > 0) { + resources.push('--memory', `${Math.round(memoryLimitMb)}m`); + } + try { await run(dockerBin, [ 'run', '-d', '--name', containerName, '--network', config.dockerNetwork, '-l', DEQUEL_MANAGED_LABEL, + ...resources, ...volumes, ...envVars, imageTag, ]); @@ -219,8 +234,8 @@ class ScalingEngine { private async scaleDown( dep: { id: string; projectId: string | null; containerName: string | null }, minReplicas: number, + slug: string, ) { - const slug = dep.projectId || dep.id; const currentReplicas = await this.getCurrentReplicas(slug); if (currentReplicas <= minReplicas) return; @@ -265,7 +280,7 @@ class ScalingEngine { } const baseDomain = config.caddyBaseDomain === 'localhost' ? `${config.caddyBaseDomain}:80` : config.caddyBaseDomain; - const caddySnippet = `${slug}.${baseDomain} {\n reverse_proxy ${targets.join(' ')}\n}\n`; + const caddySnippet = `${slug}.${baseDomain} {\n reverse_proxy ${targets.join(' ')} {\n header_up Host {upstream_hostport}\n }\n}\n`; await writeFile(routeFile, caddySnippet, 'utf8'); // Reload Caddy diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index e7bb643..46e65e2 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -22,6 +22,9 @@ export interface Project { port: number | null; sourceDir: string | null; sourceType: string; + projectType: string; + buildCommand: string | null; + startCommand: string | null; githubTokenEncrypted: string | null; githubTokenIv: string | null; githubTokenTag: string | null; @@ -49,6 +52,9 @@ export interface CreateProjectInput { port?: number | null; sourceDir?: string | null; sourceType?: string; + projectType?: string; + buildCommand?: string | null; + startCommand?: string | null; } export interface EnvironmentVariable { @@ -145,6 +151,8 @@ export interface CreateScalingPolicyInput { maxReplicas?: number; cpuThresholdPercent?: number; memoryThresholdPercent?: number; + cooldownSeconds?: number; + enabled?: boolean; } export interface Server { @@ -222,6 +230,7 @@ export interface Deployment { environment: string | null; failureReason: string | null; clearCache: boolean; + finishedAt: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/api/src/utils/__tests__/domain-verifier.test.ts b/apps/api/src/utils/__tests__/domain-verifier.test.ts index 42cb577..c86fad6 100644 --- a/apps/api/src/utils/__tests__/domain-verifier.test.ts +++ b/apps/api/src/utils/__tests__/domain-verifier.test.ts @@ -151,13 +151,13 @@ describe('buildCaddySnippet', () => { it('creates snippet with just localhost domain when no verified domains', async () => { const snippet = await module.buildCaddySnippet('elysia', 'deploy-abc', 'proj-1', noDomains); - expect(snippet).toBe('elysia.localhost:80 {\n log {\n output stdout\n format json\n }\n reverse_proxy deploy-abc:3000\n}\n'); + expect(snippet).toBe('elysia.localhost:80 {\n log {\n output stdout\n format json\n }\n reverse_proxy deploy-abc:3000 {\n header_up Host {upstream_hostport}\n }\n}\n'); }); it('creates snippet with verified domains when projectId is provided', async () => { const listFn = mockDomains(['app.example.com']); const snippet = await module.buildCaddySnippet('elysia', 'deploy-abc', 'proj-1', listFn); - expect(snippet).toBe('elysia.localhost:80, app.example.com:80 {\n log {\n output stdout\n format json\n }\n reverse_proxy deploy-abc:3000\n}\n'); + expect(snippet).toBe('elysia.localhost:80, app.example.com:80 {\n log {\n output stdout\n format json\n }\n reverse_proxy deploy-abc:3000 {\n header_up Host {upstream_hostport}\n }\n}\n'); }); it('only includes verified (not pending) domains', async () => { diff --git a/apps/api/src/utils/__tests__/env-filter.test.ts b/apps/api/src/utils/__tests__/env-filter.test.ts new file mode 100644 index 0000000..a8c79cf --- /dev/null +++ b/apps/api/src/utils/__tests__/env-filter.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from "bun:test"; +import { isBuildSafeEnv, filterBuildEnvVars } from "../env-filter"; + +describe("isBuildSafeEnv", () => { + test("allows NEXT_PUBLIC_ prefix", () => { + expect(isBuildSafeEnv("NEXT_PUBLIC_API_URL")).toBe(true); + expect(isBuildSafeEnv("NEXT_PUBLIC_STRIPE_KEY")).toBe(true); + }); + + test("allows VITE_ prefix", () => { + expect(isBuildSafeEnv("VITE_API_URL")).toBe(true); + expect(isBuildSafeEnv("VITE_APP_TITLE")).toBe(true); + }); + + test("allows REACT_APP_ prefix", () => { + expect(isBuildSafeEnv("REACT_APP_API_URL")).toBe(true); + }); + + test("allows NUXT_PUBLIC_ prefix", () => { + expect(isBuildSafeEnv("NUXT_PUBLIC_API_BASE")).toBe(true); + }); + + test("allows NUXT_ENV_ prefix", () => { + expect(isBuildSafeEnv("NUXT_ENV_COOL_WORD")).toBe(true); + }); + + test("allows GATSBY_ prefix", () => { + expect(isBuildSafeEnv("GATSBY_API_URL")).toBe(true); + }); + + test("allows PUBLIC_ prefix (Astro/SvelteKit)", () => { + expect(isBuildSafeEnv("PUBLIC_SUPABASE_URL")).toBe(true); + expect(isBuildSafeEnv("PUBLIC_ANALYTICS_ID")).toBe(true); + }); + + + test("allows STORYBOOK_ prefix", () => { + expect(isBuildSafeEnv("STORYBOOK_API_URL")).toBe(true); + }); + + test("allows safe exact matches", () => { + expect(isBuildSafeEnv("NODE_ENV")).toBe(true); + expect(isBuildSafeEnv("CI")).toBe(true); + expect(isBuildSafeEnv("GENERATE_SOURCEMAP")).toBe(true); + expect(isBuildSafeEnv("SENTRY_DSN")).toBe(true); + }); + + test("rejects secrets and runtime vars", () => { + expect(isBuildSafeEnv("DATABASE_URL")).toBe(false); + expect(isBuildSafeEnv("API_SECRET_KEY")).toBe(false); + expect(isBuildSafeEnv("JWT_SECRET")).toBe(false); + expect(isBuildSafeEnv("SMTP_PASSWORD")).toBe(false); + expect(isBuildSafeEnv("REDIS_URL")).toBe(false); + expect(isBuildSafeEnv("AWS_SECRET_ACCESS_KEY")).toBe(false); + expect(isBuildSafeEnv("STRIPE_SECRET_KEY")).toBe(false); + }); + + test("rejects partial prefix matches", () => { + expect(isBuildSafeEnv("NEXT_PRIVATE_KEY")).toBe(false); + expect(isBuildSafeEnv("VITEPRESS_KEY")).toBe(false); + }); +}); + +describe("filterBuildEnvVars", () => { + test("filters mixed env vars correctly", () => { + const input = [ + { key: "NEXT_PUBLIC_API_URL", value: "https://api.example.com" }, + { key: "DATABASE_URL", value: "postgres://user:pass@host/db" }, + { key: "VITE_APP_TITLE", value: "My App" }, + { key: "JWT_SECRET", value: "supersecret" }, + { key: "NODE_ENV", value: "production" }, + { key: "STRIPE_SECRET_KEY", value: "sk_live_xxx" }, + { key: "PUBLIC_SUPABASE_URL", value: "https://xxx.supabase.co" }, + ]; + + const result = filterBuildEnvVars(input); + expect(result).toEqual([ + { key: "NEXT_PUBLIC_API_URL", value: "https://api.example.com" }, + { key: "VITE_APP_TITLE", value: "My App" }, + { key: "NODE_ENV", value: "production" }, + { key: "PUBLIC_SUPABASE_URL", value: "https://xxx.supabase.co" }, + ]); + }); + + test("returns empty array when no build-safe vars", () => { + const input = [ + { key: "DATABASE_URL", value: "postgres://..." }, + { key: "API_KEY", value: "secret" }, + ]; + expect(filterBuildEnvVars(input)).toEqual([]); + }); + + test("returns all vars when all are build-safe", () => { + const input = [ + { key: "NEXT_PUBLIC_URL", value: "https://..." }, + { key: "NODE_ENV", value: "production" }, + ]; + expect(filterBuildEnvVars(input)).toEqual(input); + }); +}); diff --git a/apps/api/src/utils/domain-verifier.ts b/apps/api/src/utils/domain-verifier.ts index 4f0877a..62bb1cd 100644 --- a/apps/api/src/utils/domain-verifier.ts +++ b/apps/api/src/utils/domain-verifier.ts @@ -144,5 +144,5 @@ export const buildCaddySnippet = async ( } } - return `${domains.join(', ')} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${containerName}:${port}\n}\n`; + 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`; }; diff --git a/apps/api/src/utils/env-filter.ts b/apps/api/src/utils/env-filter.ts new file mode 100644 index 0000000..3ce1921 --- /dev/null +++ b/apps/api/src/utils/env-filter.ts @@ -0,0 +1,42 @@ +const BUILD_SAFE_PREFIXES = [ + "NEXT_PUBLIC_", + "VITE_", + "REACT_APP_", + "NUXT_PUBLIC_", + "NUXT_ENV_", + "GATSBY_", + "PUBLIC_", + "STORYBOOK_", +]; + +const BUILD_SAFE_EXACT = new Set([ + "NODE_ENV", + "NODE_OPTIONS", + "NPM_CONFIG_PRODUCTION", + "GENERATE_SOURCEMAP", + "INLINE_RUNTIME_CHUNK", + "CI", + "BUILD_PATH", + "DISABLE_ESLINT_PLUGIN", + "TSC_COMPILE_ON_ERROR", + "IMAGE_INLINE_SIZE_LIMIT", + "FAST_REFRESH", + "ESLINT_NO_DEV_ERRORS", + "TAILWIND_MODE", + "ANALYZE", + "SENTRY_DSN", + "SENTRY_AUTH_TOKEN", +]); + +export const isBuildSafeEnv = (key: string): boolean => { + if (BUILD_SAFE_EXACT.has(key)) return true; + return BUILD_SAFE_PREFIXES.some((prefix) => + key.startsWith(prefix), + ); +}; + +export const filterBuildEnvVars = ( + vars: { key: string; value: string }[], +): { key: string; value: string }[] => { + return vars.filter((v) => isBuildSafeEnv(v.key)); +}; diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index 57c8503..e2e9502 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -171,6 +171,13 @@ declare module 'astro:content' { collection: "docs"; data: any } & { render(): Render[".md"] }; +"changelog.md": { + id: "changelog.md"; + slug: "changelog"; + body: string; + collection: "docs"; + data: any +} & { render(): Render[".md"] }; "configuration.md": { id: "configuration.md"; slug: "configuration"; diff --git a/apps/docs/package.json b/apps/docs/package.json index 9091a24..0304a6e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/docs/src/content/changelogs/v0.2.1.md b/apps/docs/src/content/changelogs/v0.2.1.md new file mode 100644 index 0000000..4b053ef --- /dev/null +++ b/apps/docs/src/content/changelogs/v0.2.1.md @@ -0,0 +1,18 @@ +--- +version: 0.2.1 +date: "2026-07-27" +--- + +## What's Changed +### Improvements +- Caddy reverse proxy now passes the Host header correctly to upstream containers ([f20e3fa](https://github.com/Lftobs/dequel/commit/f20e3fa)) +- Runtime reconciliation skips non-existent containers ([dc4593c](https://github.com/Lftobs/dequel/commit/dc4593c)) +- Docker-compose configuration updated for local builds ([dc4593c](https://github.com/Lftobs/dequel/commit/dc4593c)) +- Add `project_type`, `buildCommand`, and `startCommand` fields to projects ([f20e3fa](https://github.com/Lftobs/dequel/commit/f20e3fa)) +- Implement dynamic `railpack.json` generation for build/runtime compatibility ([f20e3fa](https://github.com/Lftobs/dequel/commit/f20e3fa)) +- Add `ProjectSettingsTab` to the dashboard for managing build settings ([f20e3fa](https://github.com/Lftobs/dequel/commit/f20e3fa)) +- Track deployment completion time with `finished_at` column ([dc4593c](https://github.com/Lftobs/dequel/commit/dc4593c)) +- Implement status-based timestamping for deployment lifecycle ([dc4593c](https://github.com/Lftobs/dequel/commit/dc4593c)) + +### Bug Fixes +- Ko-fi popup mounting fixed in dashboard sidebar ([dc4593c](https://github.com/Lftobs/dequel/commit/dc4593c)) diff --git a/apps/web/package.json b/apps/web/package.json index 21ac8e9..befca43 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "dequel-web", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8438ae2..e383b2c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -87,6 +87,9 @@ export const createProject = (data: { port?: number; sourceDir?: string; sourceType?: string; + projectType?: string; + buildCommand?: string; + startCommand?: string; }) => apiFetch("/projects", { method: "POST", @@ -100,6 +103,9 @@ export const updateProject = ( baseDomain?: string | null; sourceDir?: string | null; port?: number | null; + projectType?: string; + buildCommand?: string | null; + startCommand?: string | null; }, ) => apiFetch(`/projects/${id}`, { diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 6a8b82b..8de0c47 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -48,7 +48,7 @@ export function Sidebar({