blob: 0ba25b00cdf01ae1fdde9070cf01a353ff072336 [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";
gioc31bf142025-06-16 07:48:20 +000015import { Env, generateDodoConfig, ConfigSchema, AppNode, ConfigWithInput, configToGraph, Network } from "config";
gioa71316d2025-05-24 09:41:36 +040016
17async function generateKey(root: string): Promise<[string, string]> {
18 const privKeyPath = path.join(root, "key");
19 const pubKeyPath = path.join(root, "key.pub");
20 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
21 throw new Error("Failed to generate SSH key pair");
22 }
23 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
24 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
25 return [publicKey, privateKey];
26}
giod0026612025-05-08 13:00:36 +000027
28const db = new PrismaClient();
gio3ed59592025-05-14 16:51:09 +000029const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000030
gioa1efbad2025-05-21 07:16:45 +000031const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000032
giod0026612025-05-08 13:00:36 +000033const handleProjectCreate: express.Handler = async (req, resp) => {
34 try {
gioa71316d2025-05-24 09:41:36 +040035 const tmpDir = tmp.dirSync().name;
36 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000037 const { id } = await db.project.create({
38 data: {
gio09fcab52025-05-12 14:05:07 +000039 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000040 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040041 deployKey: privateKey,
42 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000043 },
44 });
45 resp.status(200);
46 resp.header("Content-Type", "application/json");
47 resp.write(
48 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000049 id: id.toString(),
giod0026612025-05-08 13:00:36 +000050 }),
51 );
52 } catch (e) {
53 console.log(e);
54 resp.status(500);
55 } finally {
56 resp.end();
57 }
58};
59
60const handleProjectAll: express.Handler = async (req, resp) => {
61 try {
62 const r = await db.project.findMany({
63 where: {
gio09fcab52025-05-12 14:05:07 +000064 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000065 },
66 });
67 resp.status(200);
68 resp.header("Content-Type", "application/json");
69 resp.write(
70 JSON.stringify(
71 r.map((p) => ({
72 id: p.id.toString(),
73 name: p.name,
74 })),
75 ),
76 );
77 } catch (e) {
78 console.log(e);
79 resp.status(500);
80 } finally {
81 resp.end();
82 }
83};
84
85const handleSave: express.Handler = async (req, resp) => {
86 try {
87 await db.project.update({
88 where: {
89 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +000090 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000091 },
92 data: {
giobd37a2b2025-05-15 04:28:42 +000093 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +000094 },
95 });
96 resp.status(200);
97 } catch (e) {
98 console.log(e);
99 resp.status(500);
100 } finally {
101 resp.end();
102 }
103};
104
gio818da4e2025-05-12 14:45:35 +0000105function handleSavedGet(state: "deploy" | "draft"): express.Handler {
106 return async (req, resp) => {
107 try {
108 const r = await db.project.findUnique({
109 where: {
110 id: Number(req.params["projectId"]),
111 userId: resp.locals.userId,
112 },
113 select: {
114 state: true,
115 draft: true,
116 },
117 });
118 if (r == null) {
119 resp.status(404);
120 return;
121 }
giod0026612025-05-08 13:00:36 +0000122 resp.status(200);
123 resp.header("content-type", "application/json");
gioc31bf142025-06-16 07:48:20 +0000124 let currentState: Record<string, unknown> | null = null;
gio818da4e2025-05-12 14:45:35 +0000125 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000126 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000127 currentState = {
giod0026612025-05-08 13:00:36 +0000128 nodes: [],
129 edges: [],
130 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000131 };
giod0026612025-05-08 13:00:36 +0000132 } else {
gioc31bf142025-06-16 07:48:20 +0000133 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
giod0026612025-05-08 13:00:36 +0000134 }
135 } else {
gio818da4e2025-05-12 14:45:35 +0000136 if (r.draft == null) {
137 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000138 currentState = {
gio818da4e2025-05-12 14:45:35 +0000139 nodes: [],
140 edges: [],
141 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000142 };
gio818da4e2025-05-12 14:45:35 +0000143 } else {
gioc31bf142025-06-16 07:48:20 +0000144 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000145 }
146 } else {
gioc31bf142025-06-16 07:48:20 +0000147 currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000148 }
giod0026612025-05-08 13:00:36 +0000149 }
gioc31bf142025-06-16 07:48:20 +0000150 const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
151 if (currentState) {
152 const config = generateDodoConfig(
153 req.params["projectId"].toString(),
154 currentState.nodes as AppNode[],
155 env,
156 );
157 resp.send({
158 state: currentState,
159 config,
160 });
161 }
gio818da4e2025-05-12 14:45:35 +0000162 } catch (e) {
163 console.log(e);
164 resp.status(500);
165 } finally {
166 resp.end();
giod0026612025-05-08 13:00:36 +0000167 }
gio818da4e2025-05-12 14:45:35 +0000168 };
169}
giod0026612025-05-08 13:00:36 +0000170
gioa71316d2025-05-24 09:41:36 +0400171const projectDeleteReqSchema = z.object({
172 state: z.optional(z.nullable(z.string())),
173});
174
175const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000176 try {
177 const projectId = Number(req.params["projectId"]);
178 const p = await db.project.findUnique({
179 where: {
180 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000181 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000182 },
183 select: {
184 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400185 githubToken: true,
186 deployKeyPublic: true,
187 state: true,
188 draft: true,
giod0026612025-05-08 13:00:36 +0000189 },
190 });
191 if (p === null) {
192 resp.status(404);
193 return;
194 }
gioa71316d2025-05-24 09:41:36 +0400195 const parseResult = projectDeleteReqSchema.safeParse(req.body);
196 if (!parseResult.success) {
197 resp.status(400);
198 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
199 return;
gioe440db82025-05-13 12:21:44 +0000200 }
gioa71316d2025-05-24 09:41:36 +0400201 if (p.githubToken && p.deployKeyPublic) {
202 const allRepos = [
203 ...new Set([
204 ...extractGithubRepos(p.state),
205 ...extractGithubRepos(p.draft),
206 ...extractGithubRepos(parseResult.data.state),
207 ]),
208 ];
209 if (allRepos.length > 0) {
210 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
211 const github = new GithubClient(p.githubToken);
212 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
213 console.log(
214 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
215 );
216 }
giod0026612025-05-08 13:00:36 +0000217 }
gioa71316d2025-05-24 09:41:36 +0400218 if (p.instanceId !== null) {
219 if (!(await appManager.removeInstance(p.instanceId))) {
220 resp.status(500);
221 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
222 return;
223 }
224 }
225 await db.project.delete({
226 where: {
227 id: projectId,
228 },
229 });
giod0026612025-05-08 13:00:36 +0000230 resp.status(200);
231 } catch (e) {
232 console.log(e);
233 resp.status(500);
234 } finally {
235 resp.end();
236 }
237};
238
gioa71316d2025-05-24 09:41:36 +0400239function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000240 if (!serializedState) {
241 return [];
242 }
243 try {
giobd37a2b2025-05-15 04:28:42 +0000244 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000245 const githubNodes = stateObj.nodes.filter(
246 // eslint-disable-next-line @typescript-eslint/no-explicit-any
247 (n: any) => n.type === "github" && n.data?.repository?.id,
248 );
249 // eslint-disable-next-line @typescript-eslint/no-explicit-any
250 return githubNodes.map((n: any) => n.data.repository.sshURL);
251 } catch (error) {
252 console.error("Failed to parse state or extract GitHub repos:", error);
253 return [];
254 }
255}
256
257type RepoDiff = {
258 toAdd?: string[];
259 toDelete?: string[];
260};
261
262function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
263 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
264 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
265 return { toAdd, toDelete };
266}
267
gio76d8ae62025-05-19 15:21:54 +0000268async function manageGithubRepos(
269 github: GithubClient,
270 diff: RepoDiff,
271 deployKey: string,
272 publicAddr?: string,
273): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000274 for (const repoUrl of diff.toDelete ?? []) {
275 try {
276 await github.removeDeployKey(repoUrl, deployKey);
277 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000278 if (publicAddr) {
279 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
280 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
281 console.log(`Removed push webhook from repository ${repoUrl}`);
282 }
gio3ed59592025-05-14 16:51:09 +0000283 } catch (error) {
284 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
285 }
286 }
287 for (const repoUrl of diff.toAdd ?? []) {
288 try {
289 await github.addDeployKey(repoUrl, deployKey);
290 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000291 if (publicAddr) {
292 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
293 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
294 console.log(`Added push webhook to repository ${repoUrl}`);
295 }
gio3ed59592025-05-14 16:51:09 +0000296 } catch (error) {
297 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
298 }
299 }
300}
301
giod0026612025-05-08 13:00:36 +0000302const handleDeploy: express.Handler = async (req, resp) => {
303 try {
304 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000305 const p = await db.project.findUnique({
306 where: {
307 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000308 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000309 },
310 select: {
311 instanceId: true,
312 githubToken: true,
313 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400314 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000315 state: true,
giod0026612025-05-08 13:00:36 +0000316 },
317 });
318 if (p === null) {
319 resp.status(404);
320 return;
321 }
gioc31bf142025-06-16 07:48:20 +0000322 const config = ConfigSchema.safeParse(req.body.config);
323 if (!config.success) {
324 resp.status(400);
325 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
326 return;
327 }
328 const state = req.body.state
329 ? JSON.stringify(req.body.state)
330 : JSON.stringify(
331 configToGraph(
332 config.data,
333 getNetworks(resp.locals.username),
334 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
335 ),
336 );
giod0026612025-05-08 13:00:36 +0000337 await db.project.update({
338 where: {
339 id: projectId,
340 },
341 data: {
342 draft: state,
343 },
344 });
gioa71316d2025-05-24 09:41:36 +0400345 let deployKey: string | null = p.deployKey;
346 let deployKeyPublic: string | null = p.deployKeyPublic;
347 if (deployKeyPublic == null) {
348 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
349 await db.project.update({
350 where: { id: projectId },
351 data: { deployKeyPublic, deployKey },
352 });
353 }
gio3ed59592025-05-14 16:51:09 +0000354 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000355 const cfg: ConfigWithInput = {
356 ...config.data,
357 input: {
358 appId: projectId.toString(),
359 managerAddr: env.INTERNAL_API_ADDR!,
360 key: {
361 public: deployKeyPublic!,
362 private: deployKey!,
363 },
364 },
gioa71316d2025-05-24 09:41:36 +0400365 };
gio3ed59592025-05-14 16:51:09 +0000366 try {
367 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000368 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000369 await db.project.update({
370 where: {
371 id: projectId,
372 },
373 data: {
374 state,
375 draft: null,
gio3ed59592025-05-14 16:51:09 +0000376 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000377 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000378 },
379 });
gio3ed59592025-05-14 16:51:09 +0000380 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000381 } else {
gioc31bf142025-06-16 07:48:20 +0000382 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000383 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000384 await db.project.update({
385 where: {
386 id: projectId,
387 },
388 data: {
389 state,
390 draft: null,
391 access: JSON.stringify(deployResponse.access),
392 },
393 });
giod0026612025-05-08 13:00:36 +0000394 }
gio3ed59592025-05-14 16:51:09 +0000395 if (diff && p.githubToken && deployKey) {
396 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400397 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000398 }
gio3ed59592025-05-14 16:51:09 +0000399 resp.status(200);
400 } catch (error) {
401 console.error("Deployment error:", error);
402 resp.status(500);
403 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000404 }
405 } catch (e) {
406 console.log(e);
407 resp.status(500);
408 } finally {
409 resp.end();
410 }
411};
412
413const handleStatus: express.Handler = async (req, resp) => {
414 try {
415 const projectId = Number(req.params["projectId"]);
416 const p = await db.project.findUnique({
417 where: {
418 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000419 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000420 },
421 select: {
422 instanceId: true,
423 },
424 });
giod0026612025-05-08 13:00:36 +0000425 if (p === null) {
426 resp.status(404);
427 return;
428 }
429 if (p.instanceId == null) {
430 resp.status(404);
431 return;
432 }
gio3ed59592025-05-14 16:51:09 +0000433 try {
434 const status = await appManager.getStatus(p.instanceId);
435 resp.status(200);
436 resp.write(JSON.stringify(status));
437 } catch (error) {
438 console.error("Error getting status:", error);
439 resp.status(500);
giod0026612025-05-08 13:00:36 +0000440 }
441 } catch (e) {
442 console.log(e);
443 resp.status(500);
444 } finally {
445 resp.end();
446 }
447};
448
gioc31bf142025-06-16 07:48:20 +0000449const handleConfigGet: express.Handler = async (req, resp) => {
450 try {
451 const projectId = Number(req.params["projectId"]);
452 const project = await db.project.findUnique({
453 where: {
454 id: projectId,
455 },
456 select: {
457 state: true,
458 },
459 });
460
461 if (!project || !project.state) {
462 resp.status(404).send({ error: "No deployed configuration found." });
463 return;
464 }
465
466 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
467 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
468 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
469
470 if (!config) {
471 resp.status(500).send({ error: "Failed to generate configuration." });
472 return;
473 }
474 resp.status(200).json(config);
475 } catch (e) {
476 console.log(e);
477 resp.status(500).send({ error: "Internal server error" });
478 } finally {
479 console.log("config get done");
480 resp.end();
481 }
482};
483
giobd37a2b2025-05-15 04:28:42 +0000484const handleRemoveDeployment: express.Handler = async (req, resp) => {
485 try {
486 const projectId = Number(req.params["projectId"]);
487 const p = await db.project.findUnique({
488 where: {
489 id: projectId,
490 userId: resp.locals.userId,
491 },
492 select: {
493 instanceId: true,
494 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400495 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000496 state: true,
497 draft: true,
498 },
499 });
500 if (p === null) {
501 resp.status(404);
502 resp.write(JSON.stringify({ error: "Project not found" }));
503 return;
504 }
505 if (p.instanceId == null) {
506 resp.status(400);
507 resp.write(JSON.stringify({ error: "Project not deployed" }));
508 return;
509 }
510 const removed = await appManager.removeInstance(p.instanceId);
511 if (!removed) {
512 resp.status(500);
513 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
514 return;
515 }
gioa71316d2025-05-24 09:41:36 +0400516 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000517 try {
518 const github = new GithubClient(p.githubToken);
519 const repos = extractGithubRepos(p.state);
520 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400521 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000522 } catch (error) {
523 console.error("Error removing GitHub deploy keys:", error);
524 }
525 }
526 await db.project.update({
527 where: {
528 id: projectId,
529 },
530 data: {
531 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400532 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000533 access: null,
giobd37a2b2025-05-15 04:28:42 +0000534 state: null,
535 draft: p.draft ?? p.state,
536 },
537 });
538 resp.status(200);
539 resp.write(JSON.stringify({ success: true }));
540 } catch (e) {
541 console.error("Error removing deployment:", e);
542 resp.status(500);
543 resp.write(JSON.stringify({ error: "Internal server error" }));
544 } finally {
545 resp.end();
546 }
547};
548
giod0026612025-05-08 13:00:36 +0000549const handleGithubRepos: express.Handler = async (req, resp) => {
550 try {
551 const projectId = Number(req.params["projectId"]);
552 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000553 where: {
554 id: projectId,
555 userId: resp.locals.userId,
556 },
557 select: {
558 githubToken: true,
559 },
giod0026612025-05-08 13:00:36 +0000560 });
giod0026612025-05-08 13:00:36 +0000561 if (!project?.githubToken) {
562 resp.status(400);
563 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
564 return;
565 }
giod0026612025-05-08 13:00:36 +0000566 const github = new GithubClient(project.githubToken);
567 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000568 resp.status(200);
569 resp.header("Content-Type", "application/json");
570 resp.write(JSON.stringify(repositories));
571 } catch (e) {
572 console.log(e);
573 resp.status(500);
574 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
575 } finally {
576 resp.end();
577 }
578};
579
580const handleUpdateGithubToken: express.Handler = async (req, resp) => {
581 try {
582 const projectId = Number(req.params["projectId"]);
583 const { githubToken } = req.body;
giod0026612025-05-08 13:00:36 +0000584 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000585 where: {
586 id: projectId,
587 userId: resp.locals.userId,
588 },
giod0026612025-05-08 13:00:36 +0000589 data: { githubToken },
590 });
giod0026612025-05-08 13:00:36 +0000591 resp.status(200);
592 } catch (e) {
593 console.log(e);
594 resp.status(500);
595 } finally {
596 resp.end();
597 }
598};
599
gioc31bf142025-06-16 07:48:20 +0000600const getNetworks = (username?: string | undefined): Network[] => {
601 return [
602 {
603 name: "Trial",
604 domain: "trial.dodoapp.xyz",
605 hasAuth: false,
606 },
607 // TODO(gio): Remove
608 ].concat(
609 username === "gio" || 1 == 1
610 ? [
611 {
612 name: "Public",
613 domain: "v1.dodo.cloud",
614 hasAuth: true,
615 },
616 {
617 name: "Private",
618 domain: "p.v1.dodo.cloud",
619 hasAuth: true,
620 },
621 ]
622 : [],
623 );
624};
625
626const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
627 const project = await db.project.findUnique({
628 where: {
629 id: projectId,
630 userId,
631 },
632 select: {
633 deployKeyPublic: true,
634 githubToken: true,
635 access: true,
636 instanceId: true,
637 },
638 });
639 if (!project) {
640 throw new Error("Project not found");
641 }
642 const monitor = projectMonitors.get(projectId);
643 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
644 const services = serviceNames.map((name: string) => ({
645 name,
646 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
647 ([id, status]) => ({
648 ...status,
649 id,
650 }),
651 ),
652 }));
653 return {
654 managerAddr: env.INTERNAL_API_ADDR,
655 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
656 instanceId: project.instanceId == null ? undefined : project.instanceId,
657 access: JSON.parse(project.access ?? "[]"),
658 integrations: {
659 github: !!project.githubToken,
660 },
661 networks: getNetworks(username),
662 services,
663 user: {
664 id: userId,
665 username: username,
666 },
667 };
668};
669
giod0026612025-05-08 13:00:36 +0000670const handleEnv: express.Handler = async (req, resp) => {
671 const projectId = Number(req.params["projectId"]);
672 try {
gioc31bf142025-06-16 07:48:20 +0000673 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000674 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000675 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000676 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000677 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000678 resp.status(500);
679 resp.write(JSON.stringify({ error: "Internal server error" }));
680 } finally {
681 resp.end();
682 }
683};
684
gio3a921b82025-05-10 07:36:09 +0000685const handleServiceLogs: express.Handler = async (req, resp) => {
686 try {
687 const projectId = Number(req.params["projectId"]);
688 const service = req.params["service"];
gioa1efbad2025-05-21 07:16:45 +0000689 const workerId = req.params["workerId"];
gio09fcab52025-05-12 14:05:07 +0000690 const project = await db.project.findUnique({
691 where: {
692 id: projectId,
693 userId: resp.locals.userId,
694 },
695 });
696 if (project == null) {
697 resp.status(404);
698 resp.write(JSON.stringify({ error: "Project not found" }));
699 return;
700 }
gioa1efbad2025-05-21 07:16:45 +0000701 const monitor = projectMonitors.get(projectId);
702 if (!monitor || !monitor.hasLogs()) {
gio3a921b82025-05-10 07:36:09 +0000703 resp.status(404);
704 resp.write(JSON.stringify({ error: "No logs found for this project" }));
705 return;
706 }
gioa1efbad2025-05-21 07:16:45 +0000707 const serviceLog = monitor.getWorkerLog(service, workerId);
gio3a921b82025-05-10 07:36:09 +0000708 if (!serviceLog) {
709 resp.status(404);
gioa1efbad2025-05-21 07:16:45 +0000710 resp.write(JSON.stringify({ error: "No logs found for this service/worker" }));
gio3a921b82025-05-10 07:36:09 +0000711 return;
712 }
gio3a921b82025-05-10 07:36:09 +0000713 resp.status(200);
714 resp.write(JSON.stringify({ logs: serviceLog }));
715 } catch (e) {
716 console.log(e);
717 resp.status(500);
718 resp.write(JSON.stringify({ error: "Failed to get service logs" }));
719 } finally {
720 resp.end();
721 }
722};
723
gio7d813702025-05-08 18:29:52 +0000724const handleRegisterWorker: express.Handler = async (req, resp) => {
725 try {
726 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000727 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000728 if (!result.success) {
729 resp.status(400);
730 resp.write(
731 JSON.stringify({
732 error: "Invalid request data",
733 details: result.error.format(),
734 }),
735 );
736 return;
737 }
gioa1efbad2025-05-21 07:16:45 +0000738 let monitor = projectMonitors.get(projectId);
739 if (!monitor) {
740 monitor = new ProjectMonitor();
741 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000742 }
gioa1efbad2025-05-21 07:16:45 +0000743 monitor.registerWorker(result.data);
gio7d813702025-05-08 18:29:52 +0000744 resp.status(200);
745 resp.write(
746 JSON.stringify({
747 success: true,
gio7d813702025-05-08 18:29:52 +0000748 }),
749 );
750 } catch (e) {
751 console.log(e);
752 resp.status(500);
753 resp.write(JSON.stringify({ error: "Failed to register worker" }));
754 } finally {
755 resp.end();
756 }
757};
758
gio76d8ae62025-05-19 15:21:54 +0000759async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000760 const monitor = projectMonitors.get(projectId);
761 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000762 const workerCount = projectWorkers.length;
763 if (workerCount === 0) {
764 return true;
765 }
766 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000767 projectWorkers.map(async (workerAddress: string) => {
768 try {
769 const { data } = await axios.get(`http://${workerAddress}/reload`);
770 return data.every((s: { status: string }) => s.status === "ok");
771 } catch (error) {
772 console.error(`Failed to reload worker ${workerAddress}:`, error);
773 return false;
774 }
gio76d8ae62025-05-19 15:21:54 +0000775 }),
776 );
gioc31bf142025-06-16 07:48:20 +0000777 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000778}
779
gio7d813702025-05-08 18:29:52 +0000780const handleReload: express.Handler = async (req, resp) => {
781 try {
782 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000783 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000784 where: {
785 id: projectId,
786 userId: resp.locals.userId,
787 },
gio76d8ae62025-05-19 15:21:54 +0000788 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000789 });
gio76d8ae62025-05-19 15:21:54 +0000790 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000791 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000792 return;
793 }
gio76d8ae62025-05-19 15:21:54 +0000794 const success = await reloadProject(projectId);
795 if (success) {
796 resp.status(200);
797 } else {
798 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000799 }
gio7d813702025-05-08 18:29:52 +0000800 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000801 console.error(e);
gio7d813702025-05-08 18:29:52 +0000802 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000803 }
804};
805
gio918780d2025-05-22 08:24:41 +0000806const handleReloadWorker: express.Handler = async (req, resp) => {
807 const projectId = Number(req.params["projectId"]);
808 const serviceName = req.params["serviceName"];
809 const workerId = req.params["workerId"];
810
811 const projectMonitor = projectMonitors.get(projectId);
812 if (!projectMonitor) {
813 resp.status(404).send({ error: "Project monitor not found" });
814 return;
815 }
816
817 try {
818 await projectMonitor.reloadWorker(serviceName, workerId);
819 resp.status(200).send({ message: "Worker reload initiated" });
820 } catch (error) {
821 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
822 const errorMessage = error instanceof Error ? error.message : "Unknown error";
823 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
824 }
825};
826
gioa71316d2025-05-24 09:41:36 +0400827const analyzeRepoReqSchema = z.object({
828 address: z.string(),
829});
830
831const handleAnalyzeRepo: express.Handler = async (req, resp) => {
832 const projectId = Number(req.params["projectId"]);
833 const project = await db.project.findUnique({
834 where: {
835 id: projectId,
836 userId: resp.locals.userId,
837 },
838 select: {
839 githubToken: true,
840 deployKey: true,
841 deployKeyPublic: true,
842 },
843 });
844 if (!project) {
845 resp.status(404).send({ error: "Project not found" });
846 return;
847 }
848 if (!project.githubToken) {
849 resp.status(400).send({ error: "GitHub token not configured" });
850 return;
851 }
gio8e74dc02025-06-13 10:19:26 +0000852 let tmpDir: tmp.DirResult | null = null;
853 try {
854 let deployKey: string | null = project.deployKey;
855 let deployKeyPublic: string | null = project.deployKeyPublic;
856 if (!deployKeyPublic) {
857 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
858 await db.project.update({
859 where: { id: projectId },
860 data: {
861 deployKeyPublic: deployKeyPublic,
862 deployKey: deployKey,
863 },
864 });
865 }
866 const github = new GithubClient(project.githubToken);
867 const result = analyzeRepoReqSchema.safeParse(req.body);
868 if (!result.success) {
869 resp.status(400).send({ error: "Invalid request data" });
870 return;
871 }
872 const { address } = result.data;
873 tmpDir = tmp.dirSync({
874 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400875 });
gio8e74dc02025-06-13 10:19:26 +0000876 await github.addDeployKey(address, deployKeyPublic);
877 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
878 mode: 0o600,
879 });
880 shell.exec(
881 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
882 );
883 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
884 const analyzer = new NodeJSAnalyzer();
885 const info = await analyzer.analyze(fsc, "/");
886 resp.status(200).send([info]);
887 } catch (e) {
888 console.error(e);
889 resp.status(500).send({ error: "Failed to analyze repository" });
890 } finally {
891 if (tmpDir) {
892 tmpDir.removeCallback();
893 }
894 resp.end();
gioa71316d2025-05-24 09:41:36 +0400895 }
gioa71316d2025-05-24 09:41:36 +0400896};
897
gio09fcab52025-05-12 14:05:07 +0000898const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
899 const userId = req.get("x-forwarded-userid");
gio3ed59592025-05-14 16:51:09 +0000900 const username = req.get("x-forwarded-user");
901 if (userId == null || username == null) {
gio09fcab52025-05-12 14:05:07 +0000902 resp.status(401);
903 resp.write("Unauthorized");
904 resp.end();
905 return;
906 }
907 resp.locals.userId = userId;
gio3ed59592025-05-14 16:51:09 +0000908 resp.locals.username = username;
gio09fcab52025-05-12 14:05:07 +0000909 next();
910};
911
gio76d8ae62025-05-19 15:21:54 +0000912const handleGithubPushWebhook: express.Handler = async (req, resp) => {
913 try {
914 // TODO(gio): Implement GitHub signature verification for security
915 const webhookSchema = z.object({
916 repository: z.object({
917 ssh_url: z.string(),
918 }),
919 });
920
921 const result = webhookSchema.safeParse(req.body);
922 if (!result.success) {
923 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
924 resp.status(400).json({ error: "Invalid webhook payload" });
925 return;
926 }
927 const { ssh_url: addr } = result.data.repository;
928 const allProjects = await db.project.findMany({
929 select: {
930 id: true,
931 state: true,
932 },
933 where: {
934 instanceId: {
935 not: null,
936 },
937 },
938 });
939 // TODO(gio): This should run in background
940 new Promise<boolean>((resolve, reject) => {
941 setTimeout(() => {
942 const projectsToReloadIds: number[] = [];
943 for (const project of allProjects) {
944 if (project.state && project.state.length > 0) {
945 const projectRepos = extractGithubRepos(project.state);
946 if (projectRepos.includes(addr)) {
947 projectsToReloadIds.push(project.id);
948 }
949 }
950 }
951 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
952 .then((results) => {
953 resolve(results.reduce((acc, curr) => acc && curr, true));
954 })
955 // eslint-disable-next-line @typescript-eslint/no-explicit-any
956 .catch((reason: any) => reject(reason));
957 }, 10);
958 });
959 // eslint-disable-next-line @typescript-eslint/no-explicit-any
960 } catch (error: any) {
961 console.error(error);
962 resp.status(500);
963 }
964};
965
gioc31bf142025-06-16 07:48:20 +0000966const handleValidateConfig: express.Handler = async (req, resp) => {
967 try {
968 const validationResult = ConfigSchema.safeParse(req.body);
969 if (!validationResult.success) {
970 resp.status(400);
971 resp.header("Content-Type", "application/json");
972 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
973 } else {
974 resp.status(200);
975 resp.header("Content-Type", "application/json");
976 resp.write(JSON.stringify({ success: true }));
977 }
978 } catch (e) {
979 console.log(e);
980 resp.status(500);
981 } finally {
982 resp.end();
983 }
984};
985
giod0026612025-05-08 13:00:36 +0000986async function start() {
987 await db.$connect();
988 const app = express();
gioc31bf142025-06-16 07:48:20 +0000989 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +0000990 app.use(express.json()); // Global JSON parsing
991
992 // Public webhook route - no auth needed
993 app.post("/api/webhook/github/push", handleGithubPushWebhook);
994
995 // Authenticated project routes
996 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +0400997 projectRouter.use(auth);
998 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +0000999 projectRouter.post("/:projectId/saved", handleSave);
1000 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1001 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1002 projectRouter.post("/:projectId/deploy", handleDeploy);
1003 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001004 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001005 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001006 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1007 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
1008 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001009 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +00001010 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001011 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001012 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1013 projectRouter.get("/", handleProjectAll);
1014 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001015
gio76d8ae62025-05-19 15:21:54 +00001016 app.use("/api/project", projectRouter); // Mount the authenticated router
1017
giod0026612025-05-08 13:00:36 +00001018 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001019
gio76d8ae62025-05-19 15:21:54 +00001020 const internalApi = express();
1021 internalApi.use(express.json());
1022 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001023 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1024 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1025 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001026
giod0026612025-05-08 13:00:36 +00001027 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001028 console.log("Web server started on port", env.DODO_PORT_WEB);
1029 });
1030
gio76d8ae62025-05-19 15:21:54 +00001031 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001032 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001033 });
1034}
1035
1036start();