blob: a2e8b31565de589447fa23159b6badc6bb312545 [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";
6import { GithubClient } from "./github";
gio3ed59592025-05-14 16:51:09 +00007import { AppManager } from "./app_manager";
gio7d813702025-05-08 18:29:52 +00008import { z } from "zod";
gioa1efbad2025-05-21 07:16:45 +00009import { ProjectMonitor, WorkerSchema } from "./project_monitor";
gioa71316d2025-05-24 09:41:36 +040010import tmp from "tmp";
11import { NodeJSAnalyzer } from "./lib/nodejs";
12import shell from "shelljs";
13import { RealFileSystem } from "./lib/fs";
14import path from "node:path";
15
16async function generateKey(root: string): Promise<[string, string]> {
17 const privKeyPath = path.join(root, "key");
18 const pubKeyPath = path.join(root, "key.pub");
19 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
20 throw new Error("Failed to generate SSH key pair");
21 }
22 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
23 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
24 return [publicKey, privateKey];
25}
giod0026612025-05-08 13:00:36 +000026
27const db = new PrismaClient();
gio3ed59592025-05-14 16:51:09 +000028const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000029
gioa1efbad2025-05-21 07:16:45 +000030const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000031
giod0026612025-05-08 13:00:36 +000032const handleProjectCreate: express.Handler = async (req, resp) => {
33 try {
gioa71316d2025-05-24 09:41:36 +040034 const tmpDir = tmp.dirSync().name;
35 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000036 const { id } = await db.project.create({
37 data: {
gio09fcab52025-05-12 14:05:07 +000038 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000039 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040040 deployKey: privateKey,
41 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000042 },
43 });
44 resp.status(200);
45 resp.header("Content-Type", "application/json");
46 resp.write(
47 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000048 id: id.toString(),
giod0026612025-05-08 13:00:36 +000049 }),
50 );
51 } catch (e) {
52 console.log(e);
53 resp.status(500);
54 } finally {
55 resp.end();
56 }
57};
58
59const handleProjectAll: express.Handler = async (req, resp) => {
60 try {
61 const r = await db.project.findMany({
62 where: {
gio09fcab52025-05-12 14:05:07 +000063 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000064 },
65 });
66 resp.status(200);
67 resp.header("Content-Type", "application/json");
68 resp.write(
69 JSON.stringify(
70 r.map((p) => ({
71 id: p.id.toString(),
72 name: p.name,
73 })),
74 ),
75 );
76 } catch (e) {
77 console.log(e);
78 resp.status(500);
79 } finally {
80 resp.end();
81 }
82};
83
84const handleSave: express.Handler = async (req, resp) => {
85 try {
86 await db.project.update({
87 where: {
88 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +000089 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000090 },
91 data: {
giobd37a2b2025-05-15 04:28:42 +000092 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +000093 },
94 });
95 resp.status(200);
96 } catch (e) {
97 console.log(e);
98 resp.status(500);
99 } finally {
100 resp.end();
101 }
102};
103
gio818da4e2025-05-12 14:45:35 +0000104function handleSavedGet(state: "deploy" | "draft"): express.Handler {
105 return async (req, resp) => {
106 try {
107 const r = await db.project.findUnique({
108 where: {
109 id: Number(req.params["projectId"]),
110 userId: resp.locals.userId,
111 },
112 select: {
113 state: true,
114 draft: true,
115 },
116 });
117 if (r == null) {
118 resp.status(404);
119 return;
120 }
giod0026612025-05-08 13:00:36 +0000121 resp.status(200);
122 resp.header("content-type", "application/json");
gio818da4e2025-05-12 14:45:35 +0000123 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000124 if (r.state == null) {
125 resp.send({
126 nodes: [],
127 edges: [],
128 viewport: { x: 0, y: 0, zoom: 1 },
129 });
130 } else {
131 resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
132 }
133 } else {
gio818da4e2025-05-12 14:45:35 +0000134 if (r.draft == null) {
135 if (r.state == null) {
136 resp.send({
137 nodes: [],
138 edges: [],
139 viewport: { x: 0, y: 0, zoom: 1 },
140 });
141 } else {
142 resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
143 }
144 } else {
145 resp.send(JSON.parse(Buffer.from(r.draft).toString("utf8")));
146 }
giod0026612025-05-08 13:00:36 +0000147 }
gio818da4e2025-05-12 14:45:35 +0000148 } catch (e) {
149 console.log(e);
150 resp.status(500);
151 } finally {
152 resp.end();
giod0026612025-05-08 13:00:36 +0000153 }
gio818da4e2025-05-12 14:45:35 +0000154 };
155}
giod0026612025-05-08 13:00:36 +0000156
gioa71316d2025-05-24 09:41:36 +0400157const projectDeleteReqSchema = z.object({
158 state: z.optional(z.nullable(z.string())),
159});
160
161const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000162 try {
163 const projectId = Number(req.params["projectId"]);
164 const p = await db.project.findUnique({
165 where: {
166 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000167 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000168 },
169 select: {
170 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400171 githubToken: true,
172 deployKeyPublic: true,
173 state: true,
174 draft: true,
giod0026612025-05-08 13:00:36 +0000175 },
176 });
177 if (p === null) {
178 resp.status(404);
179 return;
180 }
gioa71316d2025-05-24 09:41:36 +0400181 const parseResult = projectDeleteReqSchema.safeParse(req.body);
182 if (!parseResult.success) {
183 resp.status(400);
184 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
185 return;
gioe440db82025-05-13 12:21:44 +0000186 }
gioa71316d2025-05-24 09:41:36 +0400187 if (p.githubToken && p.deployKeyPublic) {
188 const allRepos = [
189 ...new Set([
190 ...extractGithubRepos(p.state),
191 ...extractGithubRepos(p.draft),
192 ...extractGithubRepos(parseResult.data.state),
193 ]),
194 ];
195 if (allRepos.length > 0) {
196 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
197 const github = new GithubClient(p.githubToken);
198 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
199 console.log(
200 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
201 );
202 }
giod0026612025-05-08 13:00:36 +0000203 }
gioa71316d2025-05-24 09:41:36 +0400204 if (p.instanceId !== null) {
205 if (!(await appManager.removeInstance(p.instanceId))) {
206 resp.status(500);
207 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
208 return;
209 }
210 }
211 await db.project.delete({
212 where: {
213 id: projectId,
214 },
215 });
giod0026612025-05-08 13:00:36 +0000216 resp.status(200);
217 } catch (e) {
218 console.log(e);
219 resp.status(500);
220 } finally {
221 resp.end();
222 }
223};
224
gioa71316d2025-05-24 09:41:36 +0400225function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000226 if (!serializedState) {
227 return [];
228 }
229 try {
giobd37a2b2025-05-15 04:28:42 +0000230 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000231 const githubNodes = stateObj.nodes.filter(
232 // eslint-disable-next-line @typescript-eslint/no-explicit-any
233 (n: any) => n.type === "github" && n.data?.repository?.id,
234 );
235 // eslint-disable-next-line @typescript-eslint/no-explicit-any
236 return githubNodes.map((n: any) => n.data.repository.sshURL);
237 } catch (error) {
238 console.error("Failed to parse state or extract GitHub repos:", error);
239 return [];
240 }
241}
242
243type RepoDiff = {
244 toAdd?: string[];
245 toDelete?: string[];
246};
247
248function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
249 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
250 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
251 return { toAdd, toDelete };
252}
253
gio76d8ae62025-05-19 15:21:54 +0000254async function manageGithubRepos(
255 github: GithubClient,
256 diff: RepoDiff,
257 deployKey: string,
258 publicAddr?: string,
259): Promise<void> {
260 console.log(publicAddr);
gio3ed59592025-05-14 16:51:09 +0000261 for (const repoUrl of diff.toDelete ?? []) {
262 try {
263 await github.removeDeployKey(repoUrl, deployKey);
264 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000265 if (publicAddr) {
266 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
267 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
268 console.log(`Removed push webhook from repository ${repoUrl}`);
269 }
gio3ed59592025-05-14 16:51:09 +0000270 } catch (error) {
271 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
272 }
273 }
274 for (const repoUrl of diff.toAdd ?? []) {
275 try {
276 await github.addDeployKey(repoUrl, deployKey);
277 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000278 if (publicAddr) {
279 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
280 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
281 console.log(`Added push webhook to repository ${repoUrl}`);
282 }
gio3ed59592025-05-14 16:51:09 +0000283 } catch (error) {
284 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
285 }
286 }
287}
288
giod0026612025-05-08 13:00:36 +0000289const handleDeploy: express.Handler = async (req, resp) => {
290 try {
291 const projectId = Number(req.params["projectId"]);
giobd37a2b2025-05-15 04:28:42 +0000292 const state = JSON.stringify(req.body.state);
giod0026612025-05-08 13:00:36 +0000293 const p = await db.project.findUnique({
294 where: {
295 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000296 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000297 },
298 select: {
299 instanceId: true,
300 githubToken: true,
301 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400302 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000303 state: true,
giod0026612025-05-08 13:00:36 +0000304 },
305 });
306 if (p === null) {
307 resp.status(404);
308 return;
309 }
310 await db.project.update({
311 where: {
312 id: projectId,
313 },
314 data: {
315 draft: state,
316 },
317 });
gioa71316d2025-05-24 09:41:36 +0400318 let deployKey: string | null = p.deployKey;
319 let deployKeyPublic: string | null = p.deployKeyPublic;
320 if (deployKeyPublic == null) {
321 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
322 await db.project.update({
323 where: { id: projectId },
324 data: { deployKeyPublic, deployKey },
325 });
326 }
gio3ed59592025-05-14 16:51:09 +0000327 let diff: RepoDiff | null = null;
gioa71316d2025-05-24 09:41:36 +0400328 const config = req.body.config;
329 config.input.key = {
330 public: deployKeyPublic,
331 private: deployKey,
332 };
gio3ed59592025-05-14 16:51:09 +0000333 try {
334 if (p.instanceId == null) {
gioa71316d2025-05-24 09:41:36 +0400335 const deployResponse = await appManager.deploy(config);
giod0026612025-05-08 13:00:36 +0000336 await db.project.update({
337 where: {
338 id: projectId,
339 },
340 data: {
341 state,
342 draft: null,
gio3ed59592025-05-14 16:51:09 +0000343 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000344 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000345 },
346 });
gio3ed59592025-05-14 16:51:09 +0000347 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000348 } else {
gioa71316d2025-05-24 09:41:36 +0400349 const deployResponse = await appManager.update(p.instanceId, config);
giob77cb932025-05-19 09:37:14 +0000350 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000351 await db.project.update({
352 where: {
353 id: projectId,
354 },
355 data: {
356 state,
357 draft: null,
358 access: JSON.stringify(deployResponse.access),
359 },
360 });
giod0026612025-05-08 13:00:36 +0000361 }
gio3ed59592025-05-14 16:51:09 +0000362 if (diff && p.githubToken && deployKey) {
363 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400364 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000365 }
gio3ed59592025-05-14 16:51:09 +0000366 resp.status(200);
367 } catch (error) {
368 console.error("Deployment error:", error);
369 resp.status(500);
370 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000371 }
372 } catch (e) {
373 console.log(e);
374 resp.status(500);
375 } finally {
376 resp.end();
377 }
378};
379
380const handleStatus: express.Handler = async (req, resp) => {
381 try {
382 const projectId = Number(req.params["projectId"]);
383 const p = await db.project.findUnique({
384 where: {
385 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000386 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000387 },
388 select: {
389 instanceId: true,
390 },
391 });
giod0026612025-05-08 13:00:36 +0000392 if (p === null) {
393 resp.status(404);
394 return;
395 }
396 if (p.instanceId == null) {
397 resp.status(404);
398 return;
399 }
gio3ed59592025-05-14 16:51:09 +0000400 try {
401 const status = await appManager.getStatus(p.instanceId);
402 resp.status(200);
403 resp.write(JSON.stringify(status));
404 } catch (error) {
405 console.error("Error getting status:", error);
406 resp.status(500);
giod0026612025-05-08 13:00:36 +0000407 }
408 } catch (e) {
409 console.log(e);
410 resp.status(500);
411 } finally {
412 resp.end();
413 }
414};
415
giobd37a2b2025-05-15 04:28:42 +0000416const handleRemoveDeployment: express.Handler = async (req, resp) => {
417 try {
418 const projectId = Number(req.params["projectId"]);
419 const p = await db.project.findUnique({
420 where: {
421 id: projectId,
422 userId: resp.locals.userId,
423 },
424 select: {
425 instanceId: true,
426 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400427 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000428 state: true,
429 draft: true,
430 },
431 });
432 if (p === null) {
433 resp.status(404);
434 resp.write(JSON.stringify({ error: "Project not found" }));
435 return;
436 }
437 if (p.instanceId == null) {
438 resp.status(400);
439 resp.write(JSON.stringify({ error: "Project not deployed" }));
440 return;
441 }
442 const removed = await appManager.removeInstance(p.instanceId);
443 if (!removed) {
444 resp.status(500);
445 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
446 return;
447 }
gioa71316d2025-05-24 09:41:36 +0400448 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000449 try {
450 const github = new GithubClient(p.githubToken);
451 const repos = extractGithubRepos(p.state);
452 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400453 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000454 } catch (error) {
455 console.error("Error removing GitHub deploy keys:", error);
456 }
457 }
458 await db.project.update({
459 where: {
460 id: projectId,
461 },
462 data: {
463 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400464 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000465 access: null,
giobd37a2b2025-05-15 04:28:42 +0000466 state: null,
467 draft: p.draft ?? p.state,
468 },
469 });
470 resp.status(200);
471 resp.write(JSON.stringify({ success: true }));
472 } catch (e) {
473 console.error("Error removing deployment:", e);
474 resp.status(500);
475 resp.write(JSON.stringify({ error: "Internal server error" }));
476 } finally {
477 resp.end();
478 }
479};
480
giod0026612025-05-08 13:00:36 +0000481const handleGithubRepos: express.Handler = async (req, resp) => {
482 try {
483 const projectId = Number(req.params["projectId"]);
484 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000485 where: {
486 id: projectId,
487 userId: resp.locals.userId,
488 },
489 select: {
490 githubToken: true,
491 },
giod0026612025-05-08 13:00:36 +0000492 });
giod0026612025-05-08 13:00:36 +0000493 if (!project?.githubToken) {
494 resp.status(400);
495 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
496 return;
497 }
giod0026612025-05-08 13:00:36 +0000498 const github = new GithubClient(project.githubToken);
499 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000500 resp.status(200);
501 resp.header("Content-Type", "application/json");
502 resp.write(JSON.stringify(repositories));
503 } catch (e) {
504 console.log(e);
505 resp.status(500);
506 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
507 } finally {
508 resp.end();
509 }
510};
511
512const handleUpdateGithubToken: express.Handler = async (req, resp) => {
513 try {
514 const projectId = Number(req.params["projectId"]);
515 const { githubToken } = req.body;
giod0026612025-05-08 13:00:36 +0000516 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000517 where: {
518 id: projectId,
519 userId: resp.locals.userId,
520 },
giod0026612025-05-08 13:00:36 +0000521 data: { githubToken },
522 });
giod0026612025-05-08 13:00:36 +0000523 resp.status(200);
524 } catch (e) {
525 console.log(e);
526 resp.status(500);
527 } finally {
528 resp.end();
529 }
530};
531
532const handleEnv: express.Handler = async (req, resp) => {
533 const projectId = Number(req.params["projectId"]);
534 try {
535 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000536 where: {
537 id: projectId,
538 userId: resp.locals.userId,
539 },
giod0026612025-05-08 13:00:36 +0000540 select: {
gioa71316d2025-05-24 09:41:36 +0400541 deployKeyPublic: true,
giod0026612025-05-08 13:00:36 +0000542 githubToken: true,
giob77cb932025-05-19 09:37:14 +0000543 access: true,
gioa71316d2025-05-24 09:41:36 +0400544 instanceId: true,
giod0026612025-05-08 13:00:36 +0000545 },
546 });
giod0026612025-05-08 13:00:36 +0000547 if (!project) {
548 resp.status(404);
549 resp.write(JSON.stringify({ error: "Project not found" }));
550 return;
551 }
gioa1efbad2025-05-21 07:16:45 +0000552 const monitor = projectMonitors.get(projectId);
553 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
554 const services = serviceNames.map((name) => ({
555 name,
556 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
557 ([id, status]) => ({
558 ...status,
559 id,
560 }),
561 ),
562 }));
563
giod0026612025-05-08 13:00:36 +0000564 resp.status(200);
565 resp.write(
566 JSON.stringify({
gio376a81d2025-05-20 06:42:01 +0000567 managerAddr: env.INTERNAL_API_ADDR,
gioa71316d2025-05-24 09:41:36 +0400568 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
569 instanceId: project.instanceId == null ? undefined : project.instanceId,
giob77cb932025-05-19 09:37:14 +0000570 access: JSON.parse(project.access ?? "[]"),
giod0026612025-05-08 13:00:36 +0000571 integrations: {
572 github: !!project.githubToken,
573 },
574 networks: [
575 {
gio33046722025-05-16 14:49:55 +0000576 name: "Trial",
577 domain: "trial.dodoapp.xyz",
gio6d8b71c2025-05-19 12:57:35 +0000578 hasAuth: false,
giod0026612025-05-08 13:00:36 +0000579 },
giob1c5c452025-05-21 04:16:54 +0000580 // TODO(gio): Remove
581 ].concat(
582 resp.locals.username !== "gio"
583 ? []
584 : [
585 {
586 name: "Public",
587 domain: "v1.dodo.cloud",
588 hasAuth: true,
589 },
590 {
591 name: "Private",
592 domain: "p.v1.dodo.cloud",
593 hasAuth: true,
594 },
595 ],
596 ),
gio3a921b82025-05-10 07:36:09 +0000597 services,
gio3ed59592025-05-14 16:51:09 +0000598 user: {
599 id: resp.locals.userId,
600 username: resp.locals.username,
601 },
giod0026612025-05-08 13:00:36 +0000602 }),
603 );
604 } catch (error) {
605 console.error("Error checking integrations:", error);
606 resp.status(500);
607 resp.write(JSON.stringify({ error: "Internal server error" }));
608 } finally {
609 resp.end();
610 }
611};
612
gio3a921b82025-05-10 07:36:09 +0000613const handleServiceLogs: express.Handler = async (req, resp) => {
614 try {
615 const projectId = Number(req.params["projectId"]);
616 const service = req.params["service"];
gioa1efbad2025-05-21 07:16:45 +0000617 const workerId = req.params["workerId"];
gio09fcab52025-05-12 14:05:07 +0000618 const project = await db.project.findUnique({
619 where: {
620 id: projectId,
621 userId: resp.locals.userId,
622 },
623 });
624 if (project == null) {
625 resp.status(404);
626 resp.write(JSON.stringify({ error: "Project not found" }));
627 return;
628 }
gioa1efbad2025-05-21 07:16:45 +0000629 const monitor = projectMonitors.get(projectId);
630 if (!monitor || !monitor.hasLogs()) {
gio3a921b82025-05-10 07:36:09 +0000631 resp.status(404);
632 resp.write(JSON.stringify({ error: "No logs found for this project" }));
633 return;
634 }
gioa1efbad2025-05-21 07:16:45 +0000635 const serviceLog = monitor.getWorkerLog(service, workerId);
gio3a921b82025-05-10 07:36:09 +0000636 if (!serviceLog) {
637 resp.status(404);
gioa1efbad2025-05-21 07:16:45 +0000638 resp.write(JSON.stringify({ error: "No logs found for this service/worker" }));
gio3a921b82025-05-10 07:36:09 +0000639 return;
640 }
gio3a921b82025-05-10 07:36:09 +0000641 resp.status(200);
642 resp.write(JSON.stringify({ logs: serviceLog }));
643 } catch (e) {
644 console.log(e);
645 resp.status(500);
646 resp.write(JSON.stringify({ error: "Failed to get service logs" }));
647 } finally {
648 resp.end();
649 }
650};
651
gio7d813702025-05-08 18:29:52 +0000652const handleRegisterWorker: express.Handler = async (req, resp) => {
653 try {
654 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000655 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000656 if (!result.success) {
657 resp.status(400);
658 resp.write(
659 JSON.stringify({
660 error: "Invalid request data",
661 details: result.error.format(),
662 }),
663 );
664 return;
665 }
gioa1efbad2025-05-21 07:16:45 +0000666 let monitor = projectMonitors.get(projectId);
667 if (!monitor) {
668 monitor = new ProjectMonitor();
669 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000670 }
gioa1efbad2025-05-21 07:16:45 +0000671 monitor.registerWorker(result.data);
gio7d813702025-05-08 18:29:52 +0000672 resp.status(200);
673 resp.write(
674 JSON.stringify({
675 success: true,
gio7d813702025-05-08 18:29:52 +0000676 }),
677 );
678 } catch (e) {
679 console.log(e);
680 resp.status(500);
681 resp.write(JSON.stringify({ error: "Failed to register worker" }));
682 } finally {
683 resp.end();
684 }
685};
686
gio76d8ae62025-05-19 15:21:54 +0000687async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000688 const monitor = projectMonitors.get(projectId);
689 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000690 const workerCount = projectWorkers.length;
691 if (workerCount === 0) {
692 return true;
693 }
694 const results = await Promise.all(
695 projectWorkers.map(async (workerAddress) => {
696 const resp = await axios.post(`${workerAddress}/update`);
697 return resp.status === 200;
698 }),
699 );
700 return results.reduce((acc, curr) => acc && curr, true);
701}
702
gio7d813702025-05-08 18:29:52 +0000703const handleReload: express.Handler = async (req, resp) => {
704 try {
705 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000706 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000707 where: {
708 id: projectId,
709 userId: resp.locals.userId,
710 },
gio76d8ae62025-05-19 15:21:54 +0000711 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000712 });
gio76d8ae62025-05-19 15:21:54 +0000713 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000714 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000715 return;
716 }
gio76d8ae62025-05-19 15:21:54 +0000717 const success = await reloadProject(projectId);
718 if (success) {
719 resp.status(200);
720 } else {
721 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000722 }
gio7d813702025-05-08 18:29:52 +0000723 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000724 console.error(e);
gio7d813702025-05-08 18:29:52 +0000725 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000726 }
727};
728
gio918780d2025-05-22 08:24:41 +0000729const handleReloadWorker: express.Handler = async (req, resp) => {
730 const projectId = Number(req.params["projectId"]);
731 const serviceName = req.params["serviceName"];
732 const workerId = req.params["workerId"];
733
734 const projectMonitor = projectMonitors.get(projectId);
735 if (!projectMonitor) {
736 resp.status(404).send({ error: "Project monitor not found" });
737 return;
738 }
739
740 try {
741 await projectMonitor.reloadWorker(serviceName, workerId);
742 resp.status(200).send({ message: "Worker reload initiated" });
743 } catch (error) {
744 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
745 const errorMessage = error instanceof Error ? error.message : "Unknown error";
746 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
747 }
748};
749
gioa71316d2025-05-24 09:41:36 +0400750const analyzeRepoReqSchema = z.object({
751 address: z.string(),
752});
753
754const handleAnalyzeRepo: express.Handler = async (req, resp) => {
755 const projectId = Number(req.params["projectId"]);
756 const project = await db.project.findUnique({
757 where: {
758 id: projectId,
759 userId: resp.locals.userId,
760 },
761 select: {
762 githubToken: true,
763 deployKey: true,
764 deployKeyPublic: true,
765 },
766 });
767 if (!project) {
768 resp.status(404).send({ error: "Project not found" });
769 return;
770 }
771 if (!project.githubToken) {
772 resp.status(400).send({ error: "GitHub token not configured" });
773 return;
774 }
gio8e74dc02025-06-13 10:19:26 +0000775 let tmpDir: tmp.DirResult | null = null;
776 try {
777 let deployKey: string | null = project.deployKey;
778 let deployKeyPublic: string | null = project.deployKeyPublic;
779 if (!deployKeyPublic) {
780 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
781 await db.project.update({
782 where: { id: projectId },
783 data: {
784 deployKeyPublic: deployKeyPublic,
785 deployKey: deployKey,
786 },
787 });
788 }
789 const github = new GithubClient(project.githubToken);
790 const result = analyzeRepoReqSchema.safeParse(req.body);
791 if (!result.success) {
792 resp.status(400).send({ error: "Invalid request data" });
793 return;
794 }
795 const { address } = result.data;
796 tmpDir = tmp.dirSync({
797 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400798 });
gio8e74dc02025-06-13 10:19:26 +0000799 await github.addDeployKey(address, deployKeyPublic);
800 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
801 mode: 0o600,
802 });
803 shell.exec(
804 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
805 );
806 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
807 const analyzer = new NodeJSAnalyzer();
808 const info = await analyzer.analyze(fsc, "/");
809 resp.status(200).send([info]);
810 } catch (e) {
811 console.error(e);
812 resp.status(500).send({ error: "Failed to analyze repository" });
813 } finally {
814 if (tmpDir) {
815 tmpDir.removeCallback();
816 }
817 resp.end();
gioa71316d2025-05-24 09:41:36 +0400818 }
gioa71316d2025-05-24 09:41:36 +0400819};
820
gio09fcab52025-05-12 14:05:07 +0000821const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
822 const userId = req.get("x-forwarded-userid");
gio3ed59592025-05-14 16:51:09 +0000823 const username = req.get("x-forwarded-user");
824 if (userId == null || username == null) {
gio09fcab52025-05-12 14:05:07 +0000825 resp.status(401);
826 resp.write("Unauthorized");
827 resp.end();
828 return;
829 }
830 resp.locals.userId = userId;
gio3ed59592025-05-14 16:51:09 +0000831 resp.locals.username = username;
gio09fcab52025-05-12 14:05:07 +0000832 next();
833};
834
gio76d8ae62025-05-19 15:21:54 +0000835const handleGithubPushWebhook: express.Handler = async (req, resp) => {
836 try {
837 // TODO(gio): Implement GitHub signature verification for security
838 const webhookSchema = z.object({
839 repository: z.object({
840 ssh_url: z.string(),
841 }),
842 });
843
844 const result = webhookSchema.safeParse(req.body);
845 if (!result.success) {
846 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
847 resp.status(400).json({ error: "Invalid webhook payload" });
848 return;
849 }
850 const { ssh_url: addr } = result.data.repository;
851 const allProjects = await db.project.findMany({
852 select: {
853 id: true,
854 state: true,
855 },
856 where: {
857 instanceId: {
858 not: null,
859 },
860 },
861 });
862 // TODO(gio): This should run in background
863 new Promise<boolean>((resolve, reject) => {
864 setTimeout(() => {
865 const projectsToReloadIds: number[] = [];
866 for (const project of allProjects) {
867 if (project.state && project.state.length > 0) {
868 const projectRepos = extractGithubRepos(project.state);
869 if (projectRepos.includes(addr)) {
870 projectsToReloadIds.push(project.id);
871 }
872 }
873 }
874 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
875 .then((results) => {
876 resolve(results.reduce((acc, curr) => acc && curr, true));
877 })
878 // eslint-disable-next-line @typescript-eslint/no-explicit-any
879 .catch((reason: any) => reject(reason));
880 }, 10);
881 });
882 // eslint-disable-next-line @typescript-eslint/no-explicit-any
883 } catch (error: any) {
884 console.error(error);
885 resp.status(500);
886 }
887};
888
giod0026612025-05-08 13:00:36 +0000889async function start() {
890 await db.$connect();
891 const app = express();
gio76d8ae62025-05-19 15:21:54 +0000892 app.use(express.json()); // Global JSON parsing
893
894 // Public webhook route - no auth needed
895 app.post("/api/webhook/github/push", handleGithubPushWebhook);
896
897 // Authenticated project routes
898 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +0400899 projectRouter.use(auth);
900 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +0000901 projectRouter.post("/:projectId/saved", handleSave);
902 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
903 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
904 projectRouter.post("/:projectId/deploy", handleDeploy);
905 projectRouter.get("/:projectId/status", handleStatus);
gioa71316d2025-05-24 09:41:36 +0400906 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +0000907 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
908 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
909 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +0000910 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +0000911 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +0000912 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +0000913 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
914 projectRouter.get("/", handleProjectAll);
915 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +0000916
gio76d8ae62025-05-19 15:21:54 +0000917 app.use("/api/project", projectRouter); // Mount the authenticated router
918
giod0026612025-05-08 13:00:36 +0000919 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +0000920
gio76d8ae62025-05-19 15:21:54 +0000921 const internalApi = express();
922 internalApi.use(express.json());
923 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gio09fcab52025-05-12 14:05:07 +0000924
giod0026612025-05-08 13:00:36 +0000925 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +0000926 console.log("Web server started on port", env.DODO_PORT_WEB);
927 });
928
gio76d8ae62025-05-19 15:21:54 +0000929 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +0000930 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +0000931 });
932}
933
934start();