blob: 17369163e0d39eb4b6982720705253232dc16b9b [file] [log] [blame]
gio34193052025-07-03 03:55:11 +00001import { AppNode, Env, NodeType, ServiceNode } from "config";
gioc31bf142025-06-16 07:48:20 +00002import { Message, MessageType } from "./state";
gio5f2f1002025-03-20 18:38:48 +04003
4export interface Validator {
gio34193052025-07-03 03:55:11 +00005 (nodes: AppNode[], env: Env): Message[];
gio5f2f1002025-03-20 18:38:48 +04006}
7
8function CombineValidators(...v: Validator[]): Validator {
gio34193052025-07-03 03:55:11 +00009 return (n, env) => v.flatMap((v) => v(n, env));
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 {
gio34193052025-07-03 03:55:11 +000047 return (nodes, env) => {
48 const nt = new Map(nodes.map((n) => [n.id, NodeTypeToNumber(n.type)]));
49 return v(nodes, env).sort((a, b) => {
giod0026612025-05-08 13:00:36 +000050 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,
gio34193052025-07-03 03:55:11 +000089 AgentApiKeyValidator,
giod0026612025-05-08 13:00:36 +000090 ),
91 );
gio5f2f1002025-03-20 18:38:48 +040092}
93
94function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +000095 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +000096 return [];
97 }
98 return [
99 {
100 id: "no-nodes",
101 type: "FATAL",
102 message: "Start by importing application source code",
103 onHighlight: (store) => store.setHighlightCategory("repository", true),
104 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
105 },
106 ];
gio5f2f1002025-03-20 18:38:48 +0400107}
108
109function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000110 const git = nodes.filter((n) => n.type === "github");
111 const noAddress: Message[] = git
112 .filter((n) => n.data == null || n.data.repository == null)
113 .map(
114 (n) =>
115 ({
116 id: `${n.id}-no-address`,
117 type: "FATAL",
118 nodeId: n.id,
119 message: "Configure repository address",
120 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
121 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
122 }) satisfies Message,
123 );
124 const noApp = git
gioc31bf142025-06-16 07:48:20 +0000125 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.repoNodeId === n.id))
giod0026612025-05-08 13:00:36 +0000126 .map(
127 (n) =>
128 ({
129 id: `${n.id}-no-app`,
130 type: "WARNING",
131 nodeId: n.id,
132 message: "Connect to service",
133 onHighlight: (store) => store.setHighlightCategory("Services", true),
134 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
135 }) satisfies Message,
136 );
137 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400138}
139
140function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000141 const apps = nodes.filter((n) => n.type === "app");
142 const noName = apps
143 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
144 .map(
145 (n): Message => ({
146 id: `${n.id}-no-name`,
147 type: "FATAL",
148 nodeId: n.id,
149 message: "Name the service",
150 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
151 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
152 onClick: (store) => {
153 store.updateNode(n.id, { selected: true });
154 store.updateNodeData<"app">(n.id, {
155 activeField: "name",
156 });
157 },
158 }),
159 );
160 const noSource = apps
gio69148322025-06-19 23:16:12 +0400161 .filter((n) => n.data.type !== "sketch:latest")
gioc31bf142025-06-16 07:48:20 +0000162 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
giod0026612025-05-08 13:00:36 +0000163 .map(
164 (n): Message => ({
165 id: `${n.id}-no-repo`,
166 type: "FATAL",
167 nodeId: n.id,
168 message: "Connect to source repository",
169 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
170 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
171 }),
172 );
173 const noRuntime = apps
174 .filter((n) => n.data == null || n.data.type == null)
175 .map(
176 (n): Message => ({
177 id: `${n.id}-no-runtime`,
178 type: "FATAL",
179 nodeId: n.id,
180 message: "Choose runtime",
181 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
182 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
183 onClick: (store) => {
184 store.updateNode(n.id, { selected: true });
185 store.updateNodeData<"app">(n.id, {
186 activeField: "type",
187 });
188 },
189 }),
190 );
191 const noPorts = apps
192 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
193 .map(
194 (n): Message => ({
195 id: `${n.id}-no-ports`,
196 type: "INFO",
197 nodeId: n.id,
198 message: "Expose ports",
199 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
200 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
201 }),
202 );
gio69148322025-06-19 23:16:12 +0400203 const noIngress = apps
204 .filter((n) => n.data.type !== "sketch:latest")
205 .flatMap((n): Message[] => {
206 if (n.data == null) {
207 return [];
208 }
209 return (n.data.ports || [])
210 .filter(
211 (p) =>
212 !nodes
213 .filter((i) => i.type === "gateway-https")
214 .some((i) => {
215 if (
216 i.data &&
217 i.data.https &&
218 i.data.https.serviceId === n.id &&
219 i.data.https.portId === p.id
220 ) {
221 return true;
222 }
223 return false;
224 }),
225 )
226 .map(
227 (p): Message => ({
228 id: `${n.id}-${p.id}-no-ingress`,
229 type: "WARNING",
230 nodeId: n.id,
231 message: `Connect to gateway: ${p.name} - ${p.value}`,
232 onHighlight: (store) => {
233 store.updateNode(n.id, { selected: true });
234 store.setHighlightCategory("gateways", true);
235 },
236 onLooseHighlight: (store) => {
237 store.updateNode(n.id, { selected: false });
238 store.setHighlightCategory("gateways", false);
239 },
240 }),
241 );
242 });
giod0026612025-05-08 13:00:36 +0000243 const multipleIngress = apps
244 .filter((n) => n.data != null && n.data.ports != null)
245 .flatMap((n) =>
246 n.data.ports.map((p): Message | undefined => {
247 const ing = nodes
248 .filter((i) => i.type === "gateway-https")
249 .filter(
250 (i) =>
251 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
252 );
253 if (ing.length < 2) {
254 return undefined;
255 }
256 return {
257 id: `${n.id}-${p.id}-multiple-ingress`,
258 type: "FATAL",
259 nodeId: n.id,
260 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
261 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
262 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
263 };
264 }),
265 )
266 .filter((m) => m !== undefined);
267 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400268}
269
gioa71316d2025-05-24 09:41:36 +0400270function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
271 const apps = nodes.filter((n) => n.type === "app");
272 return apps
273 .filter((n) => n.data.info != null)
274 .flatMap((n) => {
275 return n.data
276 .info!.configVars.map((cv): Message | undefined => {
277 if (cv.semanticType === "PORT") {
278 if (
279 !(n.data.envVars || []).some(
280 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
281 )
282 ) {
283 return {
284 id: `${n.id}-missing-port-${cv.name}`,
285 type: "WARNING",
286 nodeId: n.id,
287 message: `Service requires port env variable ${cv.name}`,
288 };
289 }
290 }
291 if (cv.category === "EnvironmentVariable") {
292 if (
293 !(n.data.envVars || []).some(
294 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
295 )
296 ) {
297 if (cv.semanticType === "FILESYSTEM_PATH") {
298 return {
299 id: `${n.id}-missing-env-${cv.name}`,
300 type: "FATAL",
301 nodeId: n.id,
302 message: `Service requires env variable ${cv.name}, representing filesystem path`,
303 };
304 } else if (cv.semanticType === "POSTGRES_URL") {
305 return {
306 id: `${n.id}-missing-env-${cv.name}`,
307 type: "FATAL",
308 nodeId: n.id,
309 message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
310 };
311 }
312 }
313 }
314 return undefined;
315 })
316 .filter((m) => m !== undefined);
317 });
318}
319
gio5f2f1002025-03-20 18:38:48 +0400320function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000321 const ing = nodes.filter((n) => n.type === "gateway-https");
322 const noNetwork: Message[] = ing
323 .filter(
324 (n) =>
325 n.data == null ||
326 n.data.network == null ||
327 n.data.network == "" ||
328 n.data.subdomain == null ||
329 n.data.subdomain == "",
330 )
331 .map(
332 (n): Message => ({
333 id: `${n.id}-no-network`,
334 type: "FATAL",
335 nodeId: n.id,
336 message: "Network and subdomain must be defined",
337 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
338 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
339 }),
340 );
341 const notConnected: Message[] = ing
342 .filter(
343 (n) =>
344 n.data == null ||
345 n.data.https == null ||
346 n.data.https.serviceId == null ||
347 n.data.https.serviceId == "" ||
348 n.data.https.portId == null ||
349 n.data.https.portId == "",
350 )
351 .map((n) => ({
352 id: `${n.id}-not-connected`,
353 type: "FATAL",
354 nodeId: n.id,
355 message: "Connect to a service port",
356 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
357 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
358 }));
359 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400360}
361
362function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000363 const ing = nodes.filter((n) => n.type === "gateway-tcp");
364 const noNetwork: Message[] = ing
365 .filter(
366 (n) =>
367 n.data == null ||
368 n.data.network == null ||
369 n.data.network == "" ||
370 n.data.subdomain == null ||
371 n.data.subdomain == "",
372 )
373 .map(
374 (n): Message => ({
375 id: `${n.id}-no-network`,
376 type: "FATAL",
377 nodeId: n.id,
378 message: "Network and subdomain must be defined",
379 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
380 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
381 }),
382 );
383 const notConnected: Message[] = ing
384 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
385 .map((n) => ({
386 id: `${n.id}-not-connected`,
387 type: "FATAL",
388 nodeId: n.id,
389 message: "Connect to a service port",
390 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
391 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
392 }));
393 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400394}
gio34193052025-07-03 03:55:11 +0000395
396function AgentApiKeyValidator(nodes: AppNode[], env: Env): Message[] {
397 return nodes
398 .filter((n): n is ServiceNode => n.type === "app" && n.data.type === "sketch:latest")
gio69ff7592025-07-03 06:27:21 +0000399 .flatMap((n) => {
400 const messages: Message[] = [];
401 const model = n.data.model;
402
403 if (!model || !model.name) {
404 messages.push({
405 id: `${n.id}-no-agent-model`,
406 type: "FATAL",
407 nodeId: n.id,
408 message: "Select an AI model for the agent (Gemini or Claude).",
409 });
410 return messages;
411 }
412
413 if (model.name === "gemini" && !model.apiKey && !env.integrations.gemini) {
414 messages.push({
415 id: `${n.id}-no-gemini-api-key`,
416 type: "FATAL",
417 nodeId: n.id,
418 message: "Configure Gemini API key either on the service or in the project integrations.",
419 });
420 }
421
422 if (model.name === "claude" && !model.apiKey && !env.integrations.anthropic) {
423 messages.push({
424 id: `${n.id}-no-anthropic-api-key`,
425 type: "FATAL",
426 nodeId: n.id,
427 message: "Configure Anthropic API key either on the service or in the project integrations.",
428 });
429 }
430
431 return messages;
432 });
gio34193052025-07-03 03:55:11 +0000433}