blob: 39db5b4e4178f7a003dbcdda56479b74d084c2fe [file] [log] [blame]
gioc31bf142025-06-16 07:48:20 +00001import { AppNode, NodeType } from "config";
2import { Message, MessageType } from "./state";
gio5f2f1002025-03-20 18:38:48 +04003
4export interface Validator {
giod0026612025-05-08 13:00:36 +00005 (nodes: AppNode[]): Message[];
gio5f2f1002025-03-20 18:38:48 +04006}
7
8function CombineValidators(...v: Validator[]): Validator {
giod0026612025-05-08 13:00:36 +00009 return (n) => v.flatMap((v) => v(n));
gio5f2f1002025-03-20 18:38:48 +040010}
11
12function MessageTypeToNumber(t: MessageType) {
giod0026612025-05-08 13:00:36 +000013 switch (t) {
14 case "FATAL":
15 return 0;
16 case "WARNING":
17 return 1;
18 case "INFO":
19 return 2;
20 }
gio5f2f1002025-03-20 18:38:48 +040021}
22
23function NodeTypeToNumber(t?: NodeType) {
giod0026612025-05-08 13:00:36 +000024 switch (t) {
25 case "github":
26 return 0;
27 case "app":
28 return 1;
29 case "volume":
30 return 2;
31 case "postgresql":
32 return 3;
33 case "mongodb":
34 return 4;
35 case "gateway-https":
36 return 5;
37 case undefined:
38 return 100;
39 }
gio5f2f1002025-03-20 18:38:48 +040040}
41
42function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +000043 return (n) => {
44 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
45 return v(n).sort((a, b) => {
46 const at = MessageTypeToNumber(a.type);
47 const bt = MessageTypeToNumber(b.type);
48 if (a.nodeId === undefined && b.nodeId === undefined) {
49 if (at !== bt) {
50 return at - bt;
51 }
52 return a.id.localeCompare(b.id);
53 }
54 if (a.nodeId === undefined) {
55 return -1;
56 }
57 if (b.nodeId === undefined) {
58 return 1;
59 }
60 if (a.nodeId === b.nodeId) {
61 if (at !== bt) {
62 return at - bt;
63 }
64 return a.id.localeCompare(b.id);
65 }
66 const ant = nt.get(a.id)!;
67 const bnt = nt.get(b.id)!;
68 if (ant !== bnt) {
69 return ant - bnt;
70 }
71 return a.id.localeCompare(b.id);
72 });
73 };
gio5f2f1002025-03-20 18:38:48 +040074}
75
76export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +000077 return SortingValidator(
78 CombineValidators(
79 EmptyValidator,
80 GitRepositoryValidator,
81 ServiceValidator,
gioa71316d2025-05-24 09:41:36 +040082 ServiceAnalyzisValidator,
giod0026612025-05-08 13:00:36 +000083 GatewayHTTPSValidator,
84 GatewayTCPValidator,
85 ),
86 );
gio5f2f1002025-03-20 18:38:48 +040087}
88
89function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +000090 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +000091 return [];
92 }
93 return [
94 {
95 id: "no-nodes",
96 type: "FATAL",
97 message: "Start by importing application source code",
98 onHighlight: (store) => store.setHighlightCategory("repository", true),
99 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
100 },
101 ];
gio5f2f1002025-03-20 18:38:48 +0400102}
103
104function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000105 const git = nodes.filter((n) => n.type === "github");
106 const noAddress: Message[] = git
107 .filter((n) => n.data == null || n.data.repository == null)
108 .map(
109 (n) =>
110 ({
111 id: `${n.id}-no-address`,
112 type: "FATAL",
113 nodeId: n.id,
114 message: "Configure repository address",
115 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
116 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
117 }) satisfies Message,
118 );
119 const noApp = git
gioc31bf142025-06-16 07:48:20 +0000120 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.repoNodeId === n.id))
giod0026612025-05-08 13:00:36 +0000121 .map(
122 (n) =>
123 ({
124 id: `${n.id}-no-app`,
125 type: "WARNING",
126 nodeId: n.id,
127 message: "Connect to service",
128 onHighlight: (store) => store.setHighlightCategory("Services", true),
129 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
130 }) satisfies Message,
131 );
132 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400133}
134
135function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000136 const apps = nodes.filter((n) => n.type === "app");
137 const noName = apps
138 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
139 .map(
140 (n): Message => ({
141 id: `${n.id}-no-name`,
142 type: "FATAL",
143 nodeId: n.id,
144 message: "Name the service",
145 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
146 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
147 onClick: (store) => {
148 store.updateNode(n.id, { selected: true });
149 store.updateNodeData<"app">(n.id, {
150 activeField: "name",
151 });
152 },
153 }),
154 );
155 const noSource = apps
gioc31bf142025-06-16 07:48:20 +0000156 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
giod0026612025-05-08 13:00:36 +0000157 .map(
158 (n): Message => ({
159 id: `${n.id}-no-repo`,
160 type: "FATAL",
161 nodeId: n.id,
162 message: "Connect to source repository",
163 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
164 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
165 }),
166 );
167 const noRuntime = apps
168 .filter((n) => n.data == null || n.data.type == null)
169 .map(
170 (n): Message => ({
171 id: `${n.id}-no-runtime`,
172 type: "FATAL",
173 nodeId: n.id,
174 message: "Choose runtime",
175 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
176 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
177 onClick: (store) => {
178 store.updateNode(n.id, { selected: true });
179 store.updateNodeData<"app">(n.id, {
180 activeField: "type",
181 });
182 },
183 }),
184 );
185 const noPorts = apps
186 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
187 .map(
188 (n): Message => ({
189 id: `${n.id}-no-ports`,
190 type: "INFO",
191 nodeId: n.id,
192 message: "Expose ports",
193 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
194 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
195 }),
196 );
197 const noIngress = apps.flatMap((n): Message[] => {
198 if (n.data == null) {
199 return [];
200 }
201 return (n.data.ports || [])
202 .filter(
203 (p) =>
204 !nodes
205 .filter((i) => i.type === "gateway-https")
206 .some((i) => {
207 if (
208 i.data &&
209 i.data.https &&
210 i.data.https.serviceId === n.id &&
211 i.data.https.portId === p.id
212 ) {
213 return true;
214 }
215 return false;
216 }),
217 )
218 .map(
219 (p): Message => ({
220 id: `${n.id}-${p.id}-no-ingress`,
221 type: "WARNING",
222 nodeId: n.id,
gio33046722025-05-16 14:49:55 +0000223 message: `Connect to gateway: ${p.name} - ${p.value}`,
giod0026612025-05-08 13:00:36 +0000224 onHighlight: (store) => {
225 store.updateNode(n.id, { selected: true });
226 store.setHighlightCategory("gateways", true);
227 },
228 onLooseHighlight: (store) => {
229 store.updateNode(n.id, { selected: false });
230 store.setHighlightCategory("gateways", false);
231 },
232 }),
233 );
234 });
235 const multipleIngress = apps
236 .filter((n) => n.data != null && n.data.ports != null)
237 .flatMap((n) =>
238 n.data.ports.map((p): Message | undefined => {
239 const ing = nodes
240 .filter((i) => i.type === "gateway-https")
241 .filter(
242 (i) =>
243 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
244 );
245 if (ing.length < 2) {
246 return undefined;
247 }
248 return {
249 id: `${n.id}-${p.id}-multiple-ingress`,
250 type: "FATAL",
251 nodeId: n.id,
252 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
253 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
254 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
255 };
256 }),
257 )
258 .filter((m) => m !== undefined);
259 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400260}
261
gioa71316d2025-05-24 09:41:36 +0400262function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
263 const apps = nodes.filter((n) => n.type === "app");
264 return apps
265 .filter((n) => n.data.info != null)
266 .flatMap((n) => {
267 return n.data
268 .info!.configVars.map((cv): Message | undefined => {
269 if (cv.semanticType === "PORT") {
270 if (
271 !(n.data.envVars || []).some(
272 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
273 )
274 ) {
275 return {
276 id: `${n.id}-missing-port-${cv.name}`,
277 type: "WARNING",
278 nodeId: n.id,
279 message: `Service requires port env variable ${cv.name}`,
280 };
281 }
282 }
283 if (cv.category === "EnvironmentVariable") {
284 if (
285 !(n.data.envVars || []).some(
286 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
287 )
288 ) {
289 if (cv.semanticType === "FILESYSTEM_PATH") {
290 return {
291 id: `${n.id}-missing-env-${cv.name}`,
292 type: "FATAL",
293 nodeId: n.id,
294 message: `Service requires env variable ${cv.name}, representing filesystem path`,
295 };
296 } else if (cv.semanticType === "POSTGRES_URL") {
297 return {
298 id: `${n.id}-missing-env-${cv.name}`,
299 type: "FATAL",
300 nodeId: n.id,
301 message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
302 };
303 }
304 }
305 }
306 return undefined;
307 })
308 .filter((m) => m !== undefined);
309 });
310}
311
gio5f2f1002025-03-20 18:38:48 +0400312function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000313 const ing = nodes.filter((n) => n.type === "gateway-https");
314 const noNetwork: Message[] = ing
315 .filter(
316 (n) =>
317 n.data == null ||
318 n.data.network == null ||
319 n.data.network == "" ||
320 n.data.subdomain == null ||
321 n.data.subdomain == "",
322 )
323 .map(
324 (n): Message => ({
325 id: `${n.id}-no-network`,
326 type: "FATAL",
327 nodeId: n.id,
328 message: "Network and subdomain must be defined",
329 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
330 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
331 }),
332 );
333 const notConnected: Message[] = ing
334 .filter(
335 (n) =>
336 n.data == null ||
337 n.data.https == null ||
338 n.data.https.serviceId == null ||
339 n.data.https.serviceId == "" ||
340 n.data.https.portId == null ||
341 n.data.https.portId == "",
342 )
343 .map((n) => ({
344 id: `${n.id}-not-connected`,
345 type: "FATAL",
346 nodeId: n.id,
347 message: "Connect to a service port",
348 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
349 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
350 }));
351 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400352}
353
354function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000355 const ing = nodes.filter((n) => n.type === "gateway-tcp");
356 const noNetwork: Message[] = ing
357 .filter(
358 (n) =>
359 n.data == null ||
360 n.data.network == null ||
361 n.data.network == "" ||
362 n.data.subdomain == null ||
363 n.data.subdomain == "",
364 )
365 .map(
366 (n): Message => ({
367 id: `${n.id}-no-network`,
368 type: "FATAL",
369 nodeId: n.id,
370 message: "Network and subdomain must be defined",
371 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
372 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
373 }),
374 );
375 const notConnected: Message[] = ing
376 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
377 .map((n) => ({
378 id: `${n.id}-not-connected`,
379 type: "FATAL",
380 nodeId: n.id,
381 message: "Connect to a service port",
382 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
383 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
384 }));
385 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400386}