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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,12 @@ jobs:
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
TAR_DIR=_tar
mkdir -p "$TAR_DIR/infra/caddy" "$TAR_DIR/infra/monitoring/grafana/datasources"
mkdir -p "$TAR_DIR/infra/caddy" "$TAR_DIR/infra/monitoring/grafana/datasources" "$TAR_DIR/scripts/auth"
cp docker-compose.yml "$TAR_DIR/"
cp scripts/dequel "$TAR_DIR/dequel"
cp infra/caddy/Caddyfile "$TAR_DIR/infra/caddy/"
cp -r infra/monitoring "$TAR_DIR/infra/"
cp -r scripts/auth/* "$TAR_DIR/scripts/auth/"
cd "$TAR_DIR" && tar -czf "../dequel-config-${VERSION}.tar.gz" .

- name: Extract changelog entry for this version
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ docker compose up -d # Start full stack
docker compose up -d --build # Rebuild and start

# Install / Manage
curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/scripts/install.sh | sh
curl -fsSL https://github.com/Lftobs/dequel/releases/latest/download/install.sh | bash
scripts/dequel start # Start platform
scripts/dequel uninstall # Remove everything (prompts)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ and in the `apps/docs/` directory of this repository.
## Quick Start

```bash
curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/scripts/install.sh | sh
curl -fsSL https://github.com/Lftobs/dequel/releases/latest/download/install.sh | bash
```

Once installed, manage Dequel with the `dequel` CLI:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.2.1
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dequel-api",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions apps/api/src/api/deployments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createDeployment,
countDeployments,
getDeploymentById,
getProjectById,
getLogs,
listDeployments,
} from "../../db/repo";
Expand Down Expand Up @@ -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" &&
Expand All @@ -88,7 +90,7 @@ export const deploymentsRoutes = new Elysia()
projectId,
sourceType: "git",
sourceRef: gitUrl,
branch,
branch: resolvedBranch,
environment,
commitSha,
clearCache,
Expand Down Expand Up @@ -122,7 +124,7 @@ export const deploymentsRoutes = new Elysia()
projectId,
sourceType: "upload",
sourceRef: uploadPath,
branch,
branch: resolvedBranch,
environment,
clearCache,
});
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/api/domains/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
listDomains,
updateDomainValidation,
} from "../../db/repo";
import { SERVICE_NAME_RE, isPort } from "../../utils/validate";

export const domainsRoutes = new Elysia()
.get(
Expand All @@ -20,10 +21,21 @@ export const domainsRoutes = new Elysia()
set.status = 400;
return { error: "domain is required" };
}
if (body.targetService && !SERVICE_NAME_RE.test(String(body.targetService))) {
set.status = 400;
return { error: "targetService may only contain letters, numbers, underscores and hyphens" };
}
const targetPort = body.targetPort ? Number(body.targetPort) : null;
if (targetPort !== null && !isPort(targetPort)) {
set.status = 400;
return { error: "targetPort must be an integer between 1 and 65535" };
}
const domain = await createDomain({
projectId: params.id,
domain: body.domain,
type: body.type ?? "custom",
targetService: body.targetService || null,
targetPort,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
const { validateDomain, resolveServerIp } = await import(
"../../utils/dns",
Expand Down
58 changes: 40 additions & 18 deletions apps/api/src/api/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ import { tryRun, reloadCaddy } from "../../orchestrator/runtime";
import { removeFromCaddyRoute } from "../../utils/domain-verifier";
import { config } from "../../utils/config";
import { dockerBin } from "../../utils/docker-bin";
import { SERVICE_NAME_RE, isPort, validateComposeServices } from "../../utils/validate";

const validateComposeFields = (body: any): string | null => {
if (body?.composeService && !SERVICE_NAME_RE.test(String(body.composeService))) {
return "composeService may only contain letters, numbers, underscores and hyphens";
}
if (body?.composePort) {
const port = Number(body.composePort);
if (!isPort(port)) {
return "composePort must be an integer between 1 and 65535";
}
}
if (body?.composeServices) {
const result = validateComposeServices(body.composeServices);
if (!result.ok) return result.error;
}
return null;
};

export const projectsRoutes = new Elysia()
.get("/projects", async () => listProjects())
Expand All @@ -34,15 +52,10 @@ 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 validationError = validateComposeFields(body);
if (validationError) {
set.status = 400;
return { error: validationError };
}
const project = await createProject({
name: body.name,
Expand All @@ -55,22 +68,24 @@ export const projectsRoutes = new Elysia()
port: body.port ? Number(body.port) : null,
sourceDir: body.sourceDir || null,
sourceType: body.sourceType || "git",
projectType: body.projectType || "web",
buildType: body.buildType || "railpack",
composeService: body.composeService || null,
composePort: body.composePort ? Number(body.composePort) : null,
composeServices: body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null,
buildCommand: body.buildCommand || undefined,
startCommand: body.startCommand || undefined,
});
return project;
},
)
.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 validationError = validateComposeFields(body);
if (validationError) {
set.status = 400;
return { error: validationError };
}
const project = await updateProject(id, {
name: body?.name,
Expand All @@ -82,6 +97,13 @@ 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,
buildType: body?.buildType ?? undefined,
composeService: "composeService" in (body ?? {}) ? (body.composeService || null) : undefined,
composePort: "composePort" in (body ?? {}) ? (body.composePort ? Number(body.composePort) : null) : undefined,
composeServices: "composeServices" in (body ?? {}) ? (body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null) : undefined,
buildCommand: "buildCommand" in (body ?? {}) ? (body.buildCommand ?? "") || null : undefined,
startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined,
});
if (!project) {
set.status = 404;
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/api/scaling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
deleteScalingPolicy,
getScalingPolicy,
upsertScalingPolicy,
getProjectById,
} from "../../db/repo";

export const scalingRoutes = new Elysia()
Expand All @@ -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,
Expand Down
94 changes: 49 additions & 45 deletions apps/api/src/databases/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,59 +24,63 @@ const tryRun = (cmd: string, args: string[]) =>
export const provisionDatabase = async (dbRecord: Database): Promise<void> => {
const containerName = dbRecord.internalHost;

const version = dbRecord.version || (dbRecord.type === 'mysql' ? '8.0' : '16-alpine');
const image = dbRecord.type === 'mysql' ? `mysql:${version}` : `postgres:${version}`;
try {
const version = dbRecord.version || (dbRecord.type === 'mysql' ? '8.0' : '16-alpine');
const image = dbRecord.type === 'mysql' ? `mysql:${version}` : `postgres:${version}`;

// Pull image first (non-blocking)
await tryRun(dockerBin, ['pull', image]);
await tryRun(dockerBin, ['pull', image]);

const envVars = dbRecord.type === 'mysql'
? [
`MYSQL_ROOT_PASSWORD=${dbRecord.password}`,
`MYSQL_DATABASE=${dbRecord.databaseName}`,
`MYSQL_USER=${dbRecord.username}`,
`MYSQL_PASSWORD=${dbRecord.password}`,
]
: [
`POSTGRES_USER=${dbRecord.username}`,
`POSTGRES_PASSWORD=${dbRecord.password}`,
`POSTGRES_DB=${dbRecord.databaseName}`,
];
const envVars = dbRecord.type === 'mysql'
? [
`MYSQL_ROOT_PASSWORD=${dbRecord.password}`,
`MYSQL_DATABASE=${dbRecord.databaseName}`,
`MYSQL_USER=${dbRecord.username}`,
`MYSQL_PASSWORD=${dbRecord.password}`,
]
: [
`POSTGRES_USER=${dbRecord.username}`,
`POSTGRES_PASSWORD=${dbRecord.password}`,
`POSTGRES_DB=${dbRecord.databaseName}`,
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const volumeName = `db-${dbRecord.id.slice(0, 12)}`;
const volumeName = `db-${dbRecord.id.slice(0, 12)}`;

// Create volume
await tryRun(dockerBin, ['volume', 'create', volumeName]);
await tryRun(dockerBin, ['volume', 'create', volumeName]);
await tryRun(dockerBin, ['rm', '-f', containerName]);

const args = [
'run', '-d',
'--name', containerName,
'--network', config.dockerNetwork,
'--network-alias', containerName,
'-l', DEQUEL_MANAGED_LABEL,
...(dbRecord.cpuLimit ? ['--cpus', String(dbRecord.cpuLimit)] : []),
...(dbRecord.memoryLimitMb ? ['--memory', `${Math.round(dbRecord.memoryLimitMb)}m`] : []),
'-v', `${volumeName}:/var/lib/${dbRecord.type === 'mysql' ? 'mysql' : 'postgresql/data'}`,
'-e', `TZ=UTC`,
...envVars.flatMap(e => ['-e', e]),
image,
];
const args = [
'run', '-d',
'--name', containerName,
'--network', config.dockerNetwork,
'--network-alias', containerName,
'-l', DEQUEL_MANAGED_LABEL,
...(dbRecord.cpuLimit ? ['--cpus', String(dbRecord.cpuLimit)] : []),
...(dbRecord.memoryLimitMb ? ['--memory', `${Math.round(dbRecord.memoryLimitMb)}m`] : []),
'-v', `${volumeName}:/var/lib/${dbRecord.type === 'mysql' ? 'mysql' : 'postgresql/data'}`,
'-e', `TZ=UTC`,
...envVars.flatMap(e => ['-e', e]),
image,
];

await run(dockerBin, args);
await run(dockerBin, args);

// Wait for healthy
for (let i = 0; i < 30; i++) {
try {
const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]);
if (status.trim() === 'running') {
await updateDatabaseStatus(dbRecord.id, 'running', containerName);
return;
}
} catch {}
await new Promise(r => setTimeout(r, 2000));
}
for (let i = 0; i < 30; i++) {
try {
const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]);
if (status.trim() === 'running') {
await updateDatabaseStatus(dbRecord.id, 'running', containerName);
return;
}
} catch {}
await new Promise(r => setTimeout(r, 2000));
}

await updateDatabaseStatus(dbRecord.id, 'failed', containerName);
await updateDatabaseStatus(dbRecord.id, 'failed', containerName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
console.error(`[DB Provisioner] Error provisioning database ${dbRecord.id}:`, err);
await updateDatabaseStatus(dbRecord.id, 'failed', containerName).catch(() => {});
throw err;
}
};

export const deprovisionDatabase = async (dbRecord: Database): Promise<void> => {
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const migrate = async () => {
drizzleMigrate(db, { migrationsFolder });

await addClearCacheColumn(db);
await addFinishedAtColumn(db);
await seedFromConfig();
};

Expand All @@ -45,6 +46,18 @@ const addClearCacheColumn = async (db: ReturnType<typeof getDrizzle>) => {
}
};

const addFinishedAtColumn = async (db: ReturnType<typeof getDrizzle>) => {
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();
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/db/migrations/0002_colossal_flatman.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `project_type` text DEFAULT 'web' NOT NULL;
2 changes: 2 additions & 0 deletions apps/api/src/db/migrations/0003_aberrant_viper.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `projects` ADD `build_command` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `start_command` text;
3 changes: 3 additions & 0 deletions apps/api/src/db/migrations/0004_early_sunfire.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE `projects` ADD `build_type` text DEFAULT 'railpack' NOT NULL;--> statement-breakpoint
ALTER TABLE `projects` ADD `compose_service` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `compose_port` integer;
2 changes: 2 additions & 0 deletions apps/api/src/db/migrations/0005_famous_grandmaster.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `domains` ADD `target_service` text;--> statement-breakpoint
ALTER TABLE `domains` ADD `target_port` integer;
Loading
Loading