blob: 4190f70b6b930e9a000c21a7c877f437b95252cb [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";
gioc31bf142025-06-16 07:48:20 +00009import { ProjectMonitor, WorkerSchema } 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";
gioa71316d2025-05-24 09:41:36 +040025
26async function generateKey(root: string): Promise<[string, string]> {
27 const privKeyPath = path.join(root, "key");
28 const pubKeyPath = path.join(root, "key.pub");
29 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
30 throw new Error("Failed to generate SSH key pair");
31 }
32 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
33 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
34 return [publicKey, privateKey];
35}
giod0026612025-05-08 13:00:36 +000036
37const db = new PrismaClient();
gio3ed59592025-05-14 16:51:09 +000038const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000039
gioa1efbad2025-05-21 07:16:45 +000040const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000041
giod0026612025-05-08 13:00:36 +000042const handleProjectCreate: express.Handler = async (req, resp) => {
43 try {
gioa71316d2025-05-24 09:41:36 +040044 const tmpDir = tmp.dirSync().name;
45 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000046 const { id } = await db.project.create({
47 data: {
gio09fcab52025-05-12 14:05:07 +000048 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000049 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040050 deployKey: privateKey,
51 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000052 },
53 });
54 resp.status(200);
55 resp.header("Content-Type", "application/json");
56 resp.write(
57 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000058 id: id.toString(),
giod0026612025-05-08 13:00:36 +000059 }),
60 );
61 } catch (e) {
62 console.log(e);
63 resp.status(500);
64 } finally {
65 resp.end();
66 }
67};
68
69const handleProjectAll: express.Handler = async (req, resp) => {
70 try {
71 const r = await db.project.findMany({
72 where: {
gio09fcab52025-05-12 14:05:07 +000073 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000074 },
75 });
76 resp.status(200);
77 resp.header("Content-Type", "application/json");
78 resp.write(
79 JSON.stringify(
80 r.map((p) => ({
81 id: p.id.toString(),
82 name: p.name,
83 })),
84 ),
85 );
86 } catch (e) {
87 console.log(e);
88 resp.status(500);
89 } finally {
90 resp.end();
91 }
92};
93
94const handleSave: express.Handler = async (req, resp) => {
95 try {
96 await db.project.update({
97 where: {
98 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +000099 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000100 },
101 data: {
giobd37a2b2025-05-15 04:28:42 +0000102 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +0000103 },
104 });
105 resp.status(200);
106 } catch (e) {
107 console.log(e);
108 resp.status(500);
109 } finally {
110 resp.end();
111 }
112};
113
gio818da4e2025-05-12 14:45:35 +0000114function handleSavedGet(state: "deploy" | "draft"): express.Handler {
115 return async (req, resp) => {
116 try {
117 const r = await db.project.findUnique({
118 where: {
119 id: Number(req.params["projectId"]),
120 userId: resp.locals.userId,
121 },
122 select: {
123 state: true,
124 draft: true,
125 },
126 });
127 if (r == null) {
128 resp.status(404);
129 return;
130 }
giod0026612025-05-08 13:00:36 +0000131 resp.status(200);
132 resp.header("content-type", "application/json");
gioc31bf142025-06-16 07:48:20 +0000133 let currentState: Record<string, unknown> | null = null;
gio818da4e2025-05-12 14:45:35 +0000134 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000135 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000136 currentState = {
giod0026612025-05-08 13:00:36 +0000137 nodes: [],
138 edges: [],
139 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000140 };
giod0026612025-05-08 13:00:36 +0000141 } else {
gioc31bf142025-06-16 07:48:20 +0000142 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
giod0026612025-05-08 13:00:36 +0000143 }
144 } else {
gio818da4e2025-05-12 14:45:35 +0000145 if (r.draft == null) {
146 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000147 currentState = {
gio818da4e2025-05-12 14:45:35 +0000148 nodes: [],
149 edges: [],
150 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000151 };
gio818da4e2025-05-12 14:45:35 +0000152 } else {
gioc31bf142025-06-16 07:48:20 +0000153 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000154 }
155 } else {
gioc31bf142025-06-16 07:48:20 +0000156 currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000157 }
giod0026612025-05-08 13:00:36 +0000158 }
gioc31bf142025-06-16 07:48:20 +0000159 const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
160 if (currentState) {
161 const config = generateDodoConfig(
162 req.params["projectId"].toString(),
163 currentState.nodes as AppNode[],
164 env,
165 );
166 resp.send({
167 state: currentState,
168 config,
169 });
170 }
gio818da4e2025-05-12 14:45:35 +0000171 } catch (e) {
172 console.log(e);
173 resp.status(500);
174 } finally {
175 resp.end();
giod0026612025-05-08 13:00:36 +0000176 }
gio818da4e2025-05-12 14:45:35 +0000177 };
178}
giod0026612025-05-08 13:00:36 +0000179
gioa71316d2025-05-24 09:41:36 +0400180const projectDeleteReqSchema = z.object({
181 state: z.optional(z.nullable(z.string())),
182});
183
184const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000185 try {
186 const projectId = Number(req.params["projectId"]);
187 const p = await db.project.findUnique({
188 where: {
189 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000190 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000191 },
192 select: {
193 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400194 githubToken: true,
195 deployKeyPublic: true,
196 state: true,
197 draft: true,
giod0026612025-05-08 13:00:36 +0000198 },
199 });
200 if (p === null) {
201 resp.status(404);
202 return;
203 }
gioa71316d2025-05-24 09:41:36 +0400204 const parseResult = projectDeleteReqSchema.safeParse(req.body);
205 if (!parseResult.success) {
206 resp.status(400);
207 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
208 return;
gioe440db82025-05-13 12:21:44 +0000209 }
gioa71316d2025-05-24 09:41:36 +0400210 if (p.githubToken && p.deployKeyPublic) {
211 const allRepos = [
212 ...new Set([
213 ...extractGithubRepos(p.state),
214 ...extractGithubRepos(p.draft),
215 ...extractGithubRepos(parseResult.data.state),
216 ]),
217 ];
218 if (allRepos.length > 0) {
219 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
220 const github = new GithubClient(p.githubToken);
221 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
222 console.log(
223 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
224 );
225 }
giod0026612025-05-08 13:00:36 +0000226 }
gioa71316d2025-05-24 09:41:36 +0400227 if (p.instanceId !== null) {
228 if (!(await appManager.removeInstance(p.instanceId))) {
229 resp.status(500);
230 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
231 return;
232 }
233 }
234 await db.project.delete({
235 where: {
236 id: projectId,
237 },
238 });
giod0026612025-05-08 13:00:36 +0000239 resp.status(200);
240 } catch (e) {
241 console.log(e);
242 resp.status(500);
243 } finally {
244 resp.end();
245 }
246};
247
gioa71316d2025-05-24 09:41:36 +0400248function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000249 if (!serializedState) {
250 return [];
251 }
252 try {
giobd37a2b2025-05-15 04:28:42 +0000253 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000254 const githubNodes = stateObj.nodes.filter(
255 // eslint-disable-next-line @typescript-eslint/no-explicit-any
256 (n: any) => n.type === "github" && n.data?.repository?.id,
257 );
258 // eslint-disable-next-line @typescript-eslint/no-explicit-any
259 return githubNodes.map((n: any) => n.data.repository.sshURL);
260 } catch (error) {
261 console.error("Failed to parse state or extract GitHub repos:", error);
262 return [];
263 }
264}
265
266type RepoDiff = {
267 toAdd?: string[];
268 toDelete?: string[];
269};
270
271function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
272 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
273 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
274 return { toAdd, toDelete };
275}
276
gio76d8ae62025-05-19 15:21:54 +0000277async function manageGithubRepos(
278 github: GithubClient,
279 diff: RepoDiff,
280 deployKey: string,
281 publicAddr?: string,
282): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000283 for (const repoUrl of diff.toDelete ?? []) {
284 try {
285 await github.removeDeployKey(repoUrl, deployKey);
286 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000287 if (publicAddr) {
288 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
289 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
290 console.log(`Removed push webhook from repository ${repoUrl}`);
291 }
gio3ed59592025-05-14 16:51:09 +0000292 } catch (error) {
293 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
294 }
295 }
296 for (const repoUrl of diff.toAdd ?? []) {
297 try {
298 await github.addDeployKey(repoUrl, deployKey);
299 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000300 if (publicAddr) {
301 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
302 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
303 console.log(`Added push webhook to repository ${repoUrl}`);
304 }
gio3ed59592025-05-14 16:51:09 +0000305 } catch (error) {
306 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
307 }
308 }
309}
310
giod0026612025-05-08 13:00:36 +0000311const handleDeploy: express.Handler = async (req, resp) => {
312 try {
313 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000314 const p = await db.project.findUnique({
315 where: {
316 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000317 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000318 },
319 select: {
320 instanceId: true,
321 githubToken: true,
322 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400323 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000324 state: true,
gio69148322025-06-19 23:16:12 +0400325 geminiApiKey: true,
giod0026612025-05-08 13:00:36 +0000326 },
327 });
328 if (p === null) {
329 resp.status(404);
330 return;
331 }
gioc31bf142025-06-16 07:48:20 +0000332 const config = ConfigSchema.safeParse(req.body.config);
333 if (!config.success) {
334 resp.status(400);
335 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
336 return;
337 }
gio9b7421a2025-06-18 12:31:13 +0000338 let repos: GithubRepository[] = [];
339 if (p.githubToken) {
340 const github = new GithubClient(p.githubToken);
341 repos = await github.getRepositories();
342 }
gioc31bf142025-06-16 07:48:20 +0000343 const state = req.body.state
344 ? JSON.stringify(req.body.state)
345 : JSON.stringify(
346 configToGraph(
347 config.data,
348 getNetworks(resp.locals.username),
gio9b7421a2025-06-18 12:31:13 +0000349 repos,
gioc31bf142025-06-16 07:48:20 +0000350 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
351 ),
352 );
giod0026612025-05-08 13:00:36 +0000353 await db.project.update({
354 where: {
355 id: projectId,
356 },
357 data: {
358 draft: state,
359 },
360 });
gioa71316d2025-05-24 09:41:36 +0400361 let deployKey: string | null = p.deployKey;
362 let deployKeyPublic: string | null = p.deployKeyPublic;
363 if (deployKeyPublic == null) {
364 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
365 await db.project.update({
366 where: { id: projectId },
367 data: { deployKeyPublic, deployKey },
368 });
369 }
gio3ed59592025-05-14 16:51:09 +0000370 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000371 const cfg: ConfigWithInput = {
372 ...config.data,
373 input: {
374 appId: projectId.toString(),
375 managerAddr: env.INTERNAL_API_ADDR!,
376 key: {
377 public: deployKeyPublic!,
378 private: deployKey!,
379 },
gio69148322025-06-19 23:16:12 +0400380 geminiApiKey: p.geminiApiKey ?? undefined,
gioc31bf142025-06-16 07:48:20 +0000381 },
gioa71316d2025-05-24 09:41:36 +0400382 };
gio3ed59592025-05-14 16:51:09 +0000383 try {
384 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000385 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000386 await db.project.update({
387 where: {
388 id: projectId,
389 },
390 data: {
391 state,
392 draft: null,
gio3ed59592025-05-14 16:51:09 +0000393 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000394 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000395 },
396 });
gio3ed59592025-05-14 16:51:09 +0000397 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000398 } else {
gioc31bf142025-06-16 07:48:20 +0000399 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000400 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000401 await db.project.update({
402 where: {
403 id: projectId,
404 },
405 data: {
406 state,
407 draft: null,
408 access: JSON.stringify(deployResponse.access),
409 },
410 });
giod0026612025-05-08 13:00:36 +0000411 }
gio3ed59592025-05-14 16:51:09 +0000412 if (diff && p.githubToken && deployKey) {
413 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400414 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000415 }
gio3ed59592025-05-14 16:51:09 +0000416 resp.status(200);
417 } catch (error) {
418 console.error("Deployment error:", error);
419 resp.status(500);
420 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000421 }
422 } catch (e) {
423 console.log(e);
424 resp.status(500);
425 } finally {
426 resp.end();
427 }
428};
429
430const handleStatus: express.Handler = async (req, resp) => {
431 try {
432 const projectId = Number(req.params["projectId"]);
433 const p = await db.project.findUnique({
434 where: {
435 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000436 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000437 },
438 select: {
439 instanceId: true,
440 },
441 });
giod0026612025-05-08 13:00:36 +0000442 if (p === null) {
443 resp.status(404);
444 return;
445 }
446 if (p.instanceId == null) {
447 resp.status(404);
448 return;
449 }
gio3ed59592025-05-14 16:51:09 +0000450 try {
451 const status = await appManager.getStatus(p.instanceId);
452 resp.status(200);
453 resp.write(JSON.stringify(status));
454 } catch (error) {
455 console.error("Error getting status:", error);
456 resp.status(500);
giod0026612025-05-08 13:00:36 +0000457 }
458 } catch (e) {
459 console.log(e);
460 resp.status(500);
461 } finally {
462 resp.end();
463 }
464};
465
gioc31bf142025-06-16 07:48:20 +0000466const handleConfigGet: express.Handler = async (req, resp) => {
467 try {
468 const projectId = Number(req.params["projectId"]);
469 const project = await db.project.findUnique({
470 where: {
471 id: projectId,
472 },
473 select: {
474 state: true,
475 },
476 });
477
478 if (!project || !project.state) {
479 resp.status(404).send({ error: "No deployed configuration found." });
480 return;
481 }
482
483 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
484 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
485 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
486
487 if (!config) {
488 resp.status(500).send({ error: "Failed to generate configuration." });
489 return;
490 }
491 resp.status(200).json(config);
492 } catch (e) {
493 console.log(e);
494 resp.status(500).send({ error: "Internal server error" });
495 } finally {
496 console.log("config get done");
497 resp.end();
498 }
499};
500
giobd37a2b2025-05-15 04:28:42 +0000501const handleRemoveDeployment: express.Handler = async (req, resp) => {
502 try {
503 const projectId = Number(req.params["projectId"]);
504 const p = await db.project.findUnique({
505 where: {
506 id: projectId,
507 userId: resp.locals.userId,
508 },
509 select: {
510 instanceId: true,
511 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400512 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000513 state: true,
514 draft: true,
515 },
516 });
517 if (p === null) {
518 resp.status(404);
519 resp.write(JSON.stringify({ error: "Project not found" }));
520 return;
521 }
522 if (p.instanceId == null) {
523 resp.status(400);
524 resp.write(JSON.stringify({ error: "Project not deployed" }));
525 return;
526 }
527 const removed = await appManager.removeInstance(p.instanceId);
528 if (!removed) {
529 resp.status(500);
530 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
531 return;
532 }
gioa71316d2025-05-24 09:41:36 +0400533 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000534 try {
535 const github = new GithubClient(p.githubToken);
536 const repos = extractGithubRepos(p.state);
537 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400538 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000539 } catch (error) {
540 console.error("Error removing GitHub deploy keys:", error);
541 }
542 }
543 await db.project.update({
544 where: {
545 id: projectId,
546 },
547 data: {
548 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400549 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000550 access: null,
giobd37a2b2025-05-15 04:28:42 +0000551 state: null,
552 draft: p.draft ?? p.state,
553 },
554 });
555 resp.status(200);
556 resp.write(JSON.stringify({ success: true }));
557 } catch (e) {
558 console.error("Error removing deployment:", e);
559 resp.status(500);
560 resp.write(JSON.stringify({ error: "Internal server error" }));
561 } finally {
562 resp.end();
563 }
564};
565
giod0026612025-05-08 13:00:36 +0000566const handleGithubRepos: express.Handler = async (req, resp) => {
567 try {
568 const projectId = Number(req.params["projectId"]);
569 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000570 where: {
571 id: projectId,
572 userId: resp.locals.userId,
573 },
574 select: {
575 githubToken: true,
576 },
giod0026612025-05-08 13:00:36 +0000577 });
giod0026612025-05-08 13:00:36 +0000578 if (!project?.githubToken) {
579 resp.status(400);
580 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
581 return;
582 }
giod0026612025-05-08 13:00:36 +0000583 const github = new GithubClient(project.githubToken);
584 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000585 resp.status(200);
586 resp.header("Content-Type", "application/json");
587 resp.write(JSON.stringify(repositories));
588 } catch (e) {
589 console.log(e);
590 resp.status(500);
591 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
592 } finally {
593 resp.end();
594 }
595};
596
597const handleUpdateGithubToken: express.Handler = async (req, resp) => {
598 try {
giod0026612025-05-08 13:00:36 +0000599 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000600 where: {
gio69148322025-06-19 23:16:12 +0400601 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000602 userId: resp.locals.userId,
603 },
gio69148322025-06-19 23:16:12 +0400604 data: {
605 githubToken: req.body.githubToken,
606 },
607 });
608 resp.status(200);
609 } catch (e) {
610 console.log(e);
611 resp.status(500);
612 } finally {
613 resp.end();
614 }
615};
616
617const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
618 try {
619 await db.project.update({
620 where: {
621 id: Number(req.params["projectId"]),
622 userId: resp.locals.userId,
623 },
624 data: {
625 geminiApiKey: req.body.geminiApiKey,
626 },
giod0026612025-05-08 13:00:36 +0000627 });
giod0026612025-05-08 13:00:36 +0000628 resp.status(200);
629 } catch (e) {
630 console.log(e);
631 resp.status(500);
632 } finally {
633 resp.end();
634 }
635};
636
gioc31bf142025-06-16 07:48:20 +0000637const getNetworks = (username?: string | undefined): Network[] => {
638 return [
639 {
640 name: "Trial",
641 domain: "trial.dodoapp.xyz",
642 hasAuth: false,
643 },
644 // TODO(gio): Remove
645 ].concat(
646 username === "gio" || 1 == 1
647 ? [
648 {
649 name: "Public",
650 domain: "v1.dodo.cloud",
651 hasAuth: true,
652 },
653 {
654 name: "Private",
655 domain: "p.v1.dodo.cloud",
656 hasAuth: true,
657 },
658 ]
659 : [],
660 );
661};
662
663const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
664 const project = await db.project.findUnique({
665 where: {
666 id: projectId,
667 userId,
668 },
669 select: {
670 deployKeyPublic: true,
671 githubToken: true,
gio69148322025-06-19 23:16:12 +0400672 geminiApiKey: true,
gioc31bf142025-06-16 07:48:20 +0000673 access: true,
674 instanceId: true,
675 },
676 });
677 if (!project) {
678 throw new Error("Project not found");
679 }
680 const monitor = projectMonitors.get(projectId);
681 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
682 const services = serviceNames.map((name: string) => ({
683 name,
684 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
685 ([id, status]) => ({
686 ...status,
687 id,
688 }),
689 ),
690 }));
691 return {
gioc31bf142025-06-16 07:48:20 +0000692 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
693 instanceId: project.instanceId == null ? undefined : project.instanceId,
694 access: JSON.parse(project.access ?? "[]"),
695 integrations: {
696 github: !!project.githubToken,
gio69148322025-06-19 23:16:12 +0400697 gemini: !!project.geminiApiKey,
gioc31bf142025-06-16 07:48:20 +0000698 },
699 networks: getNetworks(username),
700 services,
701 user: {
702 id: userId,
703 username: username,
704 },
705 };
706};
707
giod0026612025-05-08 13:00:36 +0000708const handleEnv: express.Handler = async (req, resp) => {
709 const projectId = Number(req.params["projectId"]);
710 try {
gioc31bf142025-06-16 07:48:20 +0000711 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000712 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000713 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000714 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000715 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000716 resp.status(500);
717 resp.write(JSON.stringify({ error: "Internal server error" }));
718 } finally {
719 resp.end();
720 }
721};
722
gio3a921b82025-05-10 07:36:09 +0000723const handleServiceLogs: express.Handler = async (req, resp) => {
724 try {
725 const projectId = Number(req.params["projectId"]);
726 const service = req.params["service"];
gioa1efbad2025-05-21 07:16:45 +0000727 const workerId = req.params["workerId"];
gio09fcab52025-05-12 14:05:07 +0000728 const project = await db.project.findUnique({
729 where: {
730 id: projectId,
731 userId: resp.locals.userId,
732 },
733 });
734 if (project == null) {
735 resp.status(404);
736 resp.write(JSON.stringify({ error: "Project not found" }));
737 return;
738 }
gioa1efbad2025-05-21 07:16:45 +0000739 const monitor = projectMonitors.get(projectId);
740 if (!monitor || !monitor.hasLogs()) {
gio3a921b82025-05-10 07:36:09 +0000741 resp.status(404);
742 resp.write(JSON.stringify({ error: "No logs found for this project" }));
743 return;
744 }
gioa1efbad2025-05-21 07:16:45 +0000745 const serviceLog = monitor.getWorkerLog(service, workerId);
gio3a921b82025-05-10 07:36:09 +0000746 if (!serviceLog) {
747 resp.status(404);
gioa1efbad2025-05-21 07:16:45 +0000748 resp.write(JSON.stringify({ error: "No logs found for this service/worker" }));
gio3a921b82025-05-10 07:36:09 +0000749 return;
750 }
gio3a921b82025-05-10 07:36:09 +0000751 resp.status(200);
752 resp.write(JSON.stringify({ logs: serviceLog }));
753 } catch (e) {
754 console.log(e);
755 resp.status(500);
756 resp.write(JSON.stringify({ error: "Failed to get service logs" }));
757 } finally {
758 resp.end();
759 }
760};
761
gio7d813702025-05-08 18:29:52 +0000762const handleRegisterWorker: express.Handler = async (req, resp) => {
763 try {
764 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000765 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000766 if (!result.success) {
767 resp.status(400);
768 resp.write(
769 JSON.stringify({
770 error: "Invalid request data",
771 details: result.error.format(),
772 }),
773 );
774 return;
775 }
gioa1efbad2025-05-21 07:16:45 +0000776 let monitor = projectMonitors.get(projectId);
777 if (!monitor) {
778 monitor = new ProjectMonitor();
779 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000780 }
gioa1efbad2025-05-21 07:16:45 +0000781 monitor.registerWorker(result.data);
gio7d813702025-05-08 18:29:52 +0000782 resp.status(200);
783 resp.write(
784 JSON.stringify({
785 success: true,
gio7d813702025-05-08 18:29:52 +0000786 }),
787 );
788 } catch (e) {
789 console.log(e);
790 resp.status(500);
791 resp.write(JSON.stringify({ error: "Failed to register worker" }));
792 } finally {
793 resp.end();
794 }
795};
796
gio76d8ae62025-05-19 15:21:54 +0000797async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000798 const monitor = projectMonitors.get(projectId);
799 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000800 const workerCount = projectWorkers.length;
801 if (workerCount === 0) {
802 return true;
803 }
804 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000805 projectWorkers.map(async (workerAddress: string) => {
806 try {
807 const { data } = await axios.get(`http://${workerAddress}/reload`);
808 return data.every((s: { status: string }) => s.status === "ok");
809 } catch (error) {
810 console.error(`Failed to reload worker ${workerAddress}:`, error);
811 return false;
812 }
gio76d8ae62025-05-19 15:21:54 +0000813 }),
814 );
gioc31bf142025-06-16 07:48:20 +0000815 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000816}
817
gio7d813702025-05-08 18:29:52 +0000818const handleReload: express.Handler = async (req, resp) => {
819 try {
820 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000821 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000822 where: {
823 id: projectId,
824 userId: resp.locals.userId,
825 },
gio76d8ae62025-05-19 15:21:54 +0000826 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000827 });
gio76d8ae62025-05-19 15:21:54 +0000828 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000829 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000830 return;
831 }
gio76d8ae62025-05-19 15:21:54 +0000832 const success = await reloadProject(projectId);
833 if (success) {
834 resp.status(200);
835 } else {
836 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000837 }
gio7d813702025-05-08 18:29:52 +0000838 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000839 console.error(e);
gio7d813702025-05-08 18:29:52 +0000840 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000841 }
842};
843
gio918780d2025-05-22 08:24:41 +0000844const handleReloadWorker: express.Handler = async (req, resp) => {
845 const projectId = Number(req.params["projectId"]);
846 const serviceName = req.params["serviceName"];
847 const workerId = req.params["workerId"];
848
849 const projectMonitor = projectMonitors.get(projectId);
850 if (!projectMonitor) {
851 resp.status(404).send({ error: "Project monitor not found" });
852 return;
853 }
854
855 try {
856 await projectMonitor.reloadWorker(serviceName, workerId);
857 resp.status(200).send({ message: "Worker reload initiated" });
858 } catch (error) {
859 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
860 const errorMessage = error instanceof Error ? error.message : "Unknown error";
861 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
862 }
863};
864
gioa71316d2025-05-24 09:41:36 +0400865const analyzeRepoReqSchema = z.object({
866 address: z.string(),
867});
868
869const handleAnalyzeRepo: express.Handler = async (req, resp) => {
870 const projectId = Number(req.params["projectId"]);
871 const project = await db.project.findUnique({
872 where: {
873 id: projectId,
874 userId: resp.locals.userId,
875 },
876 select: {
877 githubToken: true,
878 deployKey: true,
879 deployKeyPublic: true,
880 },
881 });
882 if (!project) {
883 resp.status(404).send({ error: "Project not found" });
884 return;
885 }
886 if (!project.githubToken) {
887 resp.status(400).send({ error: "GitHub token not configured" });
888 return;
889 }
gio8e74dc02025-06-13 10:19:26 +0000890 let tmpDir: tmp.DirResult | null = null;
891 try {
892 let deployKey: string | null = project.deployKey;
893 let deployKeyPublic: string | null = project.deployKeyPublic;
894 if (!deployKeyPublic) {
895 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
896 await db.project.update({
897 where: { id: projectId },
898 data: {
899 deployKeyPublic: deployKeyPublic,
900 deployKey: deployKey,
901 },
902 });
903 }
904 const github = new GithubClient(project.githubToken);
905 const result = analyzeRepoReqSchema.safeParse(req.body);
906 if (!result.success) {
907 resp.status(400).send({ error: "Invalid request data" });
908 return;
909 }
910 const { address } = result.data;
911 tmpDir = tmp.dirSync({
912 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400913 });
gio8e74dc02025-06-13 10:19:26 +0000914 await github.addDeployKey(address, deployKeyPublic);
915 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
916 mode: 0o600,
917 });
918 shell.exec(
919 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
920 );
921 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
922 const analyzer = new NodeJSAnalyzer();
923 const info = await analyzer.analyze(fsc, "/");
924 resp.status(200).send([info]);
925 } catch (e) {
926 console.error(e);
927 resp.status(500).send({ error: "Failed to analyze repository" });
928 } finally {
929 if (tmpDir) {
930 tmpDir.removeCallback();
931 }
932 resp.end();
gioa71316d2025-05-24 09:41:36 +0400933 }
gioa71316d2025-05-24 09:41:36 +0400934};
935
gio09fcab52025-05-12 14:05:07 +0000936const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
gio69148322025-06-19 23:16:12 +0400937 // Hardcoded user for development
938 resp.locals.userId = "1";
939 resp.locals.username = "gio";
gio09fcab52025-05-12 14:05:07 +0000940 next();
941};
942
gio76d8ae62025-05-19 15:21:54 +0000943const handleGithubPushWebhook: express.Handler = async (req, resp) => {
944 try {
945 // TODO(gio): Implement GitHub signature verification for security
946 const webhookSchema = z.object({
947 repository: z.object({
948 ssh_url: z.string(),
949 }),
950 });
951
952 const result = webhookSchema.safeParse(req.body);
953 if (!result.success) {
954 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
955 resp.status(400).json({ error: "Invalid webhook payload" });
956 return;
957 }
958 const { ssh_url: addr } = result.data.repository;
959 const allProjects = await db.project.findMany({
960 select: {
961 id: true,
962 state: true,
963 },
964 where: {
965 instanceId: {
966 not: null,
967 },
968 },
969 });
970 // TODO(gio): This should run in background
971 new Promise<boolean>((resolve, reject) => {
972 setTimeout(() => {
973 const projectsToReloadIds: number[] = [];
974 for (const project of allProjects) {
975 if (project.state && project.state.length > 0) {
976 const projectRepos = extractGithubRepos(project.state);
977 if (projectRepos.includes(addr)) {
978 projectsToReloadIds.push(project.id);
979 }
980 }
981 }
982 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
983 .then((results) => {
984 resolve(results.reduce((acc, curr) => acc && curr, true));
985 })
986 // eslint-disable-next-line @typescript-eslint/no-explicit-any
987 .catch((reason: any) => reject(reason));
988 }, 10);
989 });
990 // eslint-disable-next-line @typescript-eslint/no-explicit-any
991 } catch (error: any) {
992 console.error(error);
993 resp.status(500);
994 }
995};
996
gioc31bf142025-06-16 07:48:20 +0000997const handleValidateConfig: express.Handler = async (req, resp) => {
998 try {
999 const validationResult = ConfigSchema.safeParse(req.body);
1000 if (!validationResult.success) {
1001 resp.status(400);
1002 resp.header("Content-Type", "application/json");
1003 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
1004 } else {
1005 resp.status(200);
1006 resp.header("Content-Type", "application/json");
1007 resp.write(JSON.stringify({ success: true }));
1008 }
1009 } catch (e) {
1010 console.log(e);
1011 resp.status(500);
1012 } finally {
1013 resp.end();
1014 }
1015};
1016
giod0026612025-05-08 13:00:36 +00001017async function start() {
1018 await db.$connect();
1019 const app = express();
gioc31bf142025-06-16 07:48:20 +00001020 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001021 app.use(express.json()); // Global JSON parsing
1022
1023 // Public webhook route - no auth needed
1024 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1025
1026 // Authenticated project routes
1027 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001028 projectRouter.use(auth);
1029 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001030 projectRouter.post("/:projectId/saved", handleSave);
1031 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1032 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1033 projectRouter.post("/:projectId/deploy", handleDeploy);
1034 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001035 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001036 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001037 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1038 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
gio69148322025-06-19 23:16:12 +04001039 projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
gio76d8ae62025-05-19 15:21:54 +00001040 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001041 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +00001042 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001043 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001044 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1045 projectRouter.get("/", handleProjectAll);
1046 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001047
gio76d8ae62025-05-19 15:21:54 +00001048 app.use("/api/project", projectRouter); // Mount the authenticated router
1049
giod0026612025-05-08 13:00:36 +00001050 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001051
gio76d8ae62025-05-19 15:21:54 +00001052 const internalApi = express();
1053 internalApi.use(express.json());
1054 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001055 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1056 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1057 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001058
giod0026612025-05-08 13:00:36 +00001059 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001060 console.log("Web server started on port", env.DODO_PORT_WEB);
1061 });
1062
gio76d8ae62025-05-19 15:21:54 +00001063 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001064 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001065 });
1066}
1067
1068start();