blob: f6dee322a2d4b0f836d1222caae959d51c345574 [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;
gio69148322025-06-19 23:16:12 +040037 case "gateway-tcp":
38 return 7;
39 case "network":
40 return 8;
41 case undefined: // For NANode
giod0026612025-05-08 13:00:36 +000042 return 100;
43 }
gio5f2f1002025-03-20 18:38:48 +040044}
45
46function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +000047 return (n) => {
48 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
49 return v(n).sort((a, b) => {
50 const at = MessageTypeToNumber(a.type);
51 const bt = MessageTypeToNumber(b.type);
52 if (a.nodeId === undefined && b.nodeId === undefined) {
53 if (at !== bt) {
54 return at - bt;
55 }
56 return a.id.localeCompare(b.id);
57 }
58 if (a.nodeId === undefined) {
59 return -1;
60 }
61 if (b.nodeId === undefined) {
62 return 1;
63 }
64 if (a.nodeId === b.nodeId) {
65 if (at !== bt) {
66 return at - bt;
67 }
68 return a.id.localeCompare(b.id);
69 }
70 const ant = nt.get(a.id)!;
71 const bnt = nt.get(b.id)!;
72 if (ant !== bnt) {
73 return ant - bnt;
74 }
75 return a.id.localeCompare(b.id);
76 });
77 };
gio5f2f1002025-03-20 18:38:48 +040078}
79
80export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +000081 return SortingValidator(
82 CombineValidators(
83 EmptyValidator,
84 GitRepositoryValidator,
85 ServiceValidator,
gioa71316d2025-05-24 09:41:36 +040086 ServiceAnalyzisValidator,
giod0026612025-05-08 13:00:36 +000087 GatewayHTTPSValidator,
88 GatewayTCPValidator,
89 ),
90 );
gio5f2f1002025-03-20 18:38:48 +040091}
92
93function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +000094 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +000095 return [];
96 }
97 return [
98 {
99 id: "no-nodes",
100 type: "FATAL",
101 message: "Start by importing application source code",
102 onHighlight: (store) => store.setHighlightCategory("repository", true),
103 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
104 },
105 ];
gio5f2f1002025-03-20 18:38:48 +0400106}
107
108function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000109 const git = nodes.filter((n) => n.type === "github");
110 const noAddress: Message[] = git
111 .filter((n) => n.data == null || n.data.repository == null)
112 .map(
113 (n) =>
114 ({
115 id: `${n.id}-no-address`,
116 type: "FATAL",
117 nodeId: n.id,
118 message: "Configure repository address",
119 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
120 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
121 }) satisfies Message,
122 );
123 const noApp = git
gioc31bf142025-06-16 07:48:20 +0000124 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.repoNodeId === n.id))
giod0026612025-05-08 13:00:36 +0000125 .map(
126 (n) =>
127 ({
128 id: `${n.id}-no-app`,
129 type: "WARNING",
130 nodeId: n.id,
131 message: "Connect to service",
132 onHighlight: (store) => store.setHighlightCategory("Services", true),
133 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
134 }) satisfies Message,
135 );
136 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400137}
138
139function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000140 const apps = nodes.filter((n) => n.type === "app");
141 const noName = apps
142 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
143 .map(
144 (n): Message => ({
145 id: `${n.id}-no-name`,
146 type: "FATAL",
147 nodeId: n.id,
148 message: "Name the service",
149 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
150 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
151 onClick: (store) => {
152 store.updateNode(n.id, { selected: true });
153 store.updateNodeData<"app">(n.id, {
154 activeField: "name",
155 });
156 },
157 }),
158 );
159 const noSource = apps
gio69148322025-06-19 23:16:12 +0400160 .filter((n) => n.data.type !== "sketch:latest")
gioc31bf142025-06-16 07:48:20 +0000161 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
giod0026612025-05-08 13:00:36 +0000162 .map(
163 (n): Message => ({
164 id: `${n.id}-no-repo`,
165 type: "FATAL",
166 nodeId: n.id,
167 message: "Connect to source repository",
168 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
169 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
170 }),
171 );
172 const noRuntime = apps
173 .filter((n) => n.data == null || n.data.type == null)
174 .map(
175 (n): Message => ({
176 id: `${n.id}-no-runtime`,
177 type: "FATAL",
178 nodeId: n.id,
179 message: "Choose runtime",
180 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
181 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
182 onClick: (store) => {
183 store.updateNode(n.id, { selected: true });
184 store.updateNodeData<"app">(n.id, {
185 activeField: "type",
186 });
187 },
188 }),
189 );
190 const noPorts = apps
191 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
192 .map(
193 (n): Message => ({
194 id: `${n.id}-no-ports`,
195 type: "INFO",
196 nodeId: n.id,
197 message: "Expose ports",
198 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
199 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
200 }),
201 );
gio69148322025-06-19 23:16:12 +0400202 const noIngress = apps
203 .filter((n) => n.data.type !== "sketch:latest")
204 .flatMap((n): Message[] => {
205 if (n.data == null) {
206 return [];
207 }
208 return (n.data.ports || [])
209 .filter(
210 (p) =>
211 !nodes
212 .filter((i) => i.type === "gateway-https")
213 .some((i) => {
214 if (
215 i.data &&
216 i.data.https &&
217 i.data.https.serviceId === n.id &&
218 i.data.https.portId === p.id
219 ) {
220 return true;
221 }
222 return false;
223 }),
224 )
225 .map(
226 (p): Message => ({
227 id: `${n.id}-${p.id}-no-ingress`,
228 type: "WARNING",
229 nodeId: n.id,
230 message: `Connect to gateway: ${p.name} - ${p.value}`,
231 onHighlight: (store) => {
232 store.updateNode(n.id, { selected: true });
233 store.setHighlightCategory("gateways", true);
234 },
235 onLooseHighlight: (store) => {
236 store.updateNode(n.id, { selected: false });
237 store.setHighlightCategory("gateways", false);
238 },
239 }),
240 );
241 });
giod0026612025-05-08 13:00:36 +0000242 const multipleIngress = apps
243 .filter((n) => n.data != null && n.data.ports != null)
244 .flatMap((n) =>
245 n.data.ports.map((p): Message | undefined => {
246 const ing = nodes
247 .filter((i) => i.type === "gateway-https")
248 .filter(
249 (i) =>
250 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
251 );
252 if (ing.length < 2) {
253 return undefined;
254 }
255 return {
256 id: `${n.id}-${p.id}-multiple-ingress`,
257 type: "FATAL",
258 nodeId: n.id,
259 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
260 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
261 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
262 };
263 }),
264 )
265 .filter((m) => m !== undefined);
266 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400267}
268
gioa71316d2025-05-24 09:41:36 +0400269function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
270 const apps = nodes.filter((n) => n.type === "app");
271 return apps
272 .filter((n) => n.data.info != null)
273 .flatMap((n) => {
274 return n.data
275 .info!.configVars.map((cv): Message | undefined => {
276 if (cv.semanticType === "PORT") {
277 if (
278 !(n.data.envVars || []).some(
279 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
280 )
281 ) {
282 return {
283 id: `${n.id}-missing-port-${cv.name}`,
284 type: "WARNING",
285 nodeId: n.id,
286 message: `Service requires port env variable ${cv.name}`,
287 };
288 }
289 }
290 if (cv.category === "EnvironmentVariable") {
291 if (
292 !(n.data.envVars || []).some(
293 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
294 )
295 ) {
296 if (cv.semanticType === "FILESYSTEM_PATH") {
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 filesystem path`,
302 };
303 } else if (cv.semanticType === "POSTGRES_URL") {
304 return {
305 id: `${n.id}-missing-env-${cv.name}`,
306 type: "FATAL",
307 nodeId: n.id,
308 message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
309 };
310 }
311 }
312 }
313 return undefined;
314 })
315 .filter((m) => m !== undefined);
316 });
317}
318
gio5f2f1002025-03-20 18:38:48 +0400319function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000320 const ing = nodes.filter((n) => n.type === "gateway-https");
321 const noNetwork: Message[] = ing
322 .filter(
323 (n) =>
324 n.data == null ||
325 n.data.network == null ||
326 n.data.network == "" ||
327 n.data.subdomain == null ||
328 n.data.subdomain == "",
329 )
330 .map(
331 (n): Message => ({
332 id: `${n.id}-no-network`,
333 type: "FATAL",
334 nodeId: n.id,
335 message: "Network and subdomain must be defined",
336 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
337 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
338 }),
339 );
340 const notConnected: Message[] = ing
341 .filter(
342 (n) =>
343 n.data == null ||
344 n.data.https == null ||
345 n.data.https.serviceId == null ||
346 n.data.https.serviceId == "" ||
347 n.data.https.portId == null ||
348 n.data.https.portId == "",
349 )
350 .map((n) => ({
351 id: `${n.id}-not-connected`,
352 type: "FATAL",
353 nodeId: n.id,
354 message: "Connect to a service port",
355 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
356 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
357 }));
358 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400359}
360
361function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000362 const ing = nodes.filter((n) => n.type === "gateway-tcp");
363 const noNetwork: Message[] = ing
364 .filter(
365 (n) =>
366 n.data == null ||
367 n.data.network == null ||
368 n.data.network == "" ||
369 n.data.subdomain == null ||
370 n.data.subdomain == "",
371 )
372 .map(
373 (n): Message => ({
374 id: `${n.id}-no-network`,
375 type: "FATAL",
376 nodeId: n.id,
377 message: "Network and subdomain must be defined",
378 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
379 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
380 }),
381 );
382 const notConnected: Message[] = ing
383 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
384 .map((n) => ({
385 id: `${n.id}-not-connected`,
386 type: "FATAL",
387 nodeId: n.id,
388 message: "Connect to a service port",
389 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
390 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
391 }));
392 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400393}