Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/lib/config.ts b/apps/canvas/src/lib/config.ts
new file mode 100644
index 0000000..f2a0784
--- /dev/null
+++ b/apps/canvas/src/lib/config.ts
@@ -0,0 +1,390 @@
+import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
+
+export type AuthDisabled = {
+    enabled: false;
+};
+
+export type AuthEnabled = {
+    enabled: true;
+    groups: string[];
+    noAuthPathPatterns: string[];
+};
+
+export type Auth = AuthDisabled | AuthEnabled;
+
+export type Ingress = {
+    network: string;
+    subdomain: string;
+    port: { name: string; } | { value: string; };
+    auth: Auth;
+};
+
+export type Domain = {
+    network: string;
+    subdomain: string;
+};
+
+export type PortValue = {
+    name: string;
+} | {
+    value: number;
+};
+
+export type PortDomain = Domain & {
+    port: PortValue;
+}
+
+export type Service = {
+    type: ServiceType;
+    name: string;
+    source: {
+        repository: string;
+        branch: string;
+        rootDir: string;
+    };
+    ports?: {
+        name: string;
+        value: number;
+        protocol: "TCP" | "UDP";
+    }[];
+    env?: {
+        name: string;
+        alias?: string;
+    }[]
+    ingress?: Ingress;
+    expose?: PortDomain[];
+    volume?: string[];
+};
+
+export type Volume = {
+    name: string;
+    accessMode: VolumeType;
+    size: string;
+};
+
+export type PostgreSQL = {
+    name: string;
+    size: string;
+    expose?: Domain[];
+};
+
+export type MongoDB = {
+    name: string;
+    size: string;
+    expose?: Domain[];
+};
+
+export type Config = {
+    service?: Service[];
+    volume?: Volume[];
+    postgresql?: PostgreSQL[];
+    mongodb?: MongoDB[];
+};
+
+export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
+    try {
+        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 findExpose = (n: AppNode): PortDomain[] => {
+            return n.data.ports.map((p) => [n.id, p.id, p.name]).flatMap((sp) => {
+                return tcpNodes.flatMap((i) => (i.data.exposed || []).filter((t) => t.serviceId === sp[0] && t.portId === sp[1]).map(() => ({
+                    network: networkMap.get(i.data.network!)!,
+                    subdomain: i.data.subdomain!,
+                    port: { name: sp[2] },
+                })));
+            });
+        };
+        return {
+            service: nodes.filter((n) => n.type === "app").map((n): Service => {
+                return {
+                    type: n.data.type,
+                    name: n.data.label,
+                    source: {
+                        repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.address,
+                        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)
+                    })),
+                    env: (n.data.envVars || []).filter((e) => "name" in e).map((e) => ({
+                        name: e.name,
+                        alias: "alias" in e ? e.alias : undefined,
+                    })),
+                    ingress: ((i: GatewayHttpsNode | undefined) => {
+                        if (i === undefined) {
+                            return undefined;
+                        }
+                        return {
+                            network: networkMap.get(i.data.network!)!,
+                            subdomain: i.data.subdomain!,
+                            port: {
+                                name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
+                            },
+                            auth: { enabled: false },
+                        };
+                    })(ingressNodes.find((i) => i.data.https!.serviceId === n.id)),
+                    expose: findExpose(n),
+                };
+            }),
+            volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
+                name: n.data.label,
+                accessMode: n.data.type,
+                size: n.data.size,
+            })),
+            postgresql: nodes.filter((n) => n.type === "postgresql").map((n): PostgreSQL => ({
+                name: n.data.label,
+                size: "1Gi", // TODO(gio)
+                expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+            })),
+            mongodb: nodes.filter((n) => n.type === "mongodb").map((n): MongoDB => ({
+                name: n.data.label,
+                size: "1Gi", // TODO(gio)
+                expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+            })),
+        };
+    } catch (e) {
+        console.log(e);
+        return null;
+    }
+}
+
+export interface Validator {
+    (nodes: AppNode[]): Message[];
+}
+
+function CombineValidators(...v: Validator[]): Validator {
+    return (n) => v.flatMap((v) => v(n));
+}
+
+function MessageTypeToNumber(t: MessageType) {
+    switch (t) {
+        case "FATAL": return 0;
+        case "WARNING": return 1;
+        case "INFO": return 2;
+    }
+}
+
+function NodeTypeToNumber(t?: NodeType) {
+    switch (t) {
+        case "github": return 0;
+        case "app": return 1;
+        case "volume": return 2;
+        case "postgresql": return 3;
+        case "mongodb": return 4;
+        case "gateway-https": return 5;
+        case undefined: return 100;
+    }
+}
+
+function SortingValidator(v: Validator): Validator {
+    return (n) => {
+        const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]))
+        return v(n).sort((a, b) => {
+            const at = MessageTypeToNumber(a.type);
+            const bt = MessageTypeToNumber(b.type);
+            if (a.nodeId === undefined && b.nodeId === undefined) {
+                if (at !== bt) {
+                    return at - bt;
+                }
+                return a.id.localeCompare(b.id);
+            }
+            if (a.nodeId === undefined) {
+                return -1;
+            }
+            if (b.nodeId === undefined) {
+                return 1;
+            }
+            if (a.nodeId === b.nodeId) {
+                if (at !== bt) {
+                    return at - bt;
+                }
+                return a.id.localeCompare(b.id);
+            }
+            const ant = nt.get(a.id)!;
+            const bnt = nt.get(b.id)!;
+            if (ant !== bnt) {
+                return ant - bnt;
+            }
+            return a.id.localeCompare(b.id);
+        });
+    };
+}
+
+export function CreateValidators(): Validator {
+    return SortingValidator(
+        CombineValidators(
+            EmptyValidator, 
+            GitRepositoryValidator,
+            ServiceValidator,
+            GatewayHTTPSValidator,
+            GatewayTCPValidator,
+        )
+    );
+}
+
+function EmptyValidator(nodes: AppNode[]): Message[] {
+    if (nodes.length > 0) {
+        return [];
+    }
+    return [{   
+        id: "no-nodes",
+        type: "FATAL",
+        message: "Start by importing application source code",
+        onHighlight: (store) => store.setHighlightCategory("repository", true),
+        onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
+    }];
+}
+
+function GitRepositoryValidator(nodes: AppNode[]): Message[] {
+    const git = nodes.filter((n) => n.type === "github");
+    const noAddress: Message[] = git.filter((n) => n.data == null || n.data.address == null || n.data.address === "").map((n) => ({
+        id: `${n.id}-no-address`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Configure repository address",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    } satisfies Message));
+    const noApp = git.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id)).map((n) => ({
+        id: `${n.id}-no-app`,
+        type: "WARNING",
+        nodeId: n.id,
+        message: "Connect to service",
+        onHighlight: (store) => store.setHighlightCategory("Services", true),
+        onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
+} satisfies Message));
+    return noAddress.concat(noApp);
+}
+
+function ServiceValidator(nodes: AppNode[]): Message[] {
+    const apps = nodes.filter((n) => n.type === "app");
+    const noName = apps.filter((n) => n.data == null || n.data.label == null || n.data.label === "").map((n): Message => ({
+        id: `${n.id}-no-name`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Name the service",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+        onClick: (store) => {
+            store.updateNode(n.id, { selected: true });
+            store.updateNodeData<"app">(n.id, { 
+                activeField: "name" ,
+            });
+        },
+    }));
+    const noSource = apps.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "").map((n): Message => ({
+        id: `${n.id}-no-repo`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Connect to source repository",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    const noRuntime = apps.filter((n) => n.data == null || n.data.type == null).map((n): Message => ({
+        id: `${n.id}-no-runtime`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Choose runtime",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+        onClick: (store) => {
+            store.updateNode(n.id, { selected: true });
+            store.updateNodeData<"app">(n.id, { 
+                activeField: "type" ,
+            });
+        },
+    }));
+    const noPorts = apps.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0).map((n): Message => ({
+        id: `${n.id}-no-ports`,
+        type: "INFO",
+        nodeId: n.id,
+        message: "Expose ports",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    const noIngress = apps.flatMap((n): Message[] => {
+        if (n.data == null) {
+            return [];
+        }
+        return (n.data.ports || []).filter((p) => !nodes.filter((i) => i.type === "gateway-https").some((i) => {
+            if (i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id) {
+                return true;
+            }
+            return false;
+        })).map((p): Message => ({
+            id: `${n.id}-${p.id}-no-ingress`,
+            type: "WARNING",
+            nodeId: n.id,
+            message: `Connect to ingress: ${p.name} - ${p.value}`,
+            onHighlight: (store) => {
+                store.updateNode(n.id, { selected: true });
+                store.setHighlightCategory("gateways", true);
+            },
+            onLooseHighlight: (store) => {
+                store.updateNode(n.id, { selected: false });
+                store.setHighlightCategory("gateways", false);    
+            },
+        }));
+    });
+    const multipleIngress = apps.filter((n) => n.data != null && n.data.ports != null).flatMap((n) => n.data.ports.map((p): Message | undefined => {
+        const ing = nodes.filter((i) => i.type === "gateway-https").filter((i) => i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id);
+        if (ing.length < 2) {
+            return undefined;
+        }
+        return {
+            id: `${n.id}-${p.id}-multiple-ingress`,
+            type: "FATAL",
+            nodeId: n.id,
+            message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
+            onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+            onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),        
+        };
+    })).filter((m) => m !== undefined);
+    return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
+}
+
+function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
+    const ing = nodes.filter((n) => n.type === "gateway-https");
+    const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
+        id: `${n.id}-no-network`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Network and subdomain must be defined",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.https == null || n.data.https.serviceId == null || n.data.https.serviceId == "" || n.data.https.portId == null || n.data.https.portId == "").map((n) => ({
+        id: `${n.id}-not-connected`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Connect to a service port",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    return noNetwork.concat(notConnected);
+}
+
+function GatewayTCPValidator(nodes: AppNode[]): Message[] {
+    const ing = nodes.filter((n) => n.type === "gateway-tcp");
+    const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
+        id: `${n.id}-no-network`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Network and subdomain must be defined",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0).map((n) => ({
+        id: `${n.id}-not-connected`,
+        type: "FATAL",
+        nodeId: n.id,
+        message: "Connect to a service port",
+        onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+        onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+    }));
+    return noNetwork.concat(notConnected);
+}
\ No newline at end of file