Cavnas: Implement basic service discovery logic

Change-Id: I71b25076dba94d6491ad4db748b259870991c526
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index cfd8955..daeded2 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -1,11 +1,28 @@
 import { PrismaClient } from "@prisma/client";
 import express from "express";
+import fs from "node:fs";
 import { env } from "node:process";
 import axios from "axios";
 import { GithubClient } from "./github";
 import { AppManager } from "./app_manager";
 import { z } from "zod";
 import { ProjectMonitor, WorkerSchema } from "./project_monitor";
+import tmp from "tmp";
+import { NodeJSAnalyzer } from "./lib/nodejs";
+import shell from "shelljs";
+import { RealFileSystem } from "./lib/fs";
+import path from "node:path";
+
+async function generateKey(root: string): Promise<[string, string]> {
+	const privKeyPath = path.join(root, "key");
+	const pubKeyPath = path.join(root, "key.pub");
+	if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
+		throw new Error("Failed to generate SSH key pair");
+	}
+	const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
+	const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
+	return [publicKey, privateKey];
+}
 
 const db = new PrismaClient();
 const appManager = new AppManager();
@@ -14,10 +31,14 @@
 
 const handleProjectCreate: express.Handler = async (req, resp) => {
 	try {
+		const tmpDir = tmp.dirSync().name;
+		const [publicKey, privateKey] = await generateKey(tmpDir);
 		const { id } = await db.project.create({
 			data: {
 				userId: resp.locals.userId,
 				name: req.body.name,
+				deployKey: privateKey,
+				deployKeyPublic: publicKey,
 			},
 		});
 		resp.status(200);
@@ -133,7 +154,11 @@
 	};
 }
 
-const handleDelete: express.Handler = async (req, resp) => {
+const projectDeleteReqSchema = z.object({
+	state: z.optional(z.nullable(z.string())),
+});
+
+const handleProjectDelete: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
 		const p = await db.project.findUnique({
@@ -143,25 +168,51 @@
 			},
 			select: {
 				instanceId: true,
+				githubToken: true,
+				deployKeyPublic: true,
+				state: true,
+				draft: true,
 			},
 		});
 		if (p === null) {
 			resp.status(404);
 			return;
 		}
-		let ok = false;
-		if (p.instanceId === null) {
-			ok = true;
-		} else {
-			ok = await appManager.removeInstance(p.instanceId);
+		const parseResult = projectDeleteReqSchema.safeParse(req.body);
+		if (!parseResult.success) {
+			resp.status(400);
+			resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
+			return;
 		}
-		if (ok) {
-			await db.project.delete({
-				where: {
-					id: projectId,
-				},
-			});
+		if (p.githubToken && p.deployKeyPublic) {
+			const allRepos = [
+				...new Set([
+					...extractGithubRepos(p.state),
+					...extractGithubRepos(p.draft),
+					...extractGithubRepos(parseResult.data.state),
+				]),
+			];
+			if (allRepos.length > 0) {
+				const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
+				const github = new GithubClient(p.githubToken);
+				await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
+				console.log(
+					`Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
+				);
+			}
 		}
+		if (p.instanceId !== null) {
+			if (!(await appManager.removeInstance(p.instanceId))) {
+				resp.status(500);
+				resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
+				return;
+			}
+		}
+		await db.project.delete({
+			where: {
+				id: projectId,
+			},
+		});
 		resp.status(200);
 	} catch (e) {
 		console.log(e);
@@ -171,7 +222,7 @@
 	}
 };
 
-function extractGithubRepos(serializedState: string | null): string[] {
+function extractGithubRepos(serializedState: string | null | undefined): string[] {
 	if (!serializedState) {
 		return [];
 	}
@@ -248,6 +299,7 @@
 				instanceId: true,
 				githubToken: true,
 				deployKey: true,
+				deployKeyPublic: true,
 				state: true,
 			},
 		});
@@ -263,11 +315,24 @@
 				draft: state,
 			},
 		});
+		let deployKey: string | null = p.deployKey;
+		let deployKeyPublic: string | null = p.deployKeyPublic;
+		if (deployKeyPublic == null) {
+			[deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
+			await db.project.update({
+				where: { id: projectId },
+				data: { deployKeyPublic, deployKey },
+			});
+		}
 		let diff: RepoDiff | null = null;
-		let deployKey: string | null = null;
+		const config = req.body.config;
+		config.input.key = {
+			public: deployKeyPublic,
+			private: deployKey,
+		};
 		try {
 			if (p.instanceId == null) {
-				const deployResponse = await appManager.deploy(req.body.config);
+				const deployResponse = await appManager.deploy(config);
 				await db.project.update({
 					where: {
 						id: projectId,
@@ -276,16 +341,13 @@
 						state,
 						draft: null,
 						instanceId: deployResponse.id,
-						deployKey: deployResponse.deployKey,
 						access: JSON.stringify(deployResponse.access),
 					},
 				});
 				diff = { toAdd: extractGithubRepos(state) };
-				deployKey = deployResponse.deployKey;
 			} else {
-				const deployResponse = await appManager.update(p.instanceId, req.body.config);
+				const deployResponse = await appManager.update(p.instanceId, config);
 				diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
-				deployKey = p.deployKey;
 				await db.project.update({
 					where: {
 						id: projectId,
@@ -299,7 +361,7 @@
 			}
 			if (diff && p.githubToken && deployKey) {
 				const github = new GithubClient(p.githubToken);
-				await manageGithubRepos(github, diff, deployKey, env.PUBLIC_ADDR);
+				await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
 			}
 			resp.status(200);
 		} catch (error) {
@@ -362,7 +424,7 @@
 			select: {
 				instanceId: true,
 				githubToken: true,
-				deployKey: true,
+				deployKeyPublic: true,
 				state: true,
 				draft: true,
 			},
@@ -383,12 +445,12 @@
 			resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
 			return;
 		}
-		if (p.githubToken && p.deployKey && p.state) {
+		if (p.githubToken && p.deployKeyPublic && p.state) {
 			try {
 				const github = new GithubClient(p.githubToken);
 				const repos = extractGithubRepos(p.state);
 				const diff = { toDelete: repos, toAdd: [] };
-				await manageGithubRepos(github, diff, p.deployKey, env.PUBLIC_ADDR);
+				await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
 			} catch (error) {
 				console.error("Error removing GitHub deploy keys:", error);
 			}
@@ -399,7 +461,7 @@
 			},
 			data: {
 				instanceId: null,
-				deployKey: null,
+				deployKeyPublic: null,
 				access: null,
 				state: null,
 				draft: p.draft ?? p.state,
@@ -476,9 +538,10 @@
 				userId: resp.locals.userId,
 			},
 			select: {
-				deployKey: true,
+				deployKeyPublic: true,
 				githubToken: true,
 				access: true,
+				instanceId: true,
 			},
 		});
 		if (!project) {
@@ -502,7 +565,8 @@
 		resp.write(
 			JSON.stringify({
 				managerAddr: env.INTERNAL_API_ADDR,
-				deployKey: project.deployKey,
+				deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
+				instanceId: project.instanceId == null ? undefined : project.instanceId,
 				access: JSON.parse(project.access ?? "[]"),
 				integrations: {
 					github: !!project.githubToken,
@@ -683,6 +747,65 @@
 	}
 };
 
+const analyzeRepoReqSchema = z.object({
+	address: z.string(),
+});
+
+const handleAnalyzeRepo: express.Handler = async (req, resp) => {
+	const projectId = Number(req.params["projectId"]);
+	const project = await db.project.findUnique({
+		where: {
+			id: projectId,
+			userId: resp.locals.userId,
+		},
+		select: {
+			githubToken: true,
+			deployKey: true,
+			deployKeyPublic: true,
+		},
+	});
+	if (!project) {
+		resp.status(404).send({ error: "Project not found" });
+		return;
+	}
+	if (!project.githubToken) {
+		resp.status(400).send({ error: "GitHub token not configured" });
+		return;
+	}
+	let deployKey: string | null = project.deployKey;
+	let deployKeyPublic: string | null = project.deployKeyPublic;
+	if (!deployKeyPublic) {
+		[deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
+		await db.project.update({
+			where: { id: projectId },
+			data: {
+				deployKeyPublic: deployKeyPublic,
+				deployKey: deployKey,
+			},
+		});
+	}
+	const github = new GithubClient(project.githubToken);
+	const result = analyzeRepoReqSchema.safeParse(req.body);
+	if (!result.success) {
+		resp.status(400).send({ error: "Invalid request data" });
+		return;
+	}
+	const { address } = result.data;
+	const tmpDir = tmp.dirSync();
+	await github.addDeployKey(address, deployKeyPublic);
+	await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
+		mode: 0o600,
+	});
+	shell.exec(
+		`GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
+	);
+	const fsc = new RealFileSystem(`${tmpDir.name}/code`);
+	const analyzer = new NodeJSAnalyzer();
+	const info = await analyzer.analyze(fsc, "/");
+	console.log(info);
+	resp.status(200).send([info]);
+};
+
 const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
 	const userId = req.get("x-forwarded-userid");
 	const username = req.get("x-forwarded-user");
@@ -761,13 +884,14 @@
 
 	// Authenticated project routes
 	const projectRouter = express.Router();
-	projectRouter.use(auth); // Apply auth middleware to this router
+	projectRouter.use(auth);
+	projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
 	projectRouter.post("/:projectId/saved", handleSave);
 	projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
 	projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
 	projectRouter.post("/:projectId/deploy", handleDeploy);
 	projectRouter.get("/:projectId/status", handleStatus);
-	projectRouter.delete("/:projectId", handleDelete);
+	projectRouter.delete("/:projectId", handleProjectDelete);
 	projectRouter.get("/:projectId/repos/github", handleGithubRepos);
 	projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
 	projectRouter.get("/:projectId/env", handleEnv);