blob: cd182495ac0f47111311321c3689918243984753 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import { PrismaClient } from "@prisma/client";
2import express from "express";
gioa71316d2025-05-24 09:41:36 +04003import fs from "node:fs";
giod0026612025-05-08 13:00:36 +00004import { env } from "node:process";
5import axios from "axios";
gioc31bf142025-06-16 07:48:20 +00006import { GithubClient } from "./github.js";
7import { AppManager } from "./app_manager.js";
gio7d813702025-05-08 18:29:52 +00008import { z } from "zod";
gio78a22882025-07-01 18:56:01 +00009import { ProjectMonitor, WorkerSchema, LogItem } from "./project_monitor.js";
gioa71316d2025-05-24 09:41:36 +040010import tmp from "tmp";
gioc31bf142025-06-16 07:48:20 +000011import { NodeJSAnalyzer } from "./lib/nodejs.js";
gioa71316d2025-05-24 09:41:36 +040012import shell from "shelljs";
gioc31bf142025-06-16 07:48:20 +000013import { RealFileSystem } from "./lib/fs.js";
gioa71316d2025-05-24 09:41:36 +040014import path from "node:path";
gio9b7421a2025-06-18 12:31:13 +000015import {
16 Env,
17 generateDodoConfig,
18 ConfigSchema,
19 AppNode,
20 ConfigWithInput,
21 configToGraph,
22 Network,
23 GithubRepository,
24} from "config";
gio78a22882025-07-01 18:56:01 +000025import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
gio40c0c992025-07-02 13:18:05 +000026import LogStore from "./log.js";
gioa71316d2025-05-24 09:41:36 +040027
28async function generateKey(root: string): Promise<[string, string]> {
29 const privKeyPath = path.join(root, "key");
30 const pubKeyPath = path.join(root, "key.pub");
31 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
32 throw new Error("Failed to generate SSH key pair");
33 }
34 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
35 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
36 return [publicKey, privateKey];
37}
giod0026612025-05-08 13:00:36 +000038
39const db = new PrismaClient();
gio40c0c992025-07-02 13:18:05 +000040const logStore = new LogStore(db);
gio3ed59592025-05-14 16:51:09 +000041const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000042
gioa1efbad2025-05-21 07:16:45 +000043const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000044
giod0026612025-05-08 13:00:36 +000045const handleProjectCreate: express.Handler = async (req, resp) => {
46 try {
gioa71316d2025-05-24 09:41:36 +040047 const tmpDir = tmp.dirSync().name;
48 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000049 const { id } = await db.project.create({
50 data: {
gio09fcab52025-05-12 14:05:07 +000051 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000052 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040053 deployKey: privateKey,
54 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000055 },
56 });
57 resp.status(200);
58 resp.header("Content-Type", "application/json");
59 resp.write(
60 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000061 id: id.toString(),
giod0026612025-05-08 13:00:36 +000062 }),
63 );
64 } catch (e) {
65 console.log(e);
66 resp.status(500);
67 } finally {
68 resp.end();
69 }
70};
71
72const handleProjectAll: express.Handler = async (req, resp) => {
73 try {
74 const r = await db.project.findMany({
75 where: {
gio09fcab52025-05-12 14:05:07 +000076 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000077 },
78 });
79 resp.status(200);
80 resp.header("Content-Type", "application/json");
81 resp.write(
82 JSON.stringify(
83 r.map((p) => ({
84 id: p.id.toString(),
85 name: p.name,
86 })),
87 ),
88 );
89 } catch (e) {
90 console.log(e);
91 resp.status(500);
92 } finally {
93 resp.end();
94 }
95};
96
97const handleSave: express.Handler = async (req, resp) => {
98 try {
99 await db.project.update({
100 where: {
101 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000102 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000103 },
104 data: {
giobd37a2b2025-05-15 04:28:42 +0000105 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +0000106 },
107 });
108 resp.status(200);
109 } catch (e) {
110 console.log(e);
111 resp.status(500);
112 } finally {
113 resp.end();
114 }
115};
116
gio818da4e2025-05-12 14:45:35 +0000117function handleSavedGet(state: "deploy" | "draft"): express.Handler {
118 return async (req, resp) => {
119 try {
120 const r = await db.project.findUnique({
121 where: {
122 id: Number(req.params["projectId"]),
123 userId: resp.locals.userId,
124 },
125 select: {
126 state: true,
127 draft: true,
128 },
129 });
130 if (r == null) {
131 resp.status(404);
132 return;
133 }
giod0026612025-05-08 13:00:36 +0000134 resp.status(200);
135 resp.header("content-type", "application/json");
gioc31bf142025-06-16 07:48:20 +0000136 let currentState: Record<string, unknown> | null = null;
gio818da4e2025-05-12 14:45:35 +0000137 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000138 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000139 currentState = {
giod0026612025-05-08 13:00:36 +0000140 nodes: [],
141 edges: [],
142 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000143 };
giod0026612025-05-08 13:00:36 +0000144 } else {
gioc31bf142025-06-16 07:48:20 +0000145 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
giod0026612025-05-08 13:00:36 +0000146 }
147 } else {
gio818da4e2025-05-12 14:45:35 +0000148 if (r.draft == null) {
149 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000150 currentState = {
gio818da4e2025-05-12 14:45:35 +0000151 nodes: [],
152 edges: [],
153 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000154 };
gio818da4e2025-05-12 14:45:35 +0000155 } else {
gioc31bf142025-06-16 07:48:20 +0000156 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000157 }
158 } else {
gioc31bf142025-06-16 07:48:20 +0000159 currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000160 }
giod0026612025-05-08 13:00:36 +0000161 }
gioc31bf142025-06-16 07:48:20 +0000162 const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
163 if (currentState) {
164 const config = generateDodoConfig(
165 req.params["projectId"].toString(),
166 currentState.nodes as AppNode[],
167 env,
168 );
169 resp.send({
170 state: currentState,
171 config,
172 });
173 }
gio818da4e2025-05-12 14:45:35 +0000174 } catch (e) {
175 console.log(e);
176 resp.status(500);
177 } finally {
178 resp.end();
giod0026612025-05-08 13:00:36 +0000179 }
gio818da4e2025-05-12 14:45:35 +0000180 };
181}
giod0026612025-05-08 13:00:36 +0000182
gioa71316d2025-05-24 09:41:36 +0400183const projectDeleteReqSchema = z.object({
184 state: z.optional(z.nullable(z.string())),
185});
186
187const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000188 try {
189 const projectId = Number(req.params["projectId"]);
190 const p = await db.project.findUnique({
191 where: {
192 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000193 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000194 },
195 select: {
196 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400197 githubToken: true,
198 deployKeyPublic: true,
199 state: true,
200 draft: true,
giod0026612025-05-08 13:00:36 +0000201 },
202 });
203 if (p === null) {
204 resp.status(404);
205 return;
206 }
gioa71316d2025-05-24 09:41:36 +0400207 const parseResult = projectDeleteReqSchema.safeParse(req.body);
208 if (!parseResult.success) {
209 resp.status(400);
210 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
211 return;
gioe440db82025-05-13 12:21:44 +0000212 }
gioa71316d2025-05-24 09:41:36 +0400213 if (p.githubToken && p.deployKeyPublic) {
214 const allRepos = [
215 ...new Set([
216 ...extractGithubRepos(p.state),
217 ...extractGithubRepos(p.draft),
218 ...extractGithubRepos(parseResult.data.state),
219 ]),
220 ];
221 if (allRepos.length > 0) {
222 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
223 const github = new GithubClient(p.githubToken);
224 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
225 console.log(
226 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
227 );
228 }
giod0026612025-05-08 13:00:36 +0000229 }
gioa71316d2025-05-24 09:41:36 +0400230 if (p.instanceId !== null) {
231 if (!(await appManager.removeInstance(p.instanceId))) {
232 resp.status(500);
233 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
234 return;
235 }
236 }
237 await db.project.delete({
238 where: {
239 id: projectId,
240 },
241 });
giod0026612025-05-08 13:00:36 +0000242 resp.status(200);
243 } catch (e) {
244 console.log(e);
245 resp.status(500);
246 } finally {
247 resp.end();
248 }
249};
250
gioa71316d2025-05-24 09:41:36 +0400251function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000252 if (!serializedState) {
253 return [];
254 }
255 try {
giobd37a2b2025-05-15 04:28:42 +0000256 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000257 const githubNodes = stateObj.nodes.filter(
258 // eslint-disable-next-line @typescript-eslint/no-explicit-any
259 (n: any) => n.type === "github" && n.data?.repository?.id,
260 );
261 // eslint-disable-next-line @typescript-eslint/no-explicit-any
262 return githubNodes.map((n: any) => n.data.repository.sshURL);
263 } catch (error) {
264 console.error("Failed to parse state or extract GitHub repos:", error);
265 return [];
266 }
267}
268
269type RepoDiff = {
270 toAdd?: string[];
271 toDelete?: string[];
272};
273
274function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
275 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
276 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
277 return { toAdd, toDelete };
278}
279
gio76d8ae62025-05-19 15:21:54 +0000280async function manageGithubRepos(
281 github: GithubClient,
282 diff: RepoDiff,
283 deployKey: string,
284 publicAddr?: string,
285): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000286 for (const repoUrl of diff.toDelete ?? []) {
287 try {
288 await github.removeDeployKey(repoUrl, deployKey);
289 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000290 if (publicAddr) {
291 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
292 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
293 console.log(`Removed push webhook from repository ${repoUrl}`);
294 }
gio3ed59592025-05-14 16:51:09 +0000295 } catch (error) {
296 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
297 }
298 }
299 for (const repoUrl of diff.toAdd ?? []) {
300 try {
301 await github.addDeployKey(repoUrl, deployKey);
302 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000303 if (publicAddr) {
304 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
305 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
306 console.log(`Added push webhook to repository ${repoUrl}`);
307 }
gio3ed59592025-05-14 16:51:09 +0000308 } catch (error) {
309 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
310 }
311 }
312}
313
giod0026612025-05-08 13:00:36 +0000314const handleDeploy: express.Handler = async (req, resp) => {
315 try {
316 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000317 const p = await db.project.findUnique({
318 where: {
319 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000320 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000321 },
322 select: {
323 instanceId: true,
324 githubToken: true,
325 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400326 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000327 state: true,
gio69148322025-06-19 23:16:12 +0400328 geminiApiKey: true,
giod0026612025-05-08 13:00:36 +0000329 },
330 });
331 if (p === null) {
332 resp.status(404);
333 return;
334 }
gioc31bf142025-06-16 07:48:20 +0000335 const config = ConfigSchema.safeParse(req.body.config);
336 if (!config.success) {
337 resp.status(400);
338 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
339 return;
340 }
gio9b7421a2025-06-18 12:31:13 +0000341 let repos: GithubRepository[] = [];
342 if (p.githubToken) {
343 const github = new GithubClient(p.githubToken);
344 repos = await github.getRepositories();
345 }
gioc31bf142025-06-16 07:48:20 +0000346 const state = req.body.state
347 ? JSON.stringify(req.body.state)
348 : JSON.stringify(
349 configToGraph(
350 config.data,
351 getNetworks(resp.locals.username),
gio9b7421a2025-06-18 12:31:13 +0000352 repos,
gioc31bf142025-06-16 07:48:20 +0000353 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
354 ),
355 );
giod0026612025-05-08 13:00:36 +0000356 await db.project.update({
357 where: {
358 id: projectId,
359 },
360 data: {
361 draft: state,
362 },
363 });
gioa71316d2025-05-24 09:41:36 +0400364 let deployKey: string | null = p.deployKey;
365 let deployKeyPublic: string | null = p.deployKeyPublic;
366 if (deployKeyPublic == null) {
367 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
368 await db.project.update({
369 where: { id: projectId },
370 data: { deployKeyPublic, deployKey },
371 });
372 }
gio3ed59592025-05-14 16:51:09 +0000373 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000374 const cfg: ConfigWithInput = {
375 ...config.data,
376 input: {
377 appId: projectId.toString(),
378 managerAddr: env.INTERNAL_API_ADDR!,
379 key: {
380 public: deployKeyPublic!,
381 private: deployKey!,
382 },
gio69148322025-06-19 23:16:12 +0400383 geminiApiKey: p.geminiApiKey ?? undefined,
gioc31bf142025-06-16 07:48:20 +0000384 },
gioa71316d2025-05-24 09:41:36 +0400385 };
gio3ed59592025-05-14 16:51:09 +0000386 try {
387 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000388 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000389 await db.project.update({
390 where: {
391 id: projectId,
392 },
393 data: {
394 state,
395 draft: null,
gio3ed59592025-05-14 16:51:09 +0000396 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000397 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000398 },
399 });
gio3ed59592025-05-14 16:51:09 +0000400 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000401 } else {
gioc31bf142025-06-16 07:48:20 +0000402 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000403 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000404 await db.project.update({
405 where: {
406 id: projectId,
407 },
408 data: {
409 state,
410 draft: null,
411 access: JSON.stringify(deployResponse.access),
412 },
413 });
giod0026612025-05-08 13:00:36 +0000414 }
gio3ed59592025-05-14 16:51:09 +0000415 if (diff && p.githubToken && deployKey) {
416 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400417 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000418 }
gio3ed59592025-05-14 16:51:09 +0000419 resp.status(200);
420 } catch (error) {
421 console.error("Deployment error:", error);
422 resp.status(500);
423 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000424 }
425 } catch (e) {
426 console.log(e);
427 resp.status(500);
428 } finally {
429 resp.end();
430 }
431};
432
433const handleStatus: express.Handler = async (req, resp) => {
434 try {
435 const projectId = Number(req.params["projectId"]);
436 const p = await db.project.findUnique({
437 where: {
438 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000439 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000440 },
441 select: {
442 instanceId: true,
443 },
444 });
giod0026612025-05-08 13:00:36 +0000445 if (p === null) {
446 resp.status(404);
447 return;
448 }
449 if (p.instanceId == null) {
450 resp.status(404);
451 return;
452 }
gio3ed59592025-05-14 16:51:09 +0000453 try {
454 const status = await appManager.getStatus(p.instanceId);
455 resp.status(200);
456 resp.write(JSON.stringify(status));
457 } catch (error) {
458 console.error("Error getting status:", error);
459 resp.status(500);
giod0026612025-05-08 13:00:36 +0000460 }
461 } catch (e) {
462 console.log(e);
463 resp.status(500);
464 } finally {
465 resp.end();
466 }
467};
468
gioc31bf142025-06-16 07:48:20 +0000469const handleConfigGet: express.Handler = async (req, resp) => {
470 try {
471 const projectId = Number(req.params["projectId"]);
472 const project = await db.project.findUnique({
473 where: {
474 id: projectId,
475 },
476 select: {
477 state: true,
478 },
479 });
480
481 if (!project || !project.state) {
482 resp.status(404).send({ error: "No deployed configuration found." });
483 return;
484 }
485
486 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
487 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
488 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
489
490 if (!config) {
491 resp.status(500).send({ error: "Failed to generate configuration." });
492 return;
493 }
494 resp.status(200).json(config);
495 } catch (e) {
496 console.log(e);
497 resp.status(500).send({ error: "Internal server error" });
498 } finally {
499 console.log("config get done");
500 resp.end();
501 }
502};
503
giobd37a2b2025-05-15 04:28:42 +0000504const handleRemoveDeployment: express.Handler = async (req, resp) => {
505 try {
506 const projectId = Number(req.params["projectId"]);
507 const p = await db.project.findUnique({
508 where: {
509 id: projectId,
510 userId: resp.locals.userId,
511 },
512 select: {
513 instanceId: true,
514 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400515 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000516 state: true,
517 draft: true,
518 },
519 });
520 if (p === null) {
521 resp.status(404);
522 resp.write(JSON.stringify({ error: "Project not found" }));
523 return;
524 }
525 if (p.instanceId == null) {
526 resp.status(400);
527 resp.write(JSON.stringify({ error: "Project not deployed" }));
528 return;
529 }
530 const removed = await appManager.removeInstance(p.instanceId);
531 if (!removed) {
532 resp.status(500);
533 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
534 return;
535 }
gioa71316d2025-05-24 09:41:36 +0400536 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000537 try {
538 const github = new GithubClient(p.githubToken);
539 const repos = extractGithubRepos(p.state);
540 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400541 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000542 } catch (error) {
543 console.error("Error removing GitHub deploy keys:", error);
544 }
545 }
546 await db.project.update({
547 where: {
548 id: projectId,
549 },
550 data: {
551 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400552 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000553 access: null,
giobd37a2b2025-05-15 04:28:42 +0000554 state: null,
555 draft: p.draft ?? p.state,
556 },
557 });
558 resp.status(200);
559 resp.write(JSON.stringify({ success: true }));
560 } catch (e) {
561 console.error("Error removing deployment:", e);
562 resp.status(500);
563 resp.write(JSON.stringify({ error: "Internal server error" }));
564 } finally {
565 resp.end();
566 }
567};
568
giod0026612025-05-08 13:00:36 +0000569const handleGithubRepos: express.Handler = async (req, resp) => {
570 try {
571 const projectId = Number(req.params["projectId"]);
572 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000573 where: {
574 id: projectId,
575 userId: resp.locals.userId,
576 },
577 select: {
578 githubToken: true,
579 },
giod0026612025-05-08 13:00:36 +0000580 });
giod0026612025-05-08 13:00:36 +0000581 if (!project?.githubToken) {
582 resp.status(400);
583 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
584 return;
585 }
giod0026612025-05-08 13:00:36 +0000586 const github = new GithubClient(project.githubToken);
587 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000588 resp.status(200);
589 resp.header("Content-Type", "application/json");
590 resp.write(JSON.stringify(repositories));
591 } catch (e) {
592 console.log(e);
593 resp.status(500);
594 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
595 } finally {
596 resp.end();
597 }
598};
599
600const handleUpdateGithubToken: express.Handler = async (req, resp) => {
601 try {
giod0026612025-05-08 13:00:36 +0000602 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000603 where: {
gio69148322025-06-19 23:16:12 +0400604 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000605 userId: resp.locals.userId,
606 },
gio69148322025-06-19 23:16:12 +0400607 data: {
608 githubToken: req.body.githubToken,
609 },
610 });
611 resp.status(200);
612 } catch (e) {
613 console.log(e);
614 resp.status(500);
615 } finally {
616 resp.end();
617 }
618};
619
620const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
621 try {
622 await db.project.update({
623 where: {
624 id: Number(req.params["projectId"]),
625 userId: resp.locals.userId,
626 },
627 data: {
628 geminiApiKey: req.body.geminiApiKey,
629 },
giod0026612025-05-08 13:00:36 +0000630 });
giod0026612025-05-08 13:00:36 +0000631 resp.status(200);
632 } catch (e) {
633 console.log(e);
634 resp.status(500);
635 } finally {
636 resp.end();
637 }
638};
639
gioc31bf142025-06-16 07:48:20 +0000640const getNetworks = (username?: string | undefined): Network[] => {
641 return [
642 {
643 name: "Trial",
644 domain: "trial.dodoapp.xyz",
645 hasAuth: false,
646 },
647 // TODO(gio): Remove
648 ].concat(
649 username === "gio" || 1 == 1
650 ? [
651 {
652 name: "Public",
653 domain: "v1.dodo.cloud",
654 hasAuth: true,
655 },
656 {
657 name: "Private",
658 domain: "p.v1.dodo.cloud",
659 hasAuth: true,
660 },
661 ]
662 : [],
663 );
664};
665
666const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
667 const project = await db.project.findUnique({
668 where: {
669 id: projectId,
670 userId,
671 },
672 select: {
673 deployKeyPublic: true,
674 githubToken: true,
gio69148322025-06-19 23:16:12 +0400675 geminiApiKey: true,
gioc31bf142025-06-16 07:48:20 +0000676 access: true,
677 instanceId: true,
678 },
679 });
680 if (!project) {
681 throw new Error("Project not found");
682 }
683 const monitor = projectMonitors.get(projectId);
684 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
685 const services = serviceNames.map((name: string) => ({
686 name,
687 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
688 ([id, status]) => ({
689 ...status,
690 id,
691 }),
692 ),
693 }));
694 return {
gioc31bf142025-06-16 07:48:20 +0000695 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
696 instanceId: project.instanceId == null ? undefined : project.instanceId,
697 access: JSON.parse(project.access ?? "[]"),
698 integrations: {
699 github: !!project.githubToken,
gio69148322025-06-19 23:16:12 +0400700 gemini: !!project.geminiApiKey,
gioc31bf142025-06-16 07:48:20 +0000701 },
702 networks: getNetworks(username),
703 services,
704 user: {
705 id: userId,
706 username: username,
707 },
708 };
709};
710
giod0026612025-05-08 13:00:36 +0000711const handleEnv: express.Handler = async (req, resp) => {
712 const projectId = Number(req.params["projectId"]);
713 try {
gioc31bf142025-06-16 07:48:20 +0000714 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000715 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000716 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000717 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000718 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000719 resp.status(500);
720 resp.write(JSON.stringify({ error: "Internal server error" }));
721 } finally {
722 resp.end();
723 }
724};
725
gio3a921b82025-05-10 07:36:09 +0000726const handleServiceLogs: express.Handler = async (req, resp) => {
gio78a22882025-07-01 18:56:01 +0000727 const projectId = Number(req.params["projectId"]);
728 const service = req.params["service"];
729 const workerId = req.params["workerId"];
730
731 resp.setHeader("Content-Type", "text/event-stream");
732 resp.setHeader("Cache-Control", "no-cache");
733 resp.setHeader("Connection", "keep-alive");
734 resp.flushHeaders();
735
736 const timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
737 const sendLogs = (logs: LogItem[]) => {
738 if (logs.length == 0) {
739 return;
740 }
741 const logString = logs
742 .map((l) => {
743 const t = Instant.ofEpochMilli(l.timestampMilli);
744 const formattedTimestamp = t.atZone(ZoneId.UTC).format(timestampFormat);
745 return `\x1b[38;5;240m${formattedTimestamp}\x1b[0m ${l.contents}`;
746 })
747 .join("\n");
748 resp.write("event: message\n");
749 resp.write(`data: ${JSON.stringify({ logs: logString })}\n\n`);
750 };
751
gio3a921b82025-05-10 07:36:09 +0000752 try {
gio09fcab52025-05-12 14:05:07 +0000753 const project = await db.project.findUnique({
gio78a22882025-07-01 18:56:01 +0000754 where: { id: projectId, userId: resp.locals.userId },
gio09fcab52025-05-12 14:05:07 +0000755 });
gio78a22882025-07-01 18:56:01 +0000756
757 if (!project) {
758 resp.status(404).end();
gio09fcab52025-05-12 14:05:07 +0000759 return;
760 }
gio78a22882025-07-01 18:56:01 +0000761
gioa1efbad2025-05-21 07:16:45 +0000762 const monitor = projectMonitors.get(projectId);
gio78a22882025-07-01 18:56:01 +0000763 if (!monitor) {
764 resp.status(404).end();
gio3a921b82025-05-10 07:36:09 +0000765 return;
766 }
gio78a22882025-07-01 18:56:01 +0000767
gio40c0c992025-07-02 13:18:05 +0000768 let lastLogId: number | undefined = undefined;
769 const initialLogs = (await logStore.get(projectId, service, workerId)) || [];
gio78a22882025-07-01 18:56:01 +0000770 sendLogs(initialLogs);
gio40c0c992025-07-02 13:18:05 +0000771 if (initialLogs.length > 0) {
772 lastLogId = initialLogs[initialLogs.length - 1].id;
773 }
gio78a22882025-07-01 18:56:01 +0000774 resp.flushHeaders();
775
gio40c0c992025-07-02 13:18:05 +0000776 const intervalId = setInterval(async () => {
777 const currentLogs = (await logStore.get(projectId, service, workerId, lastLogId)) || [];
778 if (currentLogs.length > 0) {
779 sendLogs(currentLogs);
780 lastLogId = currentLogs[currentLogs.length - 1].id;
gio78a22882025-07-01 18:56:01 +0000781 }
782 }, 500);
783
784 req.on("close", () => {
785 clearInterval(intervalId);
786 resp.end();
787 });
gio3a921b82025-05-10 07:36:09 +0000788 } catch (e) {
789 console.log(e);
gio78a22882025-07-01 18:56:01 +0000790 resp.status(500).end();
gio3a921b82025-05-10 07:36:09 +0000791 }
792};
793
gio7d813702025-05-08 18:29:52 +0000794const handleRegisterWorker: express.Handler = async (req, resp) => {
795 try {
796 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000797 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000798 if (!result.success) {
gioa70535a2025-07-02 15:50:25 +0000799 console.log(JSON.stringify(result.error));
gio7d813702025-05-08 18:29:52 +0000800 resp.status(400);
801 resp.write(
802 JSON.stringify({
803 error: "Invalid request data",
804 details: result.error.format(),
805 }),
806 );
807 return;
808 }
gioa1efbad2025-05-21 07:16:45 +0000809 let monitor = projectMonitors.get(projectId);
810 if (!monitor) {
811 monitor = new ProjectMonitor();
812 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000813 }
gioa1efbad2025-05-21 07:16:45 +0000814 monitor.registerWorker(result.data);
gio40c0c992025-07-02 13:18:05 +0000815 if (result.data.logs) {
816 await logStore.store(projectId, result.data.service, result.data.id, result.data.logs);
817 }
gio7d813702025-05-08 18:29:52 +0000818 resp.status(200);
819 resp.write(
820 JSON.stringify({
821 success: true,
gio78a22882025-07-01 18:56:01 +0000822 logItemsConsumed: result.data.logs?.length ?? 0,
gio7d813702025-05-08 18:29:52 +0000823 }),
824 );
825 } catch (e) {
826 console.log(e);
827 resp.status(500);
828 resp.write(JSON.stringify({ error: "Failed to register worker" }));
829 } finally {
830 resp.end();
831 }
832};
833
gio76d8ae62025-05-19 15:21:54 +0000834async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000835 const monitor = projectMonitors.get(projectId);
836 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000837 const workerCount = projectWorkers.length;
838 if (workerCount === 0) {
839 return true;
840 }
841 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000842 projectWorkers.map(async (workerAddress: string) => {
843 try {
844 const { data } = await axios.get(`http://${workerAddress}/reload`);
845 return data.every((s: { status: string }) => s.status === "ok");
846 } catch (error) {
847 console.error(`Failed to reload worker ${workerAddress}:`, error);
848 return false;
849 }
gio76d8ae62025-05-19 15:21:54 +0000850 }),
851 );
gioc31bf142025-06-16 07:48:20 +0000852 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000853}
854
gio7d813702025-05-08 18:29:52 +0000855const handleReload: express.Handler = async (req, resp) => {
856 try {
857 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000858 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000859 where: {
860 id: projectId,
861 userId: resp.locals.userId,
862 },
gio76d8ae62025-05-19 15:21:54 +0000863 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000864 });
gio76d8ae62025-05-19 15:21:54 +0000865 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000866 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000867 return;
868 }
gio76d8ae62025-05-19 15:21:54 +0000869 const success = await reloadProject(projectId);
870 if (success) {
871 resp.status(200);
872 } else {
873 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000874 }
gio7d813702025-05-08 18:29:52 +0000875 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000876 console.error(e);
gio7d813702025-05-08 18:29:52 +0000877 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000878 }
879};
880
gio918780d2025-05-22 08:24:41 +0000881const handleReloadWorker: express.Handler = async (req, resp) => {
882 const projectId = Number(req.params["projectId"]);
883 const serviceName = req.params["serviceName"];
884 const workerId = req.params["workerId"];
885
886 const projectMonitor = projectMonitors.get(projectId);
887 if (!projectMonitor) {
888 resp.status(404).send({ error: "Project monitor not found" });
889 return;
890 }
891
892 try {
893 await projectMonitor.reloadWorker(serviceName, workerId);
894 resp.status(200).send({ message: "Worker reload initiated" });
895 } catch (error) {
896 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
897 const errorMessage = error instanceof Error ? error.message : "Unknown error";
898 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
899 }
900};
901
gioa71316d2025-05-24 09:41:36 +0400902const analyzeRepoReqSchema = z.object({
903 address: z.string(),
904});
905
906const handleAnalyzeRepo: express.Handler = async (req, resp) => {
907 const projectId = Number(req.params["projectId"]);
908 const project = await db.project.findUnique({
909 where: {
910 id: projectId,
911 userId: resp.locals.userId,
912 },
913 select: {
914 githubToken: true,
915 deployKey: true,
916 deployKeyPublic: true,
917 },
918 });
919 if (!project) {
920 resp.status(404).send({ error: "Project not found" });
921 return;
922 }
923 if (!project.githubToken) {
924 resp.status(400).send({ error: "GitHub token not configured" });
925 return;
926 }
gio8e74dc02025-06-13 10:19:26 +0000927 let tmpDir: tmp.DirResult | null = null;
928 try {
929 let deployKey: string | null = project.deployKey;
930 let deployKeyPublic: string | null = project.deployKeyPublic;
931 if (!deployKeyPublic) {
932 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
933 await db.project.update({
934 where: { id: projectId },
935 data: {
936 deployKeyPublic: deployKeyPublic,
937 deployKey: deployKey,
938 },
939 });
940 }
941 const github = new GithubClient(project.githubToken);
942 const result = analyzeRepoReqSchema.safeParse(req.body);
943 if (!result.success) {
944 resp.status(400).send({ error: "Invalid request data" });
945 return;
946 }
947 const { address } = result.data;
948 tmpDir = tmp.dirSync({
949 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400950 });
gio8e74dc02025-06-13 10:19:26 +0000951 await github.addDeployKey(address, deployKeyPublic);
952 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
953 mode: 0o600,
954 });
955 shell.exec(
956 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
957 );
958 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
959 const analyzer = new NodeJSAnalyzer();
960 const info = await analyzer.analyze(fsc, "/");
961 resp.status(200).send([info]);
962 } catch (e) {
963 console.error(e);
964 resp.status(500).send({ error: "Failed to analyze repository" });
965 } finally {
966 if (tmpDir) {
967 tmpDir.removeCallback();
968 }
969 resp.end();
gioa71316d2025-05-24 09:41:36 +0400970 }
gioa71316d2025-05-24 09:41:36 +0400971};
972
gio09fcab52025-05-12 14:05:07 +0000973const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
gio69148322025-06-19 23:16:12 +0400974 // Hardcoded user for development
975 resp.locals.userId = "1";
976 resp.locals.username = "gio";
gio09fcab52025-05-12 14:05:07 +0000977 next();
978};
979
gio76d8ae62025-05-19 15:21:54 +0000980const handleGithubPushWebhook: express.Handler = async (req, resp) => {
981 try {
982 // TODO(gio): Implement GitHub signature verification for security
983 const webhookSchema = z.object({
984 repository: z.object({
985 ssh_url: z.string(),
986 }),
987 });
988
989 const result = webhookSchema.safeParse(req.body);
990 if (!result.success) {
991 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
992 resp.status(400).json({ error: "Invalid webhook payload" });
993 return;
994 }
995 const { ssh_url: addr } = result.data.repository;
996 const allProjects = await db.project.findMany({
997 select: {
998 id: true,
999 state: true,
1000 },
1001 where: {
1002 instanceId: {
1003 not: null,
1004 },
1005 },
1006 });
1007 // TODO(gio): This should run in background
1008 new Promise<boolean>((resolve, reject) => {
1009 setTimeout(() => {
1010 const projectsToReloadIds: number[] = [];
1011 for (const project of allProjects) {
1012 if (project.state && project.state.length > 0) {
1013 const projectRepos = extractGithubRepos(project.state);
1014 if (projectRepos.includes(addr)) {
1015 projectsToReloadIds.push(project.id);
1016 }
1017 }
1018 }
1019 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
1020 .then((results) => {
1021 resolve(results.reduce((acc, curr) => acc && curr, true));
1022 })
1023 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1024 .catch((reason: any) => reject(reason));
1025 }, 10);
1026 });
1027 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1028 } catch (error: any) {
1029 console.error(error);
1030 resp.status(500);
1031 }
1032};
1033
gioc31bf142025-06-16 07:48:20 +00001034const handleValidateConfig: express.Handler = async (req, resp) => {
1035 try {
1036 const validationResult = ConfigSchema.safeParse(req.body);
1037 if (!validationResult.success) {
1038 resp.status(400);
1039 resp.header("Content-Type", "application/json");
1040 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
1041 } else {
1042 resp.status(200);
1043 resp.header("Content-Type", "application/json");
1044 resp.write(JSON.stringify({ success: true }));
1045 }
1046 } catch (e) {
1047 console.log(e);
1048 resp.status(500);
1049 } finally {
1050 resp.end();
1051 }
1052};
1053
giod0026612025-05-08 13:00:36 +00001054async function start() {
1055 await db.$connect();
1056 const app = express();
gioc31bf142025-06-16 07:48:20 +00001057 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001058 app.use(express.json()); // Global JSON parsing
1059
1060 // Public webhook route - no auth needed
1061 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1062
1063 // Authenticated project routes
1064 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001065 projectRouter.use(auth);
1066 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001067 projectRouter.post("/:projectId/saved", handleSave);
1068 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1069 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1070 projectRouter.post("/:projectId/deploy", handleDeploy);
1071 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001072 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001073 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001074 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1075 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
gio69148322025-06-19 23:16:12 +04001076 projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
gio76d8ae62025-05-19 15:21:54 +00001077 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001078 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +00001079 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001080 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001081 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1082 projectRouter.get("/", handleProjectAll);
1083 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001084
gio76d8ae62025-05-19 15:21:54 +00001085 app.use("/api/project", projectRouter); // Mount the authenticated router
1086
giod0026612025-05-08 13:00:36 +00001087 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001088
gio76d8ae62025-05-19 15:21:54 +00001089 const internalApi = express();
1090 internalApi.use(express.json());
1091 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001092 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1093 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1094 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001095
giod0026612025-05-08 13:00:36 +00001096 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001097 console.log("Web server started on port", env.DODO_PORT_WEB);
1098 });
1099
gio76d8ae62025-05-19 15:21:54 +00001100 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001101 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001102 });
1103}
1104
1105start();