blob: fdba5a0206610220809d0cdf0c117f6f336f1d48 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import { PrismaClient } from "@prisma/client";
2import express from "express";
gioa71316d2025-05-24 09:41:36 +04003import fs from "node:fs";
giod0026612025-05-08 13:00:36 +00004import { env } from "node:process";
5import axios from "axios";
gioc31bf142025-06-16 07:48:20 +00006import { GithubClient } from "./github.js";
7import { AppManager } from "./app_manager.js";
gio7d813702025-05-08 18:29:52 +00008import { z } from "zod";
gio78a22882025-07-01 18:56:01 +00009import { ProjectMonitor, WorkerSchema, LogItem } from "./project_monitor.js";
gioa71316d2025-05-24 09:41:36 +040010import tmp from "tmp";
gioc31bf142025-06-16 07:48:20 +000011import { NodeJSAnalyzer } from "./lib/nodejs.js";
gioa71316d2025-05-24 09:41:36 +040012import shell from "shelljs";
gioc31bf142025-06-16 07:48:20 +000013import { RealFileSystem } from "./lib/fs.js";
gioa71316d2025-05-24 09:41:36 +040014import path from "node:path";
gio9b7421a2025-06-18 12:31:13 +000015import {
16 Env,
17 generateDodoConfig,
18 ConfigSchema,
19 AppNode,
20 ConfigWithInput,
21 configToGraph,
22 Network,
23 GithubRepository,
24} from "config";
gio78a22882025-07-01 18:56:01 +000025import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
gioa71316d2025-05-24 09:41:36 +040026
27async function generateKey(root: string): Promise<[string, string]> {
28 const privKeyPath = path.join(root, "key");
29 const pubKeyPath = path.join(root, "key.pub");
30 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
31 throw new Error("Failed to generate SSH key pair");
32 }
33 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
34 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
35 return [publicKey, privateKey];
36}
giod0026612025-05-08 13:00:36 +000037
38const db = new PrismaClient();
gio3ed59592025-05-14 16:51:09 +000039const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000040
gioa1efbad2025-05-21 07:16:45 +000041const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000042
giod0026612025-05-08 13:00:36 +000043const handleProjectCreate: express.Handler = async (req, resp) => {
44 try {
gioa71316d2025-05-24 09:41:36 +040045 const tmpDir = tmp.dirSync().name;
46 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000047 const { id } = await db.project.create({
48 data: {
gio09fcab52025-05-12 14:05:07 +000049 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000050 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040051 deployKey: privateKey,
52 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000053 },
54 });
55 resp.status(200);
56 resp.header("Content-Type", "application/json");
57 resp.write(
58 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000059 id: id.toString(),
giod0026612025-05-08 13:00:36 +000060 }),
61 );
62 } catch (e) {
63 console.log(e);
64 resp.status(500);
65 } finally {
66 resp.end();
67 }
68};
69
70const handleProjectAll: express.Handler = async (req, resp) => {
71 try {
72 const r = await db.project.findMany({
73 where: {
gio09fcab52025-05-12 14:05:07 +000074 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000075 },
76 });
77 resp.status(200);
78 resp.header("Content-Type", "application/json");
79 resp.write(
80 JSON.stringify(
81 r.map((p) => ({
82 id: p.id.toString(),
83 name: p.name,
84 })),
85 ),
86 );
87 } catch (e) {
88 console.log(e);
89 resp.status(500);
90 } finally {
91 resp.end();
92 }
93};
94
95const handleSave: express.Handler = async (req, resp) => {
96 try {
97 await db.project.update({
98 where: {
99 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000100 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000101 },
102 data: {
giobd37a2b2025-05-15 04:28:42 +0000103 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +0000104 },
105 });
106 resp.status(200);
107 } catch (e) {
108 console.log(e);
109 resp.status(500);
110 } finally {
111 resp.end();
112 }
113};
114
gio818da4e2025-05-12 14:45:35 +0000115function handleSavedGet(state: "deploy" | "draft"): express.Handler {
116 return async (req, resp) => {
117 try {
118 const r = await db.project.findUnique({
119 where: {
120 id: Number(req.params["projectId"]),
121 userId: resp.locals.userId,
122 },
123 select: {
124 state: true,
125 draft: true,
126 },
127 });
128 if (r == null) {
129 resp.status(404);
130 return;
131 }
giod0026612025-05-08 13:00:36 +0000132 resp.status(200);
133 resp.header("content-type", "application/json");
gioc31bf142025-06-16 07:48:20 +0000134 let currentState: Record<string, unknown> | null = null;
gio818da4e2025-05-12 14:45:35 +0000135 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000136 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000137 currentState = {
giod0026612025-05-08 13:00:36 +0000138 nodes: [],
139 edges: [],
140 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000141 };
giod0026612025-05-08 13:00:36 +0000142 } else {
gioc31bf142025-06-16 07:48:20 +0000143 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
giod0026612025-05-08 13:00:36 +0000144 }
145 } else {
gio818da4e2025-05-12 14:45:35 +0000146 if (r.draft == null) {
147 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000148 currentState = {
gio818da4e2025-05-12 14:45:35 +0000149 nodes: [],
150 edges: [],
151 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000152 };
gio818da4e2025-05-12 14:45:35 +0000153 } else {
gioc31bf142025-06-16 07:48:20 +0000154 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000155 }
156 } else {
gioc31bf142025-06-16 07:48:20 +0000157 currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000158 }
giod0026612025-05-08 13:00:36 +0000159 }
gioc31bf142025-06-16 07:48:20 +0000160 const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
161 if (currentState) {
162 const config = generateDodoConfig(
163 req.params["projectId"].toString(),
164 currentState.nodes as AppNode[],
165 env,
166 );
167 resp.send({
168 state: currentState,
169 config,
170 });
171 }
gio818da4e2025-05-12 14:45:35 +0000172 } catch (e) {
173 console.log(e);
174 resp.status(500);
175 } finally {
176 resp.end();
giod0026612025-05-08 13:00:36 +0000177 }
gio818da4e2025-05-12 14:45:35 +0000178 };
179}
giod0026612025-05-08 13:00:36 +0000180
gioa71316d2025-05-24 09:41:36 +0400181const projectDeleteReqSchema = z.object({
182 state: z.optional(z.nullable(z.string())),
183});
184
185const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000186 try {
187 const projectId = Number(req.params["projectId"]);
188 const p = await db.project.findUnique({
189 where: {
190 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000191 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000192 },
193 select: {
194 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400195 githubToken: true,
196 deployKeyPublic: true,
197 state: true,
198 draft: true,
giod0026612025-05-08 13:00:36 +0000199 },
200 });
201 if (p === null) {
202 resp.status(404);
203 return;
204 }
gioa71316d2025-05-24 09:41:36 +0400205 const parseResult = projectDeleteReqSchema.safeParse(req.body);
206 if (!parseResult.success) {
207 resp.status(400);
208 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
209 return;
gioe440db82025-05-13 12:21:44 +0000210 }
gioa71316d2025-05-24 09:41:36 +0400211 if (p.githubToken && p.deployKeyPublic) {
212 const allRepos = [
213 ...new Set([
214 ...extractGithubRepos(p.state),
215 ...extractGithubRepos(p.draft),
216 ...extractGithubRepos(parseResult.data.state),
217 ]),
218 ];
219 if (allRepos.length > 0) {
220 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
221 const github = new GithubClient(p.githubToken);
222 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
223 console.log(
224 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
225 );
226 }
giod0026612025-05-08 13:00:36 +0000227 }
gioa71316d2025-05-24 09:41:36 +0400228 if (p.instanceId !== null) {
229 if (!(await appManager.removeInstance(p.instanceId))) {
230 resp.status(500);
231 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
232 return;
233 }
234 }
235 await db.project.delete({
236 where: {
237 id: projectId,
238 },
239 });
giod0026612025-05-08 13:00:36 +0000240 resp.status(200);
241 } catch (e) {
242 console.log(e);
243 resp.status(500);
244 } finally {
245 resp.end();
246 }
247};
248
gioa71316d2025-05-24 09:41:36 +0400249function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000250 if (!serializedState) {
251 return [];
252 }
253 try {
giobd37a2b2025-05-15 04:28:42 +0000254 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000255 const githubNodes = stateObj.nodes.filter(
256 // eslint-disable-next-line @typescript-eslint/no-explicit-any
257 (n: any) => n.type === "github" && n.data?.repository?.id,
258 );
259 // eslint-disable-next-line @typescript-eslint/no-explicit-any
260 return githubNodes.map((n: any) => n.data.repository.sshURL);
261 } catch (error) {
262 console.error("Failed to parse state or extract GitHub repos:", error);
263 return [];
264 }
265}
266
267type RepoDiff = {
268 toAdd?: string[];
269 toDelete?: string[];
270};
271
272function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
273 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
274 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
275 return { toAdd, toDelete };
276}
277
gio76d8ae62025-05-19 15:21:54 +0000278async function manageGithubRepos(
279 github: GithubClient,
280 diff: RepoDiff,
281 deployKey: string,
282 publicAddr?: string,
283): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000284 for (const repoUrl of diff.toDelete ?? []) {
285 try {
286 await github.removeDeployKey(repoUrl, deployKey);
287 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000288 if (publicAddr) {
289 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
290 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
291 console.log(`Removed push webhook from repository ${repoUrl}`);
292 }
gio3ed59592025-05-14 16:51:09 +0000293 } catch (error) {
294 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
295 }
296 }
297 for (const repoUrl of diff.toAdd ?? []) {
298 try {
299 await github.addDeployKey(repoUrl, deployKey);
300 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000301 if (publicAddr) {
302 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
303 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
304 console.log(`Added push webhook to repository ${repoUrl}`);
305 }
gio3ed59592025-05-14 16:51:09 +0000306 } catch (error) {
307 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
308 }
309 }
310}
311
giod0026612025-05-08 13:00:36 +0000312const handleDeploy: express.Handler = async (req, resp) => {
313 try {
314 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000315 const p = await db.project.findUnique({
316 where: {
317 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000318 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000319 },
320 select: {
321 instanceId: true,
322 githubToken: true,
323 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400324 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000325 state: true,
gio69148322025-06-19 23:16:12 +0400326 geminiApiKey: true,
giod0026612025-05-08 13:00:36 +0000327 },
328 });
329 if (p === null) {
330 resp.status(404);
331 return;
332 }
gioc31bf142025-06-16 07:48:20 +0000333 const config = ConfigSchema.safeParse(req.body.config);
334 if (!config.success) {
335 resp.status(400);
336 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
337 return;
338 }
gio9b7421a2025-06-18 12:31:13 +0000339 let repos: GithubRepository[] = [];
340 if (p.githubToken) {
341 const github = new GithubClient(p.githubToken);
342 repos = await github.getRepositories();
343 }
gioc31bf142025-06-16 07:48:20 +0000344 const state = req.body.state
345 ? JSON.stringify(req.body.state)
346 : JSON.stringify(
347 configToGraph(
348 config.data,
349 getNetworks(resp.locals.username),
gio9b7421a2025-06-18 12:31:13 +0000350 repos,
gioc31bf142025-06-16 07:48:20 +0000351 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
352 ),
353 );
giod0026612025-05-08 13:00:36 +0000354 await db.project.update({
355 where: {
356 id: projectId,
357 },
358 data: {
359 draft: state,
360 },
361 });
gioa71316d2025-05-24 09:41:36 +0400362 let deployKey: string | null = p.deployKey;
363 let deployKeyPublic: string | null = p.deployKeyPublic;
364 if (deployKeyPublic == null) {
365 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
366 await db.project.update({
367 where: { id: projectId },
368 data: { deployKeyPublic, deployKey },
369 });
370 }
gio3ed59592025-05-14 16:51:09 +0000371 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000372 const cfg: ConfigWithInput = {
373 ...config.data,
374 input: {
375 appId: projectId.toString(),
376 managerAddr: env.INTERNAL_API_ADDR!,
377 key: {
378 public: deployKeyPublic!,
379 private: deployKey!,
380 },
gio69148322025-06-19 23:16:12 +0400381 geminiApiKey: p.geminiApiKey ?? undefined,
gioc31bf142025-06-16 07:48:20 +0000382 },
gioa71316d2025-05-24 09:41:36 +0400383 };
gio3ed59592025-05-14 16:51:09 +0000384 try {
385 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000386 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000387 await db.project.update({
388 where: {
389 id: projectId,
390 },
391 data: {
392 state,
393 draft: null,
gio3ed59592025-05-14 16:51:09 +0000394 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000395 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000396 },
397 });
gio3ed59592025-05-14 16:51:09 +0000398 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000399 } else {
gioc31bf142025-06-16 07:48:20 +0000400 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000401 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000402 await db.project.update({
403 where: {
404 id: projectId,
405 },
406 data: {
407 state,
408 draft: null,
409 access: JSON.stringify(deployResponse.access),
410 },
411 });
giod0026612025-05-08 13:00:36 +0000412 }
gio3ed59592025-05-14 16:51:09 +0000413 if (diff && p.githubToken && deployKey) {
414 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400415 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000416 }
gio3ed59592025-05-14 16:51:09 +0000417 resp.status(200);
418 } catch (error) {
419 console.error("Deployment error:", error);
420 resp.status(500);
421 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000422 }
423 } catch (e) {
424 console.log(e);
425 resp.status(500);
426 } finally {
427 resp.end();
428 }
429};
430
431const handleStatus: express.Handler = async (req, resp) => {
432 try {
433 const projectId = Number(req.params["projectId"]);
434 const p = await db.project.findUnique({
435 where: {
436 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000437 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000438 },
439 select: {
440 instanceId: true,
441 },
442 });
giod0026612025-05-08 13:00:36 +0000443 if (p === null) {
444 resp.status(404);
445 return;
446 }
447 if (p.instanceId == null) {
448 resp.status(404);
449 return;
450 }
gio3ed59592025-05-14 16:51:09 +0000451 try {
452 const status = await appManager.getStatus(p.instanceId);
453 resp.status(200);
454 resp.write(JSON.stringify(status));
455 } catch (error) {
456 console.error("Error getting status:", error);
457 resp.status(500);
giod0026612025-05-08 13:00:36 +0000458 }
459 } catch (e) {
460 console.log(e);
461 resp.status(500);
462 } finally {
463 resp.end();
464 }
465};
466
gioc31bf142025-06-16 07:48:20 +0000467const handleConfigGet: express.Handler = async (req, resp) => {
468 try {
469 const projectId = Number(req.params["projectId"]);
470 const project = await db.project.findUnique({
471 where: {
472 id: projectId,
473 },
474 select: {
475 state: true,
476 },
477 });
478
479 if (!project || !project.state) {
480 resp.status(404).send({ error: "No deployed configuration found." });
481 return;
482 }
483
484 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
485 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
486 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
487
488 if (!config) {
489 resp.status(500).send({ error: "Failed to generate configuration." });
490 return;
491 }
492 resp.status(200).json(config);
493 } catch (e) {
494 console.log(e);
495 resp.status(500).send({ error: "Internal server error" });
496 } finally {
497 console.log("config get done");
498 resp.end();
499 }
500};
501
giobd37a2b2025-05-15 04:28:42 +0000502const handleRemoveDeployment: express.Handler = async (req, resp) => {
503 try {
504 const projectId = Number(req.params["projectId"]);
505 const p = await db.project.findUnique({
506 where: {
507 id: projectId,
508 userId: resp.locals.userId,
509 },
510 select: {
511 instanceId: true,
512 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400513 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000514 state: true,
515 draft: true,
516 },
517 });
518 if (p === null) {
519 resp.status(404);
520 resp.write(JSON.stringify({ error: "Project not found" }));
521 return;
522 }
523 if (p.instanceId == null) {
524 resp.status(400);
525 resp.write(JSON.stringify({ error: "Project not deployed" }));
526 return;
527 }
528 const removed = await appManager.removeInstance(p.instanceId);
529 if (!removed) {
530 resp.status(500);
531 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
532 return;
533 }
gioa71316d2025-05-24 09:41:36 +0400534 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000535 try {
536 const github = new GithubClient(p.githubToken);
537 const repos = extractGithubRepos(p.state);
538 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400539 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000540 } catch (error) {
541 console.error("Error removing GitHub deploy keys:", error);
542 }
543 }
544 await db.project.update({
545 where: {
546 id: projectId,
547 },
548 data: {
549 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400550 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000551 access: null,
giobd37a2b2025-05-15 04:28:42 +0000552 state: null,
553 draft: p.draft ?? p.state,
554 },
555 });
556 resp.status(200);
557 resp.write(JSON.stringify({ success: true }));
558 } catch (e) {
559 console.error("Error removing deployment:", e);
560 resp.status(500);
561 resp.write(JSON.stringify({ error: "Internal server error" }));
562 } finally {
563 resp.end();
564 }
565};
566
giod0026612025-05-08 13:00:36 +0000567const handleGithubRepos: express.Handler = async (req, resp) => {
568 try {
569 const projectId = Number(req.params["projectId"]);
570 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000571 where: {
572 id: projectId,
573 userId: resp.locals.userId,
574 },
575 select: {
576 githubToken: true,
577 },
giod0026612025-05-08 13:00:36 +0000578 });
giod0026612025-05-08 13:00:36 +0000579 if (!project?.githubToken) {
580 resp.status(400);
581 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
582 return;
583 }
giod0026612025-05-08 13:00:36 +0000584 const github = new GithubClient(project.githubToken);
585 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000586 resp.status(200);
587 resp.header("Content-Type", "application/json");
588 resp.write(JSON.stringify(repositories));
589 } catch (e) {
590 console.log(e);
591 resp.status(500);
592 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
593 } finally {
594 resp.end();
595 }
596};
597
598const handleUpdateGithubToken: express.Handler = async (req, resp) => {
599 try {
giod0026612025-05-08 13:00:36 +0000600 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000601 where: {
gio69148322025-06-19 23:16:12 +0400602 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000603 userId: resp.locals.userId,
604 },
gio69148322025-06-19 23:16:12 +0400605 data: {
606 githubToken: req.body.githubToken,
607 },
608 });
609 resp.status(200);
610 } catch (e) {
611 console.log(e);
612 resp.status(500);
613 } finally {
614 resp.end();
615 }
616};
617
618const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
619 try {
620 await db.project.update({
621 where: {
622 id: Number(req.params["projectId"]),
623 userId: resp.locals.userId,
624 },
625 data: {
626 geminiApiKey: req.body.geminiApiKey,
627 },
giod0026612025-05-08 13:00:36 +0000628 });
giod0026612025-05-08 13:00:36 +0000629 resp.status(200);
630 } catch (e) {
631 console.log(e);
632 resp.status(500);
633 } finally {
634 resp.end();
635 }
636};
637
gioc31bf142025-06-16 07:48:20 +0000638const getNetworks = (username?: string | undefined): Network[] => {
639 return [
640 {
641 name: "Trial",
642 domain: "trial.dodoapp.xyz",
643 hasAuth: false,
644 },
645 // TODO(gio): Remove
646 ].concat(
647 username === "gio" || 1 == 1
648 ? [
649 {
650 name: "Public",
651 domain: "v1.dodo.cloud",
652 hasAuth: true,
653 },
654 {
655 name: "Private",
656 domain: "p.v1.dodo.cloud",
657 hasAuth: true,
658 },
659 ]
660 : [],
661 );
662};
663
664const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
665 const project = await db.project.findUnique({
666 where: {
667 id: projectId,
668 userId,
669 },
670 select: {
671 deployKeyPublic: true,
672 githubToken: true,
gio69148322025-06-19 23:16:12 +0400673 geminiApiKey: true,
gioc31bf142025-06-16 07:48:20 +0000674 access: true,
675 instanceId: true,
676 },
677 });
678 if (!project) {
679 throw new Error("Project not found");
680 }
681 const monitor = projectMonitors.get(projectId);
682 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
683 const services = serviceNames.map((name: string) => ({
684 name,
685 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
686 ([id, status]) => ({
687 ...status,
688 id,
689 }),
690 ),
691 }));
692 return {
gioc31bf142025-06-16 07:48:20 +0000693 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
694 instanceId: project.instanceId == null ? undefined : project.instanceId,
695 access: JSON.parse(project.access ?? "[]"),
696 integrations: {
697 github: !!project.githubToken,
gio69148322025-06-19 23:16:12 +0400698 gemini: !!project.geminiApiKey,
gioc31bf142025-06-16 07:48:20 +0000699 },
700 networks: getNetworks(username),
701 services,
702 user: {
703 id: userId,
704 username: username,
705 },
706 };
707};
708
giod0026612025-05-08 13:00:36 +0000709const handleEnv: express.Handler = async (req, resp) => {
710 const projectId = Number(req.params["projectId"]);
711 try {
gioc31bf142025-06-16 07:48:20 +0000712 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000713 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000714 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000715 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000716 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000717 resp.status(500);
718 resp.write(JSON.stringify({ error: "Internal server error" }));
719 } finally {
720 resp.end();
721 }
722};
723
gio3a921b82025-05-10 07:36:09 +0000724const handleServiceLogs: express.Handler = async (req, resp) => {
gio78a22882025-07-01 18:56:01 +0000725 const projectId = Number(req.params["projectId"]);
726 const service = req.params["service"];
727 const workerId = req.params["workerId"];
728
729 resp.setHeader("Content-Type", "text/event-stream");
730 resp.setHeader("Cache-Control", "no-cache");
731 resp.setHeader("Connection", "keep-alive");
732 resp.flushHeaders();
733
734 const timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
735 const sendLogs = (logs: LogItem[]) => {
736 if (logs.length == 0) {
737 return;
738 }
739 const logString = logs
740 .map((l) => {
741 const t = Instant.ofEpochMilli(l.timestampMilli);
742 const formattedTimestamp = t.atZone(ZoneId.UTC).format(timestampFormat);
743 return `\x1b[38;5;240m${formattedTimestamp}\x1b[0m ${l.contents}`;
744 })
745 .join("\n");
746 resp.write("event: message\n");
747 resp.write(`data: ${JSON.stringify({ logs: logString })}\n\n`);
748 };
749
gio3a921b82025-05-10 07:36:09 +0000750 try {
gio09fcab52025-05-12 14:05:07 +0000751 const project = await db.project.findUnique({
gio78a22882025-07-01 18:56:01 +0000752 where: { id: projectId, userId: resp.locals.userId },
gio09fcab52025-05-12 14:05:07 +0000753 });
gio78a22882025-07-01 18:56:01 +0000754
755 if (!project) {
756 resp.status(404).end();
gio09fcab52025-05-12 14:05:07 +0000757 return;
758 }
gio78a22882025-07-01 18:56:01 +0000759
gioa1efbad2025-05-21 07:16:45 +0000760 const monitor = projectMonitors.get(projectId);
gio78a22882025-07-01 18:56:01 +0000761 if (!monitor) {
762 resp.status(404).end();
gio3a921b82025-05-10 07:36:09 +0000763 return;
764 }
gio78a22882025-07-01 18:56:01 +0000765
766 let lastLogCount = 0;
767 const initialLogs = monitor.getWorkerLog(service, workerId) || [];
768 sendLogs(initialLogs);
769 lastLogCount = initialLogs.length;
770 resp.flushHeaders();
771
772 const intervalId = setInterval(() => {
773 const currentLogs = monitor.getWorkerLog(service, workerId) || [];
774 if (currentLogs.length > lastLogCount) {
775 const newLogs = currentLogs.slice(lastLogCount);
776 sendLogs(newLogs);
777 lastLogCount = currentLogs.length;
778 }
779 }, 500);
780
781 req.on("close", () => {
782 clearInterval(intervalId);
783 resp.end();
784 });
gio3a921b82025-05-10 07:36:09 +0000785 } catch (e) {
786 console.log(e);
gio78a22882025-07-01 18:56:01 +0000787 resp.status(500).end();
gio3a921b82025-05-10 07:36:09 +0000788 }
789};
790
gio7d813702025-05-08 18:29:52 +0000791const handleRegisterWorker: express.Handler = async (req, resp) => {
792 try {
793 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000794 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000795 if (!result.success) {
796 resp.status(400);
797 resp.write(
798 JSON.stringify({
799 error: "Invalid request data",
800 details: result.error.format(),
801 }),
802 );
803 return;
804 }
gioa1efbad2025-05-21 07:16:45 +0000805 let monitor = projectMonitors.get(projectId);
806 if (!monitor) {
807 monitor = new ProjectMonitor();
808 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000809 }
gioa1efbad2025-05-21 07:16:45 +0000810 monitor.registerWorker(result.data);
gio7d813702025-05-08 18:29:52 +0000811 resp.status(200);
812 resp.write(
813 JSON.stringify({
814 success: true,
gio78a22882025-07-01 18:56:01 +0000815 logItemsConsumed: result.data.logs?.length ?? 0,
gio7d813702025-05-08 18:29:52 +0000816 }),
817 );
818 } catch (e) {
819 console.log(e);
820 resp.status(500);
821 resp.write(JSON.stringify({ error: "Failed to register worker" }));
822 } finally {
823 resp.end();
824 }
825};
826
gio76d8ae62025-05-19 15:21:54 +0000827async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000828 const monitor = projectMonitors.get(projectId);
829 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000830 const workerCount = projectWorkers.length;
831 if (workerCount === 0) {
832 return true;
833 }
834 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000835 projectWorkers.map(async (workerAddress: string) => {
836 try {
837 const { data } = await axios.get(`http://${workerAddress}/reload`);
838 return data.every((s: { status: string }) => s.status === "ok");
839 } catch (error) {
840 console.error(`Failed to reload worker ${workerAddress}:`, error);
841 return false;
842 }
gio76d8ae62025-05-19 15:21:54 +0000843 }),
844 );
gioc31bf142025-06-16 07:48:20 +0000845 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000846}
847
gio7d813702025-05-08 18:29:52 +0000848const handleReload: express.Handler = async (req, resp) => {
849 try {
850 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000851 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000852 where: {
853 id: projectId,
854 userId: resp.locals.userId,
855 },
gio76d8ae62025-05-19 15:21:54 +0000856 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000857 });
gio76d8ae62025-05-19 15:21:54 +0000858 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000859 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000860 return;
861 }
gio76d8ae62025-05-19 15:21:54 +0000862 const success = await reloadProject(projectId);
863 if (success) {
864 resp.status(200);
865 } else {
866 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000867 }
gio7d813702025-05-08 18:29:52 +0000868 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000869 console.error(e);
gio7d813702025-05-08 18:29:52 +0000870 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000871 }
872};
873
gio918780d2025-05-22 08:24:41 +0000874const handleReloadWorker: express.Handler = async (req, resp) => {
875 const projectId = Number(req.params["projectId"]);
876 const serviceName = req.params["serviceName"];
877 const workerId = req.params["workerId"];
878
879 const projectMonitor = projectMonitors.get(projectId);
880 if (!projectMonitor) {
881 resp.status(404).send({ error: "Project monitor not found" });
882 return;
883 }
884
885 try {
886 await projectMonitor.reloadWorker(serviceName, workerId);
887 resp.status(200).send({ message: "Worker reload initiated" });
888 } catch (error) {
889 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
890 const errorMessage = error instanceof Error ? error.message : "Unknown error";
891 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
892 }
893};
894
gioa71316d2025-05-24 09:41:36 +0400895const analyzeRepoReqSchema = z.object({
896 address: z.string(),
897});
898
899const handleAnalyzeRepo: express.Handler = async (req, resp) => {
900 const projectId = Number(req.params["projectId"]);
901 const project = await db.project.findUnique({
902 where: {
903 id: projectId,
904 userId: resp.locals.userId,
905 },
906 select: {
907 githubToken: true,
908 deployKey: true,
909 deployKeyPublic: true,
910 },
911 });
912 if (!project) {
913 resp.status(404).send({ error: "Project not found" });
914 return;
915 }
916 if (!project.githubToken) {
917 resp.status(400).send({ error: "GitHub token not configured" });
918 return;
919 }
gio8e74dc02025-06-13 10:19:26 +0000920 let tmpDir: tmp.DirResult | null = null;
921 try {
922 let deployKey: string | null = project.deployKey;
923 let deployKeyPublic: string | null = project.deployKeyPublic;
924 if (!deployKeyPublic) {
925 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
926 await db.project.update({
927 where: { id: projectId },
928 data: {
929 deployKeyPublic: deployKeyPublic,
930 deployKey: deployKey,
931 },
932 });
933 }
934 const github = new GithubClient(project.githubToken);
935 const result = analyzeRepoReqSchema.safeParse(req.body);
936 if (!result.success) {
937 resp.status(400).send({ error: "Invalid request data" });
938 return;
939 }
940 const { address } = result.data;
941 tmpDir = tmp.dirSync({
942 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400943 });
gio8e74dc02025-06-13 10:19:26 +0000944 await github.addDeployKey(address, deployKeyPublic);
945 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
946 mode: 0o600,
947 });
948 shell.exec(
949 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
950 );
951 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
952 const analyzer = new NodeJSAnalyzer();
953 const info = await analyzer.analyze(fsc, "/");
954 resp.status(200).send([info]);
955 } catch (e) {
956 console.error(e);
957 resp.status(500).send({ error: "Failed to analyze repository" });
958 } finally {
959 if (tmpDir) {
960 tmpDir.removeCallback();
961 }
962 resp.end();
gioa71316d2025-05-24 09:41:36 +0400963 }
gioa71316d2025-05-24 09:41:36 +0400964};
965
gio09fcab52025-05-12 14:05:07 +0000966const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
gio69148322025-06-19 23:16:12 +0400967 // Hardcoded user for development
968 resp.locals.userId = "1";
969 resp.locals.username = "gio";
gio09fcab52025-05-12 14:05:07 +0000970 next();
971};
972
gio76d8ae62025-05-19 15:21:54 +0000973const handleGithubPushWebhook: express.Handler = async (req, resp) => {
974 try {
975 // TODO(gio): Implement GitHub signature verification for security
976 const webhookSchema = z.object({
977 repository: z.object({
978 ssh_url: z.string(),
979 }),
980 });
981
982 const result = webhookSchema.safeParse(req.body);
983 if (!result.success) {
984 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
985 resp.status(400).json({ error: "Invalid webhook payload" });
986 return;
987 }
988 const { ssh_url: addr } = result.data.repository;
989 const allProjects = await db.project.findMany({
990 select: {
991 id: true,
992 state: true,
993 },
994 where: {
995 instanceId: {
996 not: null,
997 },
998 },
999 });
1000 // TODO(gio): This should run in background
1001 new Promise<boolean>((resolve, reject) => {
1002 setTimeout(() => {
1003 const projectsToReloadIds: number[] = [];
1004 for (const project of allProjects) {
1005 if (project.state && project.state.length > 0) {
1006 const projectRepos = extractGithubRepos(project.state);
1007 if (projectRepos.includes(addr)) {
1008 projectsToReloadIds.push(project.id);
1009 }
1010 }
1011 }
1012 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
1013 .then((results) => {
1014 resolve(results.reduce((acc, curr) => acc && curr, true));
1015 })
1016 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1017 .catch((reason: any) => reject(reason));
1018 }, 10);
1019 });
1020 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1021 } catch (error: any) {
1022 console.error(error);
1023 resp.status(500);
1024 }
1025};
1026
gioc31bf142025-06-16 07:48:20 +00001027const handleValidateConfig: express.Handler = async (req, resp) => {
1028 try {
1029 const validationResult = ConfigSchema.safeParse(req.body);
1030 if (!validationResult.success) {
1031 resp.status(400);
1032 resp.header("Content-Type", "application/json");
1033 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
1034 } else {
1035 resp.status(200);
1036 resp.header("Content-Type", "application/json");
1037 resp.write(JSON.stringify({ success: true }));
1038 }
1039 } catch (e) {
1040 console.log(e);
1041 resp.status(500);
1042 } finally {
1043 resp.end();
1044 }
1045};
1046
giod0026612025-05-08 13:00:36 +00001047async function start() {
1048 await db.$connect();
1049 const app = express();
gioc31bf142025-06-16 07:48:20 +00001050 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001051 app.use(express.json()); // Global JSON parsing
1052
1053 // Public webhook route - no auth needed
1054 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1055
1056 // Authenticated project routes
1057 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001058 projectRouter.use(auth);
1059 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001060 projectRouter.post("/:projectId/saved", handleSave);
1061 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1062 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1063 projectRouter.post("/:projectId/deploy", handleDeploy);
1064 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001065 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001066 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001067 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1068 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
gio69148322025-06-19 23:16:12 +04001069 projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
gio76d8ae62025-05-19 15:21:54 +00001070 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001071 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio76d8ae62025-05-19 15:21:54 +00001072 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001073 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001074 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1075 projectRouter.get("/", handleProjectAll);
1076 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001077
gio76d8ae62025-05-19 15:21:54 +00001078 app.use("/api/project", projectRouter); // Mount the authenticated router
1079
giod0026612025-05-08 13:00:36 +00001080 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001081
gio76d8ae62025-05-19 15:21:54 +00001082 const internalApi = express();
1083 internalApi.use(express.json());
1084 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001085 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1086 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1087 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001088
giod0026612025-05-08 13:00:36 +00001089 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001090 console.log("Web server started on port", env.DODO_PORT_WEB);
1091 });
1092
gio76d8ae62025-05-19 15:21:54 +00001093 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001094 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001095 });
1096}
1097
1098start();