| 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[]; |
| preBuildCommands?: 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: ingressNodes.filter((i) => i.data.https!.serviceId === n.id).map((i: GatewayHttpsNode): Ingress => ({ |
| 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 }, |
| })), |
| expose: findExpose(n), |
| preBuildCommands: [n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))], |
| }; |
| }), |
| 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[] { |
| nodes = nodes.filter((n) => n.type !== "network"); |
| 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); |
| } |