Canvas: Handle repo diff

Refactor github and appmanager clients.
Remove dev mode ports/ingress definitions.

Change-Id: I0ca15cec897d5a8cfa1c89b8ec9c09c408686c64
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
new file mode 100644
index 0000000..2f62ddd
--- /dev/null
+++ b/apps/canvas/back/src/app_manager.ts
@@ -0,0 +1,61 @@
+import axios from "axios";
+import { z } from "zod";
+
+export const DeployResponseSchema = z.object({
+	id: z.string(),
+	deployKey: z.string(),
+});
+
+export type DeployResponse = z.infer<typeof DeployResponseSchema>;
+
+export class AppManager {
+	private baseUrl: string;
+
+	constructor(baseUrl: string = "http://appmanager.hgrz-appmanager.svc.cluster.local") {
+		this.baseUrl = baseUrl;
+	}
+
+	async deploy(config: unknown): Promise<DeployResponse> {
+		const response = await axios.request({
+			url: `${this.baseUrl}/api/dodo-app`,
+			method: "post",
+			data: { config },
+		});
+		if (response.status !== 200) {
+			throw new Error(`Failed to deploy application: ${response.statusText}`);
+		}
+		const result = DeployResponseSchema.safeParse(response.data);
+		if (!result.success) {
+			throw new Error(`Invalid deploy response format: ${result.error.message}`);
+		}
+		return result.data;
+	}
+
+	async update(instanceId: string, config: unknown): Promise<boolean> {
+		const response = await axios.request({
+			url: `${this.baseUrl}/api/dodo-app/${instanceId}`,
+			method: "put",
+			data: { config },
+		});
+		return response.status === 200;
+	}
+
+	async getStatus(instanceId: string): Promise<unknown> {
+		const response = await axios.request({
+			url: `${this.baseUrl}/api/instance/${instanceId}/status`,
+			method: "get",
+		});
+		if (response.status !== 200) {
+			throw new Error(`Failed to get application status: ${response.statusText}`);
+		}
+		return response.data;
+	}
+
+	async removeInstance(instanceId: string): Promise<boolean> {
+		const response = await axios.request({
+			url: `${this.baseUrl}/api/instance/${instanceId}/remove`,
+			method: "post",
+		});
+		return response.status === 200;
+	}
+}
diff --git a/apps/canvas/back/src/github.ts b/apps/canvas/back/src/github.ts
index 850185d..60d2191 100644
--- a/apps/canvas/back/src/github.ts
+++ b/apps/canvas/back/src/github.ts
@@ -9,6 +9,13 @@
 	ssh_url: z.string(),
 });
 
+const DeployKeysSchema = z.array(
+	z.object({
+		id: z.number(),
+		key: z.string(),
+	}),
+);
+
 export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
 
 export class GithubClient {
@@ -35,7 +42,6 @@
 	async addDeployKey(repoPath: string, key: string) {
 		const sshUrl = repoPath;
 		const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
-
 		await axios.post(
 			`https://api.github.com/repos/${repoOwnerAndName}/keys`,
 			{
@@ -48,4 +54,24 @@
 			},
 		);
 	}
+
+	async removeDeployKey(repoPath: string, key: string) {
+		const sshUrl = repoPath;
+		const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
+		const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
+			headers: this.getHeaders(),
+		});
+		const result = DeployKeysSchema.safeParse(response.data);
+		if (!result.success) {
+			throw new Error("Failed to parse deploy keys response");
+		}
+		const deployKeys = result.data.filter((k) => k.key === key);
+		await Promise.all(
+			deployKeys.map((deployKey) =>
+				axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
+					headers: this.getHeaders(),
+				}),
+			),
+		);
+	}
 }
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 295bdf3..7c0fffe 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -3,11 +3,12 @@
 import { env } from "node:process";
 import axios from "axios";
 import { GithubClient } from "./github";
+import { AppManager } from "./app_manager";
 import { z } from "zod";
 
 const db = new PrismaClient();
+const appManager = new AppManager();
 
-// Map to store worker addresses by project ID
 const workers = new Map<number, string[]>();
 const logs = new Map<number, Map<string, string>>();
 
@@ -152,11 +153,7 @@
 		if (p.instanceId === null) {
 			ok = true;
 		} else {
-			const r = await axios.request({
-				url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/instance/${p.instanceId}/remove`,
-				method: "post",
-			});
-			ok = r.status === 200;
+			ok = await appManager.removeInstance(p.instanceId);
 		}
 		if (ok) {
 			await db.project.delete({
@@ -174,6 +171,54 @@
 	}
 };
 
+function extractGithubRepos(serializedState: Buffer | Uint8Array | null): string[] {
+	if (!serializedState) {
+		return [];
+	}
+	try {
+		const stateObj = JSON.parse(serializedState.toString());
+		const githubNodes = stateObj.nodes.filter(
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			(n: any) => n.type === "github" && n.data?.repository?.id,
+		);
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		return githubNodes.map((n: any) => n.data.repository.sshURL);
+	} catch (error) {
+		console.error("Failed to parse state or extract GitHub repos:", error);
+		return [];
+	}
+}
+
+type RepoDiff = {
+	toAdd?: string[];
+	toDelete?: string[];
+};
+
+function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
+	const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
+	const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
+	return { toAdd, toDelete };
+}
+
+async function manageGithubKeys(github: GithubClient, deployKey: string, diff: RepoDiff): Promise<void> {
+	for (const repoUrl of diff.toDelete ?? []) {
+		try {
+			await github.removeDeployKey(repoUrl, deployKey);
+			console.log(`Removed deploy key from repository ${repoUrl}`);
+		} catch (error) {
+			console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
+		}
+	}
+	for (const repoUrl of diff.toAdd ?? []) {
+		try {
+			await github.addDeployKey(repoUrl, deployKey);
+			console.log(`Added deploy key to repository ${repoUrl}`);
+		} catch (error) {
+			console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
+		}
+	}
+}
+
 const handleDeploy: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
@@ -187,6 +232,7 @@
 				instanceId: true,
 				githubToken: true,
 				deployKey: true,
+				state: true,
 			},
 		});
 		if (p === null) {
@@ -201,16 +247,11 @@
 				draft: state,
 			},
 		});
-		let r: { status: number; data: { id: string; deployKey: string } };
-		if (p.instanceId == null) {
-			r = await axios.request({
-				url: "http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app",
-				method: "post",
-				data: {
-					config: req.body.config,
-				},
-			});
-			if (r.status === 200) {
+		let diff: RepoDiff | null = null;
+		let deployKey: string | null = null;
+		try {
+			if (p.instanceId == null) {
+				const deployResponse = await appManager.deploy(req.body.config);
 				await db.project.update({
 					where: {
 						id: projectId,
@@ -218,50 +259,41 @@
 					data: {
 						state,
 						draft: null,
-						instanceId: r.data.id,
-						deployKey: r.data.deployKey,
+						instanceId: deployResponse.id,
+						deployKey: deployResponse.deployKey,
 					},
 				});
-
-				if (p.githubToken && r.data.deployKey) {
-					const stateObj = JSON.parse(JSON.parse(state.toString()));
-					const githubNodes = stateObj.nodes.filter(
-						// eslint-disable-next-line @typescript-eslint/no-explicit-any
-						(n: any) => n.type === "github" && n.data?.repository?.id,
-					);
-
-					const github = new GithubClient(p.githubToken);
-					for (const node of githubNodes) {
-						try {
-							await github.addDeployKey(node.data.repository.sshURL, r.data.deployKey);
-						} catch (error) {
-							console.error(
-								`Failed to add deploy key to repository ${node.data.repository.sshURL}:`,
-								error,
-							);
-						}
-					}
+				diff = { toAdd: extractGithubRepos(state) };
+				deployKey = deployResponse.deployKey;
+			} else {
+				const success = await appManager.update(p.instanceId, req.body.config);
+				if (success) {
+					diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
+					deployKey = p.deployKey;
+					await db.project.update({
+						where: {
+							id: projectId,
+						},
+						data: {
+							state,
+							draft: null,
+						},
+					});
+				} else {
+					resp.status(500);
+					resp.write(JSON.stringify({ error: "Failed to update deployment" }));
+					return;
 				}
 			}
-		} else {
-			r = await axios.request({
-				url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app/${p.instanceId}`,
-				method: "put",
-				data: {
-					config: req.body.config,
-				},
-			});
-			if (r.status === 200) {
-				await db.project.update({
-					where: {
-						id: projectId,
-					},
-					data: {
-						state,
-						draft: null,
-					},
-				});
+			if (diff && p.githubToken && deployKey) {
+				const github = new GithubClient(p.githubToken);
+				await manageGithubKeys(github, deployKey, diff);
 			}
+			resp.status(200);
+		} catch (error) {
+			console.error("Deployment error:", error);
+			resp.status(500);
+			resp.write(JSON.stringify({ error: "Deployment failed" }));
 		}
 	} catch (e) {
 		console.log(e);
@@ -291,13 +323,13 @@
 			resp.status(404);
 			return;
 		}
-		const r = await axios.request({
-			url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/instance/${p.instanceId}/status`,
-			method: "get",
-		});
-		resp.status(r.status);
-		if (r.status === 200) {
-			resp.write(JSON.stringify(r.data));
+		try {
+			const status = await appManager.getStatus(p.instanceId);
+			resp.status(200);
+			resp.write(JSON.stringify(status));
+		} catch (error) {
+			console.error("Error getting status:", error);
+			resp.status(500);
 		}
 	} catch (e) {
 		console.log(e);
@@ -319,16 +351,13 @@
 				githubToken: true,
 			},
 		});
-
 		if (!project?.githubToken) {
 			resp.status(400);
 			resp.write(JSON.stringify({ error: "GitHub token not configured" }));
 			return;
 		}
-
 		const github = new GithubClient(project.githubToken);
 		const repositories = await github.getRepositories();
-
 		resp.status(200);
 		resp.header("Content-Type", "application/json");
 		resp.write(JSON.stringify(repositories));
@@ -345,7 +374,6 @@
 	try {
 		const projectId = Number(req.params["projectId"]);
 		const { githubToken } = req.body;
-
 		await db.project.update({
 			where: {
 				id: projectId,
@@ -353,7 +381,6 @@
 			},
 			data: { githubToken },
 		});
-
 		resp.status(200);
 	} catch (e) {
 		console.log(e);
@@ -376,16 +403,13 @@
 				githubToken: true,
 			},
 		});
-
 		if (!project) {
 			resp.status(404);
 			resp.write(JSON.stringify({ error: "Project not found" }));
 			return;
 		}
-
 		const projectLogs = logs.get(projectId) || new Map();
 		const services = Array.from(projectLogs.keys());
-
 		resp.status(200);
 		resp.write(
 			JSON.stringify({
@@ -406,6 +430,10 @@
 					},
 				],
 				services,
+				user: {
+					id: resp.locals.userId,
+					username: resp.locals.username,
+				},
 			}),
 		);
 	} catch (error) {
@@ -432,21 +460,18 @@
 			resp.write(JSON.stringify({ error: "Project not found" }));
 			return;
 		}
-
 		const projectLogs = logs.get(projectId);
 		if (!projectLogs) {
 			resp.status(404);
 			resp.write(JSON.stringify({ error: "No logs found for this project" }));
 			return;
 		}
-
 		const serviceLog = projectLogs.get(service);
 		if (!serviceLog) {
 			resp.status(404);
 			resp.write(JSON.stringify({ error: "No logs found for this service" }));
 			return;
 		}
-
 		resp.status(200);
 		resp.write(JSON.stringify({ logs: serviceLog }));
 	} catch (e) {
@@ -467,7 +492,6 @@
 const handleRegisterWorker: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
-
 		const result = WorkerSchema.safeParse(req.body);
 		if (!result.success) {
 			resp.status(400);
@@ -479,17 +503,11 @@
 			);
 			return;
 		}
-
 		const { service, address, logs: log } = result.data;
-
-		// Get existing workers or initialize empty array
 		const projectWorkers = workers.get(projectId) || [];
-
-		// Add new worker if not already present
 		if (!projectWorkers.includes(address)) {
 			projectWorkers.push(address);
 		}
-
 		workers.set(projectId, projectWorkers);
 		if (log) {
 			const svcLogs: Map<string, string> = logs.get(projectId) || new Map();
@@ -526,13 +544,11 @@
 			resp.write(JSON.stringify({ error: "Project not found" }));
 			return;
 		}
-
 		if (projectWorkers.length === 0) {
 			resp.status(404);
 			resp.write(JSON.stringify({ error: "No workers registered for this project" }));
 			return;
 		}
-
 		await Promise.all(
 			projectWorkers.map(async (workerAddress) => {
 				try {
@@ -544,7 +560,6 @@
 				}
 			}),
 		);
-
 		resp.status(200);
 		resp.write(JSON.stringify({ success: true }));
 	} catch (e) {
@@ -558,13 +573,15 @@
 
 const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
 	const userId = req.get("x-forwarded-userid");
-	if (userId === undefined) {
+	const username = req.get("x-forwarded-user");
+	if (userId == null || username == null) {
 		resp.status(401);
 		resp.write("Unauthorized");
 		resp.end();
 		return;
 	}
 	resp.locals.userId = userId;
+	resp.locals.username = username;
 	next();
 };
 
@@ -592,7 +609,6 @@
 	api.use(express.json());
 	api.post("/api/project/:projectId/workers", handleRegisterWorker);
 
-	// Start both servers
 	app.listen(env.DODO_PORT_WEB, () => {
 		console.log("Web server started on port", env.DODO_PORT_WEB);
 	});
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 60d9966..63f6642 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -59,6 +59,7 @@
 	preBuildCommands?: { bin: string }[];
 	dev?: {
 		enabled: boolean;
+		username?: string;
 		ssh?: Domain;
 		codeServer?: Domain;
 	};
@@ -99,8 +100,12 @@
 			return null;
 		}
 		const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
-		const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
-		const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
+		const ingressNodes = nodes
+			.filter((n) => n.type === "gateway-https")
+			.filter((n) => n.data.https !== undefined && !n.data.readonly);
+		const tcpNodes = nodes
+			.filter((n) => n.type === "gateway-tcp")
+			.filter((n) => n.data.exposed !== undefined && !n.data.readonly);
 		const findExpose = (n: AppNode): PortDomain[] => {
 			return n.data.ports
 				.map((p) => [n.id, p.id, p.name])
@@ -134,11 +139,13 @@
 							branch: n.data.repository.branch,
 							rootDir: n.data.repository.rootDir,
 						},
-						ports: (n.data.ports || []).map((p) => ({
-							name: p.name,
-							value: p.value,
-							protocol: "TCP", // TODO(gio)
-						})),
+						ports: (n.data.ports || [])
+							.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
+							.map((p) => ({
+								name: p.name,
+								value: p.value,
+								protocol: "TCP", // TODO(gio)
+							})),
 						env: (n.data.envVars || [])
 							.filter((e) => "name" in e)
 							.map((e) => ({
@@ -172,17 +179,18 @@
 							: [],
 						dev: {
 							enabled: n.data.dev ? n.data.dev.enabled : false,
+							username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
 							codeServer:
 								n.data.dev?.enabled && n.data.dev.expose != null
 									? {
-											network: n.data.dev.expose.network,
+											network: networkMap.get(n.data.dev.expose.network)!,
 											subdomain: n.data.dev.expose.subdomain,
 										}
 									: undefined,
 							ssh:
 								n.data.dev?.enabled && n.data.dev.expose != null
 									? {
-											network: n.data.dev.expose.network,
+											network: networkMap.get(n.data.dev.expose.network)!,
 											subdomain: n.data.dev.expose.subdomain,
 										}
 									: undefined,
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 6e04e4c..7060327 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -361,6 +361,10 @@
 		github: z.boolean(),
 	}),
 	services: z.array(z.string()),
+	user: z.object({
+		id: z.string(),
+		username: z.string(),
+	}),
 });
 
 export type Env = z.infer<typeof envSchema>;
@@ -373,6 +377,10 @@
 		github: false,
 	},
 	services: [],
+	user: {
+		id: "",
+		username: "",
+	},
 };
 
 export type Project = {