| 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?: { bin: 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 = { |
| input: { |
| appId: string; |
| managerAddr: string; |
| }; |
| service?: Service[]; |
| volume?: Volume[]; |
| postgresql?: PostgreSQL[]; |
| mongodb?: MongoDB[]; |
| }; |
| |
| export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null { |
| try { |
| if (appId == null || env.managerAddr == null) { |
| 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 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 { |
| input: { |
| appId: appId, |
| managerAddr: env.managerAddr, |
| }, |
| 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.repository!.sshURL, |
| 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: |
| i.data.auth?.enabled || false |
| ? { |
| enabled: true, |
| groups: i.data.auth!.groups, |
| noAuthPathPatterns: i.data.auth!.noAuthPathPatterns, |
| } |
| : { |
| enabled: false, |
| }, |
| }), |
| ), |
| expose: findExpose(n), |
| preBuildCommands: n.data.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[] { |
| if (nodes.some((n) => n.type !== "network")) { |
| 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.repository == null) |
| .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); |
| } |