blob: 82e55fe4783094ccae2f8cd0f9e72d59715a7a8b [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,
giod0026612025-05-08 13:00:36 +0000325 },
326 });
327 if (p === null) {
328 resp.status(404);
329 return;
330 }
gioc31bf142025-06-16 07:48:20 +0000331 const config = ConfigSchema.safeParse(req.body.config);
332 if (!config.success) {
333 resp.status(400);
334 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
335 return;
336 }
gio9b7421a2025-06-18 12:31:13 +0000337 let repos: GithubRepository[] = [];
338 if (p.githubToken) {
339 const github = new GithubClient(p.githubToken);
340 repos = await github.getRepositories();
341 }
gioc31bf142025-06-16 07:48:20 +0000342 const state = req.body.state
343 ? JSON.stringify(req.body.state)
344 : JSON.stringify(
345 configToGraph(
346 config.data,
347 getNetworks(resp.locals.username),
gio9b7421a2025-06-18 12:31:13 +0000348 repos,
gioc31bf142025-06-16 07:48:20 +0000349 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
350 ),
351 );
giod0026612025-05-08 13:00:36 +0000352 await db.project.update({
353 where: {
354 id: projectId,
355 },
356 data: {
357 draft: state,
358 },
359 });
gioa71316d2025-05-24 09:41:36 +0400360 let deployKey: string | null = p.deployKey;
361 let deployKeyPublic: string | null = p.deployKeyPublic;
362 if (deployKeyPublic == null) {
363 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
364 await db.project.update({
365 where: { id: projectId },
366 data: { deployKeyPublic, deployKey },
367 });
368 }
gio3ed59592025-05-14 16:51:09 +0000369 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000370 const cfg: ConfigWithInput = {
371 ...config.data,
372 input: {
373 appId: projectId.toString(),
374 managerAddr: env.INTERNAL_API_ADDR!,
375 key: {
376 public: deployKeyPublic!,
377 private: deployKey!,
378 },
379 },
gioa71316d2025-05-24 09:41:36 +0400380 };
gio3ed59592025-05-14 16:51:09 +0000381 try {
382 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000383 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000384 await db.project.update({
385 where: {
386 id: projectId,
387 },
388 data: {
389 state,
390 draft: null,
gio3ed59592025-05-14 16:51:09 +0000391 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000392 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000393 },
394 });
gio3ed59592025-05-14 16:51:09 +0000395 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000396 } else {
gioc31bf142025-06-16 07:48:20 +0000397 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000398 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000399 await db.project.update({
400 where: {
401 id: projectId,
402 },
403 data: {
404 state,
405 draft: null,
406 access: JSON.stringify(deployResponse.access),
407 },
408 });
giod0026612025-05-08 13:00:36 +0000409 }
gio3ed59592025-05-14 16:51:09 +0000410 if (diff && p.githubToken && deployKey) {
411 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400412 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000413 }
gio3ed59592025-05-14 16:51:09 +0000414 resp.status(200);
415 } catch (error) {
416 console.error("Deployment error:", error);
417 resp.status(500);
418 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000419 }
420 } catch (e) {
421 console.log(e);
422 resp.status(500);
423 } finally {
424 resp.end();
425 }
426};
427
428const handleStatus: express.Handler = async (req, resp) => {
429 try {
430 const projectId = Number(req.params["projectId"]);
431 const p = await db.project.findUnique({
432 where: {
433 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000434 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000435 },
436 select: {
437 instanceId: true,
438 },
439 });
giod0026612025-05-08 13:00:36 +0000440 if (p === null) {
441 resp.status(404);
442 return;
443 }
444 if (p.instanceId == null) {
445 resp.status(404);
446 return;
447 }
gio3ed59592025-05-14 16:51:09 +0000448 try {
449 const status = await appManager.getStatus(p.instanceId);
450 resp.status(200);
451 resp.write(JSON.stringify(status));
452 } catch (error) {
453 console.error("Error getting status:", error);
454 resp.status(500);
giod0026612025-05-08 13:00:36 +0000455 }
456 } catch (e) {
457 console.log(e);
458 resp.status(500);
459 } finally {
460 resp.end();
461 }
462};
463
gioc31bf142025-06-16 07:48:20 +0000464const handleConfigGet: express.Handler = async (req, resp) => {
465 try {
466 const projectId = Number(req.params["projectId"]);
467 const project = await db.project.findUnique({
468 where: {
469 id: projectId,
470 },
471 select: {
472 state: true,
473 },
474 });
475
476 if (!project || !project.state) {
477 resp.status(404).send({ error: "No deployed configuration found." });
478 return;
479 }
480
481 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
482 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
483 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
484
485 if (!config) {
486 resp.status(500).send({ error: "Failed to generate configuration." });
487 return;
488 }
489 resp.status(200).json(config);
490 } catch (e) {
491 console.log(e);
492 resp.status(500).send({ error: "Internal server error" });
493 } finally {
494 console.log("config get done");
495 resp.end();
496 }
497};
498
giobd37a2b2025-05-15 04:28:42 +0000499const handleRemoveDeployment: express.Handler = async (req, resp) => {
500 try {
501 const projectId = Number(req.params["projectId"]);
502 const p = await db.project.findUnique({
503 where: {
504 id: projectId,
505 userId: resp.locals.userId,
506 },
507 select: {
508 instanceId: true,
509 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400510 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000511 state: true,
512 draft: true,
513 },
514 });
515 if (p === null) {
516 resp.status(404);
517 resp.write(JSON.stringify({ error: "Project not found" }));
518 return;
519 }
520 if (p.instanceId == null) {
521 resp.status(400);
522 resp.write(JSON.stringify({ error: "Project not deployed" }));
523 return;
524 }
525 const removed = await appManager.removeInstance(p.instanceId);
526 if (!removed) {
527 resp.status(500);
528 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
529 return;
530 }
gioa71316d2025-05-24 09:41:36 +0400531 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000532 try {
533 const github = new GithubClient(p.githubToken);
534 const repos = extractGithubRepos(p.state);
535 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400536 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000537 } catch (error) {
538 console.error("Error removing GitHub deploy keys:", error);
539 }
540 }
541 await db.project.update({
542 where: {
543 id: projectId,
544 },
545 data: {
546 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400547 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000548 access: null,
giobd37a2b2025-05-15 04:28:42 +0000549 state: null,
550 draft: p.draft ?? p.state,
551 },
552 });
553 resp.status(200);
554 resp.write(JSON.stringify({ success: true }));
555 } catch (e) {
556 console.error("Error removing deployment:", e);
557 resp.status(500);
558 resp.write(JSON.stringify({ error: "Internal server error" }));
559 } finally {
560 resp.end();
561 }
562};
563
giod0026612025-05-08 13:00:36 +0000564const handleGithubRepos: express.Handler = async (req, resp) => {
565 try {
566 const projectId = Number(req.params["projectId"]);
567 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000568 where: {
569 id: projectId,
570 userId: resp.locals.userId,
571 },
572 select: {
573 githubToken: true,
574 },
giod0026612025-05-08 13:00:36 +0000575 });
giod0026612025-05-08 13:00:36 +0000576 if (!project?.githubToken) {
577 resp.status(400);
578 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
579 return;
580 }
giod0026612025-05-08 13:00:36 +0000581 const github = new GithubClient(project.githubToken);
582 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000583 resp.status(200);
584 resp.header("Content-Type", "application/json");
585 resp.write(JSON.stringify(repositories));
586 } catch (e) {
587 console.log(e);
588 resp.status(500);
589 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
590 } finally {
591 resp.end();
592 }
593};
594
595const handleUpdateGithubToken: express.Handler = async (req, resp) => {
596 try {
597 const projectId = Number(req.params["projectId"]);
598 const { githubToken } = req.body;
giod0026612025-05-08 13:00:36 +0000599 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000600 where: {
601 id: projectId,
602 userId: resp.locals.userId,
603 },
giod0026612025-05-08 13:00:36 +0000604 data: { githubToken },
605 });
giod0026612025-05-08 13:00:36 +0000606 resp.status(200);
607 } catch (e) {
608 console.log(e);
609 resp.status(500);
610 } finally {
611 resp.end();
612 }
613};
614
gioc31bf142025-06-16 07:48:20 +0000615const getNetworks = (username?: string | undefined): Network[] => {
616 return [
617 {
618 name: "Trial",
619 domain: "trial.dodoapp.xyz",
620 hasAuth: false,
621 },
622 // TODO(gio): Remove
623 ].concat(
624 username === "gio" || 1 == 1
625 ? [
626 {
627 name: "Public",
628 domain: "v1.dodo.cloud",
629 hasAuth: true,
630 },
631 {
632 name: "Private",
633 domain: "p.v1.dodo.cloud",
634 hasAuth: true,
635 },
636 ]
637 : [],
638 );
639};
640
641const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
642 const project = await db.project.findUnique({
643 where: {
644 id: projectId,
645 userId,
646 },
647 select: {
648 deployKeyPublic: true,
649 githubToken: true,
650 access: true,
651 instanceId: true,
652 },
653 });
654 if (!project) {
655 throw new Error("Project not found");
656 }
657 const monitor = projectMonitors.get(projectId);
658 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
659 const services = serviceNames.map((name: string) => ({
660 name,
661 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
662 ([id, status]) => ({
663 ...status,
664 id,
665 }),
666 ),
667 }));
668 return {
669 managerAddr: env.INTERNAL_API_ADDR,
670 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
671 instanceId: project.instanceId == null ? undefined : project.instanceId,
672 access: JSON.parse(project.access ?? "[]"),
673 integrations: {
674 github: !!project.githubToken,
675 },
676 networks: getNetworks(username),
677 services,
678 user: {
679 id: userId,
680 username: username,
681 },
682 };
683};
684
giod0026612025-05-08 13:00:36 +0000685const handleEnv: express.Handler = async (req, resp) => {
686 const projectId = Number(req.params["projectId"]);
687 try {
gioc31bf142025-06-16 07:48:20 +0000688 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000689 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000690 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000691 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000692 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000693 resp.status(500);
694 resp.write(JSON.stringify({ error: "Internal server error" }));
695 } finally {
696 resp.end();
697 }
698};
699
gio3a921b82025-05-10 07:36:09 +0000700const handleServiceLogs: express.Handler = async (req, resp) => {
701 try {
702 const projectId = Number(req.params["projectId"]);
703 const service = req.params["service"];
gioa1efbad2025-05-21 07:16:45 +0000704 const workerId = req.params["workerId"];
gio09fcab52025-05-12 14:05:07 +0000705 const project = await db.project.findUnique({
706 where: {
707 id: projectId,
708 userId: resp.locals.userId,
709 },
710 });
711 if (project == null) {
712 resp.status(404);
713 resp.write(JSON.stringify({ error: "Project not found" }));
714 return;
715 }
gioa1efbad2025-05-21 07:16:45 +0000716 const monitor = projectMonitors.get(projectId);
717 if (!monitor || !monitor.hasLogs()) {
gio3a921b82025-05-10 07:36:09 +0000718 resp.status(404);
719 resp.write(JSON.stringify({ error: "No logs found for this project" }));
720 return;
721 }
gioa1efbad2025-05-21 07:16:45 +0000722 const serviceLog = monitor.getWorkerLog(service, workerId);
gio3a921b82025-05-10 07:36:09 +0000723 if (!serviceLog) {
724 resp.status(404);
gioa1efbad2025-05-21 07:16:45 +0000725 resp.write(JSON.stringify({ error: "No logs found for this service/worker" }));
gio3a921b82025-05-10 07:36:09 +0000726 return;
727 }
gio3a921b82025-05-10 07:36:09 +0000728 resp.status(200);
729 resp.write(JSON.stringify({ logs: serviceLog }));
730 } catch (e) {
731 console.log(e);
732 resp.status(500);
733 resp.write(JSON.stringify({ error: "Failed to get service logs" }));
734 } finally {
735 resp.end();
736 }
737};
738
gio7d813702025-05-08 18:29:52 +0000739const handleRegisterWorker: express.Handler = async (req, resp) => {
740 try {
741 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000742 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000743 if (!result.success) {
744 resp.status(400);
745 resp.write(
746 JSON.stringify({
747 error: "Invalid request data",
748 details: result.error.format(),
749 }),
750 );
751 return;
752 }
gioa1efbad2025-05-21 07:16:45 +0000753 let monitor = projectMonitors.get(projectId);
754 if (!monitor) {
755 monitor = new ProjectMonitor();
756 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000757 }
gioa1efbad2025-05-21 07:16:45 +0000758 monitor.registerWorker(result.data);
gio7d813702025-05-08 18:29:52 +0000759 resp.status(200);
760 resp.write(
761 JSON.stringify({
762 success: true,
gio7d813702025-05-08 18:29:52 +0000763 }),
764 );
765 } catch (e) {
766 console.log(e);
767 resp.status(500);
768 resp.write(JSON.stringify({ error: "Failed to register worker" }));
769 } finally {
770 resp.end();
771 }
772};
773
gio76d8ae62025-05-19 15:21:54 +0000774async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000775 const monitor = projectMonitors.get(projectId);
776 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000777 const workerCount = projectWorkers.length;
778 if (workerCount === 0) {
779 return true;
780 }
781 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000782 projectWorkers.map(async (workerAddress: string) => {
783 try {
784 const { data } = await axios.get(`http://${workerAddress}/reload`);
785 return data.every((s: { status: string }) => s.status === "ok");
786 } catch (error) {
787 console.error(`Failed to reload worker ${workerAddress}:`, error);
788 return false;
789 }
gio76d8ae62025-05-19 15:21:54 +0000790 }),
791 );
gioc31bf142025-06-16 07:48:20 +0000792 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000793}
794
gio7d813702025-05-08 18:29:52 +0000795const handleReload: express.Handler = async (req, resp) => {
796 try {
797 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000798 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000799 where: {
800 id: projectId,
801 userId: resp.locals.userId,
802 },
gio76d8ae62025-05-19 15:21:54 +0000803 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000804 });
gio76d8ae62025-05-19 15:21:54 +0000805 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000806 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000807 return;
808 }
gio76d8ae62025-05-19 15:21:54 +0000809 const success = await reloadProject(projectId);
810 if (success) {
811 resp.status(200);
812 } else {
813 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000814 }
gio7d813702025-05-08 18:29:52 +0000815 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000816 console.error(e);
gio7d813702025-05-08 18:29:52 +0000817 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000818 }
819};
820
gio918780d2025-05-22 08:24:41 +0000821const handleReloadWorker: express.Handler = async (req, resp) => {
822 const projectId = Number(req.params["projectId"]);
823 const serviceName = req.params["serviceName"];
824 const workerId = req.params["workerId"];
825
826 const projectMonitor = projectMonitors.get(projectId);
827 if (!projectMonitor) {
828 resp.status(404).send({ error: "Project monitor not found" });
829 return;
830 }
831
832 try {
833 await projectMonitor.reloadWorker(serviceName, workerId);
834 resp.status(200).send({ message: "Worker reload initiated" });
835 } catch (error) {
836 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
837 const errorMessage = error instanceof Error ? error.message : "Unknown error";
838 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
839 }
840};
841
gioa71316d2025-05-24 09:41:36 +0400842const analyzeRepoReqSchema = z.object({
843 address: z.string(),
844});
845
846const handleAnalyzeRepo: express.Handler = async (req, resp) => {
847 const projectId = Number(req.params["projectId"]);
848 const project = await db.project.findUnique({
849 where: {
850 id: projectId,
851 userId: resp.locals.userId,
852 },
853 select: {
854 githubToken: true,
855 deployKey: true,
856 deployKeyPublic: true,
857 },
858 });
859 if (!project) {
860 resp.status(404).send({ error: "Project not found" });
861 return;
862 }
863 if (!project.githubToken) {
864 resp.status(400).send({ error: "GitHub token not configured" });
865 return;
866 }
gio8e74dc02025-06-13 10:19:26 +0000867 let tmpDir: tmp.DirResult | null = null;
868 try {
869 let deployKey: string | null = project.deployKey;
870 let deployKeyPublic: string | null = project.deployKeyPublic;
871 if (!deployKeyPublic) {
872 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
873 await db.project.update({
874 where: { id: projectId },
875 data: {
876 deployKeyPublic: deployKeyPublic,
877 deployKey: deployKey,
878 },
879 });
880 }
881 const github = new GithubClient(project.githubToken);
882 const result = analyzeRepoReqSchema.safeParse(req.body);
883 if (!result.success) {
884 resp.status(400).send({ error: "Invalid request data" });
885 return;
886 }
887 const { address } = result.data;
888 tmpDir = tmp.dirSync({
889 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400890 });
gio8e74dc02025-06-13 10:19:26 +0000891 await github.addDeployKey(address, deployKeyPublic);
892 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
893 mode: 0o600,
894 });
895 shell.exec(
896 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
897 );
898 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
899 const analyzer = new NodeJSAnalyzer();
900 const info = await analyzer.analyze(fsc, "/");
901 resp.status(200).send([info]);
902 } catch (e) {
903 console.error(e);
904 resp.status(500).send({ error: "Failed to analyze repository" });
905 } finally {
906 if (tmpDir) {
907 tmpDir.removeCallback();
908 }
909 resp.end();
gioa71316d2025-05-24 09:41:36 +0400910 }
gioa71316d2025-05-24 09:41:36 +0400911};
912
gio09fcab52025-05-12 14:05:07 +0000913const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
914 const userId = req.get("x-forwarded-userid");
gio3ed59592025-05-14 16:51:09 +0000915 const username = req.get("x-forwarded-user");
916 if (userId == null || username == null) {
gio09fcab52025-05-12 14:05:07 +0000917 resp.status(401);
918 resp.write("Unauthorized");
919 resp.end();
920 return;
921 }
922 resp.locals.userId = userId;
gio3ed59592025-05-14 16:51:09 +0000923 resp.locals.username = username;
gio09fcab52025-05-12 14:05:07 +0000924 next();
925};
926
gio76d8ae62025-05-19 15:21:54 +0000927const handleGithubPushWebhook: express.Handler = async (req, resp) => {
928 try {
929 // TODO(gio): Implement GitHub signature verification for security
930 const webhookSchema = z.object({
931 repository: z.object({
932 ssh_url: z.string(),
933 }),
934 });
935
936 const result = webhookSchema.safeParse(req.body);
937 if (!result.success) {
938 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
939 resp.status(400).json({ error: "Invalid webhook payload" });
940 return;
941 }
942 const { ssh_url: addr } = result.data.repository;
943 const allProjects = await db.project.findMany({
944 select: {
945 id: true,
946 state: true,
947 },
948 where: {
949 instanceId: {
950 not: null,
951 },
952 },
953 });
954 // TODO(gio): This should run in background
955 new Promise<boolean>((resolve, reject) => {
956 setTimeout(() => {
957 const projectsToReloadIds: number[] = [];
958 for (const project of allProjects) {
959 if (project.state && project.state.length > 0) {
960 const projectRepos = extractGithubRepos(project.state);
961 if (projectRepos.includes(addr)) {
962 projectsToReloadIds.push(project.id);
963 }
964 }
965 }
966 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
967 .then((results) => {
968 resolve(results.reduce((acc, curr) => acc && curr, true));
969 })
970 // eslint-disable-next-line @typescript-eslint/no-explicit-any
971 .catch((reason: any) => reject(reason));
972 }, 10);
973 });
974 // eslint-disable-next-line @typescript-eslint/no-explicit-any
975 } catch (error: any) {
976 console.error(error);
977 resp.status(500);
978 }
979};
980
gioc31bf142025-06-16 07:48:20 +0000981const handleValidateConfig: express.Handler = async (req, resp) => {
982 try {
983 const validationResult = ConfigSchema.safeParse(req.body);
984 if (!validationResult.success) {
985 resp.status(400);
986 resp.header("Content-Type", "application/json");
987 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
988 } else {
989 resp.status(200);
990 resp.header("Content-Type", "application/json");
991 resp.write(JSON.stringify({ success: true }));
992 }
993 } catch (e) {
994 console.log(e);
995 resp.status(500);
996 } finally {
997 resp.end();
998 }
999};
1000
giod0026612025-05-08 13:00:36 +00001001async function start() {
1002 await db.$connect();
1003 const app = express();
gioc31bf142025-06-16 07:48:20 +00001004 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001005 app.use(express.json()); // Global JSON parsing
1006
1007 // Public webhook route - no auth needed
1008 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1009
1010 // Authenticated project routes
1011 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001012 projectRouter.use(auth);
1013 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001014 projectRouter.post("/:projectId/saved", handleSave);
1015 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1016 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1017 projectRouter.post("/:projectId/deploy", handleDeploy);
1018 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001019 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001020 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001021 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1022 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
1023 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001024 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +00001025 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001026 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001027 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1028 projectRouter.get("/", handleProjectAll);
1029 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001030
gio76d8ae62025-05-19 15:21:54 +00001031 app.use("/api/project", projectRouter); // Mount the authenticated router
1032
giod0026612025-05-08 13:00:36 +00001033 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001034
gio76d8ae62025-05-19 15:21:54 +00001035 const internalApi = express();
1036 internalApi.use(express.json());
1037 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001038 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1039 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1040 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001041
giod0026612025-05-08 13:00:36 +00001042 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001043 console.log("Web server started on port", env.DODO_PORT_WEB);
1044 });
1045
gio76d8ae62025-05-19 15:21:54 +00001046 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001047 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001048 });
1049}
1050
1051start();