blob: 7c0fffeedfe146cec24900ad86d3b29b92475764 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import { PrismaClient } from "@prisma/client";
2import express from "express";
3import { env } from "node:process";
4import axios from "axios";
5import { GithubClient } from "./github";
gio3ed59592025-05-14 16:51:09 +00006import { AppManager } from "./app_manager";
gio7d813702025-05-08 18:29:52 +00007import { z } from "zod";
giod0026612025-05-08 13:00:36 +00008
9const db = new PrismaClient();
gio3ed59592025-05-14 16:51:09 +000010const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000011
gio7d813702025-05-08 18:29:52 +000012const workers = new Map<number, string[]>();
gio3a921b82025-05-10 07:36:09 +000013const logs = new Map<number, Map<string, string>>();
gio7d813702025-05-08 18:29:52 +000014
giod0026612025-05-08 13:00:36 +000015const handleProjectCreate: express.Handler = async (req, resp) => {
16 try {
17 const { id } = await db.project.create({
18 data: {
gio09fcab52025-05-12 14:05:07 +000019 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000020 name: req.body.name,
21 },
22 });
23 resp.status(200);
24 resp.header("Content-Type", "application/json");
25 resp.write(
26 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000027 id: id.toString(),
giod0026612025-05-08 13:00:36 +000028 }),
29 );
30 } catch (e) {
31 console.log(e);
32 resp.status(500);
33 } finally {
34 resp.end();
35 }
36};
37
38const handleProjectAll: express.Handler = async (req, resp) => {
39 try {
40 const r = await db.project.findMany({
41 where: {
gio09fcab52025-05-12 14:05:07 +000042 userId: resp.locals.userId,
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(
49 r.map((p) => ({
50 id: p.id.toString(),
51 name: p.name,
52 })),
53 ),
54 );
55 } catch (e) {
56 console.log(e);
57 resp.status(500);
58 } finally {
59 resp.end();
60 }
61};
62
63const handleSave: express.Handler = async (req, resp) => {
64 try {
65 await db.project.update({
66 where: {
67 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +000068 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000069 },
70 data: {
71 draft: Buffer.from(JSON.stringify(req.body)),
72 },
73 });
74 resp.status(200);
75 } catch (e) {
76 console.log(e);
77 resp.status(500);
78 } finally {
79 resp.end();
80 }
81};
82
gio818da4e2025-05-12 14:45:35 +000083function handleSavedGet(state: "deploy" | "draft"): express.Handler {
84 return async (req, resp) => {
85 try {
86 const r = await db.project.findUnique({
87 where: {
88 id: Number(req.params["projectId"]),
89 userId: resp.locals.userId,
90 },
91 select: {
92 state: true,
93 draft: true,
94 },
95 });
96 if (r == null) {
97 resp.status(404);
98 return;
99 }
giod0026612025-05-08 13:00:36 +0000100 resp.status(200);
101 resp.header("content-type", "application/json");
gio818da4e2025-05-12 14:45:35 +0000102 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000103 if (r.state == null) {
104 resp.send({
105 nodes: [],
106 edges: [],
107 viewport: { x: 0, y: 0, zoom: 1 },
108 });
109 } else {
110 resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
111 }
112 } else {
gio818da4e2025-05-12 14:45:35 +0000113 if (r.draft == null) {
114 if (r.state == null) {
115 resp.send({
116 nodes: [],
117 edges: [],
118 viewport: { x: 0, y: 0, zoom: 1 },
119 });
120 } else {
121 resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
122 }
123 } else {
124 resp.send(JSON.parse(Buffer.from(r.draft).toString("utf8")));
125 }
giod0026612025-05-08 13:00:36 +0000126 }
gio818da4e2025-05-12 14:45:35 +0000127 } catch (e) {
128 console.log(e);
129 resp.status(500);
130 } finally {
131 resp.end();
giod0026612025-05-08 13:00:36 +0000132 }
gio818da4e2025-05-12 14:45:35 +0000133 };
134}
giod0026612025-05-08 13:00:36 +0000135
136const handleDelete: express.Handler = async (req, resp) => {
137 try {
138 const projectId = Number(req.params["projectId"]);
139 const p = await db.project.findUnique({
140 where: {
141 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000142 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000143 },
144 select: {
145 instanceId: true,
146 },
147 });
148 if (p === null) {
149 resp.status(404);
150 return;
151 }
gioe440db82025-05-13 12:21:44 +0000152 let ok = false;
153 if (p.instanceId === null) {
154 ok = true;
155 } else {
gio3ed59592025-05-14 16:51:09 +0000156 ok = await appManager.removeInstance(p.instanceId);
gioe440db82025-05-13 12:21:44 +0000157 }
158 if (ok) {
giod0026612025-05-08 13:00:36 +0000159 await db.project.delete({
160 where: {
161 id: projectId,
162 },
163 });
164 }
165 resp.status(200);
166 } catch (e) {
167 console.log(e);
168 resp.status(500);
169 } finally {
170 resp.end();
171 }
172};
173
gio3ed59592025-05-14 16:51:09 +0000174function extractGithubRepos(serializedState: Buffer | Uint8Array | null): string[] {
175 if (!serializedState) {
176 return [];
177 }
178 try {
179 const stateObj = JSON.parse(serializedState.toString());
180 const githubNodes = stateObj.nodes.filter(
181 // eslint-disable-next-line @typescript-eslint/no-explicit-any
182 (n: any) => n.type === "github" && n.data?.repository?.id,
183 );
184 // eslint-disable-next-line @typescript-eslint/no-explicit-any
185 return githubNodes.map((n: any) => n.data.repository.sshURL);
186 } catch (error) {
187 console.error("Failed to parse state or extract GitHub repos:", error);
188 return [];
189 }
190}
191
192type RepoDiff = {
193 toAdd?: string[];
194 toDelete?: string[];
195};
196
197function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
198 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
199 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
200 return { toAdd, toDelete };
201}
202
203async function manageGithubKeys(github: GithubClient, deployKey: string, diff: RepoDiff): Promise<void> {
204 for (const repoUrl of diff.toDelete ?? []) {
205 try {
206 await github.removeDeployKey(repoUrl, deployKey);
207 console.log(`Removed deploy key from repository ${repoUrl}`);
208 } catch (error) {
209 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
210 }
211 }
212 for (const repoUrl of diff.toAdd ?? []) {
213 try {
214 await github.addDeployKey(repoUrl, deployKey);
215 console.log(`Added deploy key to repository ${repoUrl}`);
216 } catch (error) {
217 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
218 }
219 }
220}
221
giod0026612025-05-08 13:00:36 +0000222const handleDeploy: express.Handler = async (req, resp) => {
223 try {
224 const projectId = Number(req.params["projectId"]);
225 const state = Buffer.from(JSON.stringify(req.body.state));
226 const p = await db.project.findUnique({
227 where: {
228 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000229 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000230 },
231 select: {
232 instanceId: true,
233 githubToken: true,
234 deployKey: true,
gio3ed59592025-05-14 16:51:09 +0000235 state: true,
giod0026612025-05-08 13:00:36 +0000236 },
237 });
238 if (p === null) {
239 resp.status(404);
240 return;
241 }
242 await db.project.update({
243 where: {
244 id: projectId,
245 },
246 data: {
247 draft: state,
248 },
249 });
gio3ed59592025-05-14 16:51:09 +0000250 let diff: RepoDiff | null = null;
251 let deployKey: string | null = null;
252 try {
253 if (p.instanceId == null) {
254 const deployResponse = await appManager.deploy(req.body.config);
giod0026612025-05-08 13:00:36 +0000255 await db.project.update({
256 where: {
257 id: projectId,
258 },
259 data: {
260 state,
261 draft: null,
gio3ed59592025-05-14 16:51:09 +0000262 instanceId: deployResponse.id,
263 deployKey: deployResponse.deployKey,
giod0026612025-05-08 13:00:36 +0000264 },
265 });
gio3ed59592025-05-14 16:51:09 +0000266 diff = { toAdd: extractGithubRepos(state) };
267 deployKey = deployResponse.deployKey;
268 } else {
269 const success = await appManager.update(p.instanceId, req.body.config);
270 if (success) {
271 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
272 deployKey = p.deployKey;
273 await db.project.update({
274 where: {
275 id: projectId,
276 },
277 data: {
278 state,
279 draft: null,
280 },
281 });
282 } else {
283 resp.status(500);
284 resp.write(JSON.stringify({ error: "Failed to update deployment" }));
285 return;
giod0026612025-05-08 13:00:36 +0000286 }
287 }
gio3ed59592025-05-14 16:51:09 +0000288 if (diff && p.githubToken && deployKey) {
289 const github = new GithubClient(p.githubToken);
290 await manageGithubKeys(github, deployKey, diff);
giod0026612025-05-08 13:00:36 +0000291 }
gio3ed59592025-05-14 16:51:09 +0000292 resp.status(200);
293 } catch (error) {
294 console.error("Deployment error:", error);
295 resp.status(500);
296 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000297 }
298 } catch (e) {
299 console.log(e);
300 resp.status(500);
301 } finally {
302 resp.end();
303 }
304};
305
306const handleStatus: express.Handler = async (req, resp) => {
307 try {
308 const projectId = Number(req.params["projectId"]);
309 const p = await db.project.findUnique({
310 where: {
311 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000312 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000313 },
314 select: {
315 instanceId: true,
316 },
317 });
giod0026612025-05-08 13:00:36 +0000318 if (p === null) {
319 resp.status(404);
320 return;
321 }
322 if (p.instanceId == null) {
323 resp.status(404);
324 return;
325 }
gio3ed59592025-05-14 16:51:09 +0000326 try {
327 const status = await appManager.getStatus(p.instanceId);
328 resp.status(200);
329 resp.write(JSON.stringify(status));
330 } catch (error) {
331 console.error("Error getting status:", error);
332 resp.status(500);
giod0026612025-05-08 13:00:36 +0000333 }
334 } catch (e) {
335 console.log(e);
336 resp.status(500);
337 } finally {
338 resp.end();
339 }
340};
341
342const handleGithubRepos: express.Handler = async (req, resp) => {
343 try {
344 const projectId = Number(req.params["projectId"]);
345 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000346 where: {
347 id: projectId,
348 userId: resp.locals.userId,
349 },
350 select: {
351 githubToken: true,
352 },
giod0026612025-05-08 13:00:36 +0000353 });
giod0026612025-05-08 13:00:36 +0000354 if (!project?.githubToken) {
355 resp.status(400);
356 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
357 return;
358 }
giod0026612025-05-08 13:00:36 +0000359 const github = new GithubClient(project.githubToken);
360 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000361 resp.status(200);
362 resp.header("Content-Type", "application/json");
363 resp.write(JSON.stringify(repositories));
364 } catch (e) {
365 console.log(e);
366 resp.status(500);
367 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
368 } finally {
369 resp.end();
370 }
371};
372
373const handleUpdateGithubToken: express.Handler = async (req, resp) => {
374 try {
375 const projectId = Number(req.params["projectId"]);
376 const { githubToken } = req.body;
giod0026612025-05-08 13:00:36 +0000377 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000378 where: {
379 id: projectId,
380 userId: resp.locals.userId,
381 },
giod0026612025-05-08 13:00:36 +0000382 data: { githubToken },
383 });
giod0026612025-05-08 13:00:36 +0000384 resp.status(200);
385 } catch (e) {
386 console.log(e);
387 resp.status(500);
388 } finally {
389 resp.end();
390 }
391};
392
393const handleEnv: express.Handler = async (req, resp) => {
394 const projectId = Number(req.params["projectId"]);
395 try {
396 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000397 where: {
398 id: projectId,
399 userId: resp.locals.userId,
400 },
giod0026612025-05-08 13:00:36 +0000401 select: {
402 deployKey: true,
403 githubToken: true,
404 },
405 });
giod0026612025-05-08 13:00:36 +0000406 if (!project) {
407 resp.status(404);
408 resp.write(JSON.stringify({ error: "Project not found" }));
409 return;
410 }
gio3a921b82025-05-10 07:36:09 +0000411 const projectLogs = logs.get(projectId) || new Map();
412 const services = Array.from(projectLogs.keys());
giod0026612025-05-08 13:00:36 +0000413 resp.status(200);
414 resp.write(
415 JSON.stringify({
gio7d813702025-05-08 18:29:52 +0000416 // TODO(gio): get from env or command line flags
gio09fcab52025-05-12 14:05:07 +0000417 managerAddr: "http://10.42.0.211:8081",
giod0026612025-05-08 13:00:36 +0000418 deployKey: project.deployKey,
419 integrations: {
420 github: !!project.githubToken,
421 },
422 networks: [
423 {
424 name: "Public",
425 domain: "v1.dodo.cloud",
426 },
427 {
428 name: "Private",
429 domain: "p.v1.dodo.cloud",
430 },
431 ],
gio3a921b82025-05-10 07:36:09 +0000432 services,
gio3ed59592025-05-14 16:51:09 +0000433 user: {
434 id: resp.locals.userId,
435 username: resp.locals.username,
436 },
giod0026612025-05-08 13:00:36 +0000437 }),
438 );
439 } catch (error) {
440 console.error("Error checking integrations:", error);
441 resp.status(500);
442 resp.write(JSON.stringify({ error: "Internal server error" }));
443 } finally {
444 resp.end();
445 }
446};
447
gio3a921b82025-05-10 07:36:09 +0000448const handleServiceLogs: express.Handler = async (req, resp) => {
449 try {
450 const projectId = Number(req.params["projectId"]);
451 const service = req.params["service"];
gio09fcab52025-05-12 14:05:07 +0000452 const project = await db.project.findUnique({
453 where: {
454 id: projectId,
455 userId: resp.locals.userId,
456 },
457 });
458 if (project == null) {
459 resp.status(404);
460 resp.write(JSON.stringify({ error: "Project not found" }));
461 return;
462 }
gio3a921b82025-05-10 07:36:09 +0000463 const projectLogs = logs.get(projectId);
464 if (!projectLogs) {
465 resp.status(404);
466 resp.write(JSON.stringify({ error: "No logs found for this project" }));
467 return;
468 }
gio3a921b82025-05-10 07:36:09 +0000469 const serviceLog = projectLogs.get(service);
470 if (!serviceLog) {
471 resp.status(404);
472 resp.write(JSON.stringify({ error: "No logs found for this service" }));
473 return;
474 }
gio3a921b82025-05-10 07:36:09 +0000475 resp.status(200);
476 resp.write(JSON.stringify({ logs: serviceLog }));
477 } catch (e) {
478 console.log(e);
479 resp.status(500);
480 resp.write(JSON.stringify({ error: "Failed to get service logs" }));
481 } finally {
482 resp.end();
483 }
484};
485
gio7d813702025-05-08 18:29:52 +0000486const WorkerSchema = z.object({
gio3a921b82025-05-10 07:36:09 +0000487 service: z.string(),
gio7d813702025-05-08 18:29:52 +0000488 address: z.string().url(),
gio3a921b82025-05-10 07:36:09 +0000489 logs: z.optional(z.string()),
gio7d813702025-05-08 18:29:52 +0000490});
491
492const handleRegisterWorker: express.Handler = async (req, resp) => {
493 try {
494 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000495 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000496 if (!result.success) {
497 resp.status(400);
498 resp.write(
499 JSON.stringify({
500 error: "Invalid request data",
501 details: result.error.format(),
502 }),
503 );
504 return;
505 }
gio3a921b82025-05-10 07:36:09 +0000506 const { service, address, logs: log } = result.data;
gio7d813702025-05-08 18:29:52 +0000507 const projectWorkers = workers.get(projectId) || [];
gio7d813702025-05-08 18:29:52 +0000508 if (!projectWorkers.includes(address)) {
509 projectWorkers.push(address);
510 }
gio7d813702025-05-08 18:29:52 +0000511 workers.set(projectId, projectWorkers);
gio3a921b82025-05-10 07:36:09 +0000512 if (log) {
513 const svcLogs: Map<string, string> = logs.get(projectId) || new Map();
514 svcLogs.set(service, log);
515 logs.set(projectId, svcLogs);
516 }
gio7d813702025-05-08 18:29:52 +0000517 resp.status(200);
518 resp.write(
519 JSON.stringify({
520 success: true,
gio7d813702025-05-08 18:29:52 +0000521 }),
522 );
523 } catch (e) {
524 console.log(e);
525 resp.status(500);
526 resp.write(JSON.stringify({ error: "Failed to register worker" }));
527 } finally {
528 resp.end();
529 }
530};
531
532const handleReload: express.Handler = async (req, resp) => {
533 try {
534 const projectId = Number(req.params["projectId"]);
535 const projectWorkers = workers.get(projectId) || [];
gio09fcab52025-05-12 14:05:07 +0000536 const project = await db.project.findUnique({
537 where: {
538 id: projectId,
539 userId: resp.locals.userId,
540 },
541 });
542 if (project == null) {
543 resp.status(404);
544 resp.write(JSON.stringify({ error: "Project not found" }));
545 return;
546 }
gio7d813702025-05-08 18:29:52 +0000547 if (projectWorkers.length === 0) {
548 resp.status(404);
549 resp.write(JSON.stringify({ error: "No workers registered for this project" }));
550 return;
551 }
gio7d813702025-05-08 18:29:52 +0000552 await Promise.all(
553 projectWorkers.map(async (workerAddress) => {
554 try {
555 const updateEndpoint = `${workerAddress}/update`;
556 await axios.post(updateEndpoint);
gio0b4002c2025-05-11 15:48:51 +0000557 // eslint-disable-next-line @typescript-eslint/no-explicit-any
558 } catch (error: any) {
gio7d813702025-05-08 18:29:52 +0000559 console.log(`Failed to update worker ${workerAddress}: ${error.message || "Unknown error"}`);
560 }
561 }),
562 );
gio7d813702025-05-08 18:29:52 +0000563 resp.status(200);
564 resp.write(JSON.stringify({ success: true }));
565 } catch (e) {
566 console.log(e);
567 resp.status(500);
568 resp.write(JSON.stringify({ error: "Failed to reload workers" }));
569 } finally {
570 resp.end();
571 }
572};
573
gio09fcab52025-05-12 14:05:07 +0000574const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
575 const userId = req.get("x-forwarded-userid");
gio3ed59592025-05-14 16:51:09 +0000576 const username = req.get("x-forwarded-user");
577 if (userId == null || username == null) {
gio09fcab52025-05-12 14:05:07 +0000578 resp.status(401);
579 resp.write("Unauthorized");
580 resp.end();
581 return;
582 }
583 resp.locals.userId = userId;
gio3ed59592025-05-14 16:51:09 +0000584 resp.locals.username = username;
gio09fcab52025-05-12 14:05:07 +0000585 next();
586};
587
giod0026612025-05-08 13:00:36 +0000588async function start() {
589 await db.$connect();
590 const app = express();
591 app.use(express.json());
gio09fcab52025-05-12 14:05:07 +0000592 app.use(auth);
giod0026612025-05-08 13:00:36 +0000593 app.post("/api/project/:projectId/saved", handleSave);
gio818da4e2025-05-12 14:45:35 +0000594 app.get("/api/project/:projectId/saved/deploy", handleSavedGet("deploy"));
595 app.get("/api/project/:projectId/saved/draft", handleSavedGet("draft"));
giod0026612025-05-08 13:00:36 +0000596 app.post("/api/project/:projectId/deploy", handleDeploy);
597 app.get("/api/project/:projectId/status", handleStatus);
598 app.delete("/api/project/:projectId", handleDelete);
599 app.get("/api/project", handleProjectAll);
600 app.post("/api/project", handleProjectCreate);
601 app.get("/api/project/:projectId/repos/github", handleGithubRepos);
602 app.post("/api/project/:projectId/github-token", handleUpdateGithubToken);
603 app.get("/api/project/:projectId/env", handleEnv);
gio7d813702025-05-08 18:29:52 +0000604 app.post("/api/project/:projectId/reload", handleReload);
gio3a921b82025-05-10 07:36:09 +0000605 app.get("/api/project/:projectId/logs/:service", handleServiceLogs);
giod0026612025-05-08 13:00:36 +0000606 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +0000607
608 const api = express();
609 api.use(express.json());
610 api.post("/api/project/:projectId/workers", handleRegisterWorker);
611
giod0026612025-05-08 13:00:36 +0000612 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +0000613 console.log("Web server started on port", env.DODO_PORT_WEB);
614 });
615
616 api.listen(env.DODO_PORT_API, () => {
617 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +0000618 });
619}
620
621start();