Cavnas: Implement basic service discovery logic

Change-Id: I71b25076dba94d6491ad4db748b259870991c526
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 572fc78..513f1f0 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -309,6 +309,7 @@
 			EmptyValidator,
 			GitRepositoryValidator,
 			ServiceValidator,
+			ServiceAnalyzisValidator,
 			GatewayHTTPSValidator,
 			GatewayTCPValidator,
 		),
@@ -488,6 +489,56 @@
 	return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
 }
 
+function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
+	const apps = nodes.filter((n) => n.type === "app");
+	return apps
+		.filter((n) => n.data.info != null)
+		.flatMap((n) => {
+			return n.data
+				.info!.configVars.map((cv): Message | undefined => {
+					if (cv.semanticType === "PORT") {
+						if (
+							!(n.data.envVars || []).some(
+								(p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
+							)
+						) {
+							return {
+								id: `${n.id}-missing-port-${cv.name}`,
+								type: "WARNING",
+								nodeId: n.id,
+								message: `Service requires port env variable ${cv.name}`,
+							};
+						}
+					}
+					if (cv.category === "EnvironmentVariable") {
+						if (
+							!(n.data.envVars || []).some(
+								(p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
+							)
+						) {
+							if (cv.semanticType === "FILESYSTEM_PATH") {
+								return {
+									id: `${n.id}-missing-env-${cv.name}`,
+									type: "FATAL",
+									nodeId: n.id,
+									message: `Service requires env variable ${cv.name}, representing filesystem path`,
+								};
+							} else if (cv.semanticType === "POSTGRES_URL") {
+								return {
+									id: `${n.id}-missing-env-${cv.name}`,
+									type: "FATAL",
+									nodeId: n.id,
+									message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
+								};
+							}
+						}
+					}
+					return undefined;
+				})
+				.filter((m) => m !== undefined);
+		});
+}
+
 function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
 	const ing = nodes.filter((n) => n.type === "gateway-https");
 	const noNetwork: Message[] = ing
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b7413d9..5d8013d 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -1,6 +1,6 @@
 import { Category, defaultCategories } from "./categories";
 import { CreateValidators, Validator } from "./config";
-import { GitHubService, GitHubServiceImpl } from "./github";
+import { GitHubService, GitHubServiceImpl, GitHubRepository } from "./github";
 import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react";
 import {
 	addEdge,
@@ -16,6 +16,41 @@
 import { z } from "zod";
 import { create } from "zustand";
 
+export const serviceAnalyzisSchema = z.object({
+	name: z.string(),
+	location: z.string(),
+	configVars: z.array(
+		z.object({
+			name: z.string(),
+			category: z.enum(["CommandLineFlag", "EnvironmentVariable"]),
+			type: z.optional(z.enum(["String", "Number", "Boolean"])),
+			semanticType: z.optional(
+				z.enum([
+					"EXPANDED_ENV_VAR",
+					"PORT",
+					"FILESYSTEM_PATH",
+					"DATABASE_URL",
+					"SQLITE_PATH",
+					"POSTGRES_URL",
+					"POSTGRES_PASSWORD",
+					"POSTGRES_USER",
+					"POSTGRES_DB",
+					"POSTGRES_PORT",
+					"POSTGRES_HOST",
+					"POSTGRES_SSL",
+					"MONGO_URL",
+					"MONGO_PASSWORD",
+					"MONGO_USER",
+					"MONGO_DB",
+					"MONGO_PORT",
+					"MONGO_HOST",
+					"MONGO_SSL",
+				]),
+			),
+		}),
+	),
+});
+
 export type InitData = {
 	label: string;
 	envVars: BoundEnvVar[];
@@ -125,6 +160,7 @@
 				codeServerNodeId: string;
 				sshNodeId: string;
 		  };
+	info?: z.infer<typeof serviceAnalyzisSchema>;
 };
 
 export type ServiceNode = Node<ServiceData> & {
@@ -425,7 +461,8 @@
 
 export const envSchema = z.object({
 	managerAddr: z.optional(z.string().min(1)),
-	deployKey: z.optional(z.nullable(z.string().min(1))),
+	instanceId: z.optional(z.string().min(1)),
+	deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
 	networks: z
 		.array(
 			z.object({
@@ -451,7 +488,8 @@
 
 const defaultEnv: Env = {
 	managerAddr: undefined,
-	deployKey: undefined,
+	deployKeyPublic: undefined,
+	instanceId: undefined,
 	networks: [],
 	integrations: {
 		github: false,
@@ -499,6 +537,9 @@
 	viewport: Viewport;
 	setViewport: (viewport: Viewport) => void;
 	githubService: GitHubService | null;
+	githubRepositories: GitHubRepository[];
+	githubRepositoriesLoading: boolean;
+	githubRepositoriesError: string | null;
 	setHighlightCategory: (name: string, active: boolean) => void;
 	onNodesChange: OnNodesChange<AppNode>;
 	onEdgesChange: OnEdgesChange;
@@ -512,6 +553,7 @@
 	updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
 	replaceEdge: (c: Connection, id?: string) => void;
 	refreshEnv: () => Promise<void>;
+	fetchGithubRepositories: () => Promise<void>;
 };
 
 const projectIdSelector = (state: AppState) => state.projectId;
@@ -520,6 +562,9 @@
 const githubServiceSelector = (state: AppState) => state.githubService;
 const envSelector = (state: AppState) => state.env;
 const zoomSelector = (state: AppState) => state.zoom;
+const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
+const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
+const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
 
 export function useZoom(): ReactFlowViewport {
 	return useStateStore(zoomSelector);
@@ -561,6 +606,22 @@
 	return useStateStore(githubServiceSelector);
 }
 
+export function useGithubRepositories(): GitHubRepository[] {
+	return useStateStore(githubRepositoriesSelector);
+}
+
+export function useGithubRepositoriesLoading(): boolean {
+	return useStateStore(githubRepositoriesLoadingSelector);
+}
+
+export function useGithubRepositoriesError(): string | null {
+	return useStateStore(githubRepositoriesErrorSelector);
+}
+
+export function useFetchGithubRepositories(): () => Promise<void> {
+	return useStateStore((state) => state.fetchGithubRepositories);
+}
+
 export function useMode(): "edit" | "deploy" {
 	return useStateStore((state) => state.mode);
 }
@@ -851,6 +912,29 @@
 			}
 		}
 	}
+
+	const fetchGithubRepositories = async () => {
+		const { githubService, projectId } = get();
+		if (!githubService || !projectId) {
+			set({
+				githubRepositories: [],
+				githubRepositoriesError: "GitHub service or Project ID not available.",
+				githubRepositoriesLoading: false,
+			});
+			return;
+		}
+
+		set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
+		try {
+			const repos = await githubService.getRepositories();
+			set({ githubRepositories: repos, githubRepositoriesLoading: false });
+		} catch (error) {
+			console.error("Failed to fetch GitHub repositories in store:", error);
+			const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
+			set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
+		}
+	};
+
 	return {
 		projectId: undefined,
 		mode: "edit",
@@ -873,6 +957,9 @@
 			zoom: 1,
 		},
 		githubService: null,
+		githubRepositories: [],
+		githubRepositoriesLoading: false,
+		githubRepositoriesError: null,
 		setViewport: (viewport) => {
 			const { viewport: vp } = get();
 			if (
@@ -970,13 +1057,30 @@
 			} catch (error) {
 				console.error("Failed to fetch integrations:", error);
 			} finally {
-				if (JSON.stringify(get().env) !== JSON.stringify(env)) {
+				const oldEnv = get().env;
+				const oldGithubIntegrationStatus = oldEnv.integrations.github;
+				if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
 					set({ env });
 					injectNetworkNodes();
+					let ghService = null;
 					if (env.integrations.github) {
-						set({ githubService: new GitHubServiceImpl(projectId!) });
-					} else {
-						set({ githubService: null });
+						ghService = new GitHubServiceImpl(projectId!);
+					}
+					if (get().githubService !== ghService || (ghService && !get().githubService)) {
+						set({ githubService: ghService });
+					}
+					if (
+						ghService &&
+						(oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
+					) {
+						get().fetchGithubRepositories();
+					}
+					if (!env.integrations.github) {
+						set({
+							githubRepositories: [],
+							githubRepositoriesError: null,
+							githubRepositoriesLoading: false,
+						});
 					}
 				}
 			}
@@ -992,10 +1096,13 @@
 			stopRefreshEnvInterval();
 			set({
 				projectId,
+				githubRepositories: [],
+				githubRepositoriesLoading: false,
+				githubRepositoriesError: null,
 			});
 			if (projectId) {
 				await get().refreshEnv();
-				if (get().env.deployKey) {
+				if (get().env.instanceId) {
 					set({ mode: "deploy" });
 				} else {
 					set({ mode: "edit" });
@@ -1008,8 +1115,12 @@
 					edges: [],
 					env: defaultEnv,
 					githubService: null,
+					githubRepositories: [],
+					githubRepositoriesLoading: false,
+					githubRepositoriesError: null,
 				});
 			}
 		},
+		fetchGithubRepositories: fetchGithubRepositories,
 	};
 });