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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ bump.sh
scripts/workflow/bump.sh
__pycache__
.tegami/changes-*
SETUP_GUIDE.md
bun.lock
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"drizzle-orm": "^0.45.2",
"elysia": "^1.1.26",
"ioredis": "^5.4.1",
"nodemailer": "^8.0.10"
"nodemailer": "^8.0.10",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/nodemailer": "^8.0.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/api/deployments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ export const deploymentsRoutes = new Elysia()
error: "Cannot rollback to a deployment that is still in progress",
};
}
const project = target.projectId ? await getProjectById(target.projectId) : null;
if (project?.buildType === "compose") {
set.status = 400;
return {
error: "Rollback is not supported for Docker Compose deployments",
};
}
try {
await orchestrator.rollbackTo(id);
const updated = await getDeploymentById(id);
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 on lines 33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve target routing for domains verified after deployment.

This route persists targetService and targetPort, but the verification flow calls addToCaddyRoute without either value. addToCaddyRoute only appends the domain to the primary Caddy block. A target domain added after deployment therefore routes to the default container and port until a later redeploy regenerates the snippet.

Update addToCaddyRoute and both verification call sites to generate a dedicated proxy block from the persisted Domain, or regenerate the project snippet from persisted domain records.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/api/domains/index.ts` around lines 33 - 38, Update
addToCaddyRoute and both domain-verification call sites so verified domains use
their persisted Domain targetService and targetPort values when generating Caddy
routing. Generate a dedicated proxy block for targeted domains instead of always
appending to the primary Caddy block, while preserving existing routing for
domains without targets.

});
const { validateDomain, resolveServerIp } = await import(
"../../utils/dns",
Expand Down
36 changes: 36 additions & 0 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,6 +52,11 @@ export const projectsRoutes = new Elysia()
set.status = 400;
return { error: "name is required" };
}
const validationError = validateComposeFields(body);
if (validationError) {
set.status = 400;
return { error: validationError };
}
const project = await createProject({
name: body.name,
description: body.description,
Expand All @@ -46,6 +69,10 @@ export const projectsRoutes = new Elysia()
sourceDir: body.sourceDir || null,
sourceType: body.sourceType || "git",
projectType: body.projectType || "web",
buildType: body.buildType || "railpack",
composeService: body.composeService || null,
composePort: body.composePort ? Number(body.composePort) : null,
composeServices: body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null,
buildCommand: body.buildCommand || undefined,
startCommand: body.startCommand || undefined,
});
Expand All @@ -55,6 +82,11 @@ export const projectsRoutes = new Elysia()
.patch(
"/projects/:id",
async ({ params: { id }, body, set }: any) => {
const validationError = validateComposeFields(body);
if (validationError) {
set.status = 400;
return { error: validationError };
}
const project = await updateProject(id, {
name: body?.name,
description: body?.description,
Expand All @@ -66,6 +98,10 @@ export const projectsRoutes = new Elysia()
port: body?.port ? Number(body.port) : body?.port === null ? null : undefined,
sourceDir: body?.sourceDir ?? undefined,
projectType: body?.projectType ?? undefined,
buildType: body?.buildType ?? undefined,
composeService: "composeService" in (body ?? {}) ? (body.composeService || null) : undefined,
composePort: "composePort" in (body ?? {}) ? (body.composePort ? Number(body.composePort) : null) : undefined,
composeServices: "composeServices" in (body ?? {}) ? (body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null) : undefined,
buildCommand: "buildCommand" in (body ?? {}) ? (body.buildCommand ?? "") || null : undefined,
startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined,
});
Expand Down
95 changes: 50 additions & 45 deletions apps/api/src/databases/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,59 +24,64 @@ 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}`,
];

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 on lines +67 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail the provisioning operation after the readiness timeout.

At Line 78, the function records failed and then resolves normally. Callers can treat the provisioning operation as successful and continue with a database that never reached running.

Throw a timeout error here. The existing catch block will record the failed status, log the error, and propagate the failure.

Proposed fix
-    await updateDatabaseStatus(dbRecord.id, 'failed', containerName);
+    throw new Error(`Database ${dbRecord.id} did not reach running state before the readiness timeout`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
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));
}
throw new Error(`Database ${dbRecord.id} did not reach running state before the readiness timeout`);
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/databases/manager.ts` around lines 67 - 78, Update the
provisioning flow after the readiness loop to throw a timeout error instead of
resolving normally after updateDatabaseStatus records the database as failed.
Ensure the existing catch handling captures and propagates this error while
preserving the failed status update.

throw new Error(`Database ${containerName} failed to become ready within 60 seconds`);
} catch (err) {
console.error(`[DB Provisioner] Error provisioning database ${dbRecord.id}:`, err);
await updateDatabaseStatus(dbRecord.id, 'failed', containerName).catch(() => {});
throw err;
}
};

export const deprovisionDatabase = async (dbRecord: Database): Promise<void> => {
Expand Down
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;
1 change: 1 addition & 0 deletions apps/api/src/db/migrations/0006_cheerful_vance_astro.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `compose_services` text;
Loading
Loading