| import { AppNode, Env, NodeType, ServiceNode } from "config"; |
| import { Message, MessageType } from "./state"; |
| |
| export interface Validator { |
| (nodes: AppNode[], env: Env): Message[]; |
| } |
| |
| function CombineValidators(...v: Validator[]): Validator { |
| return (n, env) => v.flatMap((v) => v(n, env)); |
| } |
| |
| 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 "gateway-tcp": |
| return 7; |
| case "network": |
| return 8; |
| case undefined: // For NANode |
| return 100; |
| } |
| } |
| |
| function SortingValidator(v: Validator): Validator { |
| return (nodes, env) => { |
| const nt = new Map(nodes.map((n) => [n.id, NodeTypeToNumber(n.type)])); |
| return v(nodes, env).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, |
| ServiceAnalyzisValidator, |
| GatewayHTTPSValidator, |
| GatewayTCPValidator, |
| AgentApiKeyValidator, |
| ), |
| ); |
| } |
| |
| 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?.repoNodeId === 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.type !== "sketch:latest") |
| .filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "") |
| .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 |
| .filter((n) => n.data.type !== "sketch:latest") |
| .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 gateway: ${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 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 |
| .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); |
| } |
| |
| function AgentApiKeyValidator(nodes: AppNode[], env: Env): Message[] { |
| return nodes |
| .filter((n): n is ServiceNode => n.type === "app" && n.data.type === "sketch:latest") |
| .filter((n) => n.data.agent?.geminiApiKey == null && !env.integrations.gemini) |
| .map((n) => ({ |
| id: `${n.id}-no-agent-api-key`, |
| type: "FATAL", |
| nodeId: n.id, |
| message: "Configure Gemini API key either on the service or in the project integrations", |
| })); |
| } |