blob: 513f1f03c8595be68dd1f04ce5da96ee1f506006 [file] [log] [blame]
gio48fde052025-05-14 09:48:08 +00001import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
gio5f2f1002025-03-20 18:38:48 +04002
3export type AuthDisabled = {
giod0026612025-05-08 13:00:36 +00004 enabled: false;
gio5f2f1002025-03-20 18:38:48 +04005};
6
7export type AuthEnabled = {
giod0026612025-05-08 13:00:36 +00008 enabled: true;
9 groups: string[];
10 noAuthPathPatterns: string[];
gio5f2f1002025-03-20 18:38:48 +040011};
12
13export type Auth = AuthDisabled | AuthEnabled;
14
15export type Ingress = {
giod0026612025-05-08 13:00:36 +000016 network: string;
17 subdomain: string;
18 port: { name: string } | { value: string };
19 auth: Auth;
gio5f2f1002025-03-20 18:38:48 +040020};
21
22export type Domain = {
giod0026612025-05-08 13:00:36 +000023 network: string;
24 subdomain: string;
gio5f2f1002025-03-20 18:38:48 +040025};
26
giod0026612025-05-08 13:00:36 +000027export type PortValue =
28 | {
29 name: string;
30 }
31 | {
32 value: number;
33 };
gio5f2f1002025-03-20 18:38:48 +040034
35export type PortDomain = Domain & {
giod0026612025-05-08 13:00:36 +000036 port: PortValue;
37};
gio5f2f1002025-03-20 18:38:48 +040038
39export type Service = {
giod0026612025-05-08 13:00:36 +000040 type: ServiceType;
41 name: string;
42 source: {
43 repository: string;
44 branch: string;
45 rootDir: string;
46 };
47 ports?: {
48 name: string;
49 value: number;
50 protocol: "TCP" | "UDP";
51 }[];
52 env?: {
53 name: string;
54 alias?: string;
55 }[];
56 ingress?: Ingress[];
57 expose?: PortDomain[];
58 volume?: string[];
59 preBuildCommands?: { bin: string }[];
gio48fde052025-05-14 09:48:08 +000060 dev?: {
61 enabled: boolean;
gio3ed59592025-05-14 16:51:09 +000062 username?: string;
gio48fde052025-05-14 09:48:08 +000063 ssh?: Domain;
64 codeServer?: Domain;
65 };
gio5f2f1002025-03-20 18:38:48 +040066};
67
68export type Volume = {
giod0026612025-05-08 13:00:36 +000069 name: string;
70 accessMode: VolumeType;
71 size: string;
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type PostgreSQL = {
giod0026612025-05-08 13:00:36 +000075 name: string;
76 size: string;
77 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040078};
79
80export type MongoDB = {
giod0026612025-05-08 13:00:36 +000081 name: string;
82 size: string;
83 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040084};
85
86export type Config = {
gio7d813702025-05-08 18:29:52 +000087 input: {
88 appId: string;
89 managerAddr: string;
90 };
giod0026612025-05-08 13:00:36 +000091 service?: Service[];
92 volume?: Volume[];
93 postgresql?: PostgreSQL[];
94 mongodb?: MongoDB[];
gio5f2f1002025-03-20 18:38:48 +040095};
96
gio7d813702025-05-08 18:29:52 +000097export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
giod0026612025-05-08 13:00:36 +000098 try {
gio7d813702025-05-08 18:29:52 +000099 if (appId == null || env.managerAddr == null) {
100 return null;
101 }
giod0026612025-05-08 13:00:36 +0000102 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
gio3ed59592025-05-14 16:51:09 +0000103 const ingressNodes = nodes
104 .filter((n) => n.type === "gateway-https")
105 .filter((n) => n.data.https !== undefined && !n.data.readonly);
106 const tcpNodes = nodes
107 .filter((n) => n.type === "gateway-tcp")
108 .filter((n) => n.data.exposed !== undefined && !n.data.readonly);
giod0026612025-05-08 13:00:36 +0000109 const findExpose = (n: AppNode): PortDomain[] => {
110 return n.data.ports
111 .map((p) => [n.id, p.id, p.name])
112 .flatMap((sp) => {
113 return tcpNodes.flatMap((i) =>
114 (i.data.exposed || [])
115 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
116 .map(() => ({
117 network: networkMap.get(i.data.network!)!,
118 subdomain: i.data.subdomain!,
119 port: { name: sp[2] },
120 })),
121 );
122 });
123 };
124 return {
gio7d813702025-05-08 18:29:52 +0000125 input: {
126 appId: appId,
127 managerAddr: env.managerAddr,
128 },
giod0026612025-05-08 13:00:36 +0000129 service: nodes
130 .filter((n) => n.type === "app")
131 .map((n): Service => {
132 return {
133 type: n.data.type,
134 name: n.data.label,
135 source: {
136 repository: nodes
137 .filter((i) => i.type === "github")
138 .find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
139 branch: n.data.repository.branch,
140 rootDir: n.data.repository.rootDir,
141 },
gio3ed59592025-05-14 16:51:09 +0000142 ports: (n.data.ports || [])
143 .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
144 .map((p) => ({
145 name: p.name,
146 value: p.value,
147 protocol: "TCP", // TODO(gio)
148 })),
giod0026612025-05-08 13:00:36 +0000149 env: (n.data.envVars || [])
150 .filter((e) => "name" in e)
151 .map((e) => ({
152 name: e.name,
153 alias: "alias" in e ? e.alias : undefined,
154 })),
155 ingress: ingressNodes
156 .filter((i) => i.data.https!.serviceId === n.id)
157 .map(
gio48fde052025-05-14 09:48:08 +0000158 (i): Ingress => ({
giod0026612025-05-08 13:00:36 +0000159 network: networkMap.get(i.data.network!)!,
160 subdomain: i.data.subdomain!,
161 port: {
162 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
163 },
164 auth:
165 i.data.auth?.enabled || false
166 ? {
167 enabled: true,
168 groups: i.data.auth!.groups,
169 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
170 }
171 : {
172 enabled: false,
173 },
174 }),
175 ),
176 expose: findExpose(n),
177 preBuildCommands: n.data.preBuildCommands
178 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
179 : [],
gio48fde052025-05-14 09:48:08 +0000180 dev: {
181 enabled: n.data.dev ? n.data.dev.enabled : false,
gio3ed59592025-05-14 16:51:09 +0000182 username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
gio48fde052025-05-14 09:48:08 +0000183 codeServer:
184 n.data.dev?.enabled && n.data.dev.expose != null
185 ? {
gio3ed59592025-05-14 16:51:09 +0000186 network: networkMap.get(n.data.dev.expose.network)!,
gio48fde052025-05-14 09:48:08 +0000187 subdomain: n.data.dev.expose.subdomain,
188 }
189 : undefined,
190 ssh:
191 n.data.dev?.enabled && n.data.dev.expose != null
192 ? {
gio3ed59592025-05-14 16:51:09 +0000193 network: networkMap.get(n.data.dev.expose.network)!,
gio48fde052025-05-14 09:48:08 +0000194 subdomain: n.data.dev.expose.subdomain,
195 }
196 : undefined,
197 },
giod0026612025-05-08 13:00:36 +0000198 };
199 }),
200 volume: nodes
201 .filter((n) => n.type === "volume")
202 .map(
203 (n): Volume => ({
204 name: n.data.label,
205 accessMode: n.data.type,
206 size: n.data.size,
207 }),
208 ),
209 postgresql: nodes
210 .filter((n) => n.type === "postgresql")
211 .map(
212 (n): PostgreSQL => ({
213 name: n.data.label,
214 size: "1Gi", // TODO(gio)
215 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
216 }),
217 ),
218 mongodb: nodes
219 .filter((n) => n.type === "mongodb")
220 .map(
221 (n): MongoDB => ({
222 name: n.data.label,
223 size: "1Gi", // TODO(gio)
224 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
225 }),
226 ),
227 };
228 } catch (e) {
229 console.log(e);
230 return null;
231 }
gio5f2f1002025-03-20 18:38:48 +0400232}
233
234export interface Validator {
giod0026612025-05-08 13:00:36 +0000235 (nodes: AppNode[]): Message[];
gio5f2f1002025-03-20 18:38:48 +0400236}
237
238function CombineValidators(...v: Validator[]): Validator {
giod0026612025-05-08 13:00:36 +0000239 return (n) => v.flatMap((v) => v(n));
gio5f2f1002025-03-20 18:38:48 +0400240}
241
242function MessageTypeToNumber(t: MessageType) {
giod0026612025-05-08 13:00:36 +0000243 switch (t) {
244 case "FATAL":
245 return 0;
246 case "WARNING":
247 return 1;
248 case "INFO":
249 return 2;
250 }
gio5f2f1002025-03-20 18:38:48 +0400251}
252
253function NodeTypeToNumber(t?: NodeType) {
giod0026612025-05-08 13:00:36 +0000254 switch (t) {
255 case "github":
256 return 0;
257 case "app":
258 return 1;
259 case "volume":
260 return 2;
261 case "postgresql":
262 return 3;
263 case "mongodb":
264 return 4;
265 case "gateway-https":
266 return 5;
267 case undefined:
268 return 100;
269 }
gio5f2f1002025-03-20 18:38:48 +0400270}
271
272function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +0000273 return (n) => {
274 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
275 return v(n).sort((a, b) => {
276 const at = MessageTypeToNumber(a.type);
277 const bt = MessageTypeToNumber(b.type);
278 if (a.nodeId === undefined && b.nodeId === undefined) {
279 if (at !== bt) {
280 return at - bt;
281 }
282 return a.id.localeCompare(b.id);
283 }
284 if (a.nodeId === undefined) {
285 return -1;
286 }
287 if (b.nodeId === undefined) {
288 return 1;
289 }
290 if (a.nodeId === b.nodeId) {
291 if (at !== bt) {
292 return at - bt;
293 }
294 return a.id.localeCompare(b.id);
295 }
296 const ant = nt.get(a.id)!;
297 const bnt = nt.get(b.id)!;
298 if (ant !== bnt) {
299 return ant - bnt;
300 }
301 return a.id.localeCompare(b.id);
302 });
303 };
gio5f2f1002025-03-20 18:38:48 +0400304}
305
306export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +0000307 return SortingValidator(
308 CombineValidators(
309 EmptyValidator,
310 GitRepositoryValidator,
311 ServiceValidator,
gioa71316d2025-05-24 09:41:36 +0400312 ServiceAnalyzisValidator,
giod0026612025-05-08 13:00:36 +0000313 GatewayHTTPSValidator,
314 GatewayTCPValidator,
315 ),
316 );
gio5f2f1002025-03-20 18:38:48 +0400317}
318
319function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +0000320 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +0000321 return [];
322 }
323 return [
324 {
325 id: "no-nodes",
326 type: "FATAL",
327 message: "Start by importing application source code",
328 onHighlight: (store) => store.setHighlightCategory("repository", true),
329 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
330 },
331 ];
gio5f2f1002025-03-20 18:38:48 +0400332}
333
334function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000335 const git = nodes.filter((n) => n.type === "github");
336 const noAddress: Message[] = git
337 .filter((n) => n.data == null || n.data.repository == null)
338 .map(
339 (n) =>
340 ({
341 id: `${n.id}-no-address`,
342 type: "FATAL",
343 nodeId: n.id,
344 message: "Configure repository address",
345 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
346 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
347 }) satisfies Message,
348 );
349 const noApp = git
350 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
351 .map(
352 (n) =>
353 ({
354 id: `${n.id}-no-app`,
355 type: "WARNING",
356 nodeId: n.id,
357 message: "Connect to service",
358 onHighlight: (store) => store.setHighlightCategory("Services", true),
359 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
360 }) satisfies Message,
361 );
362 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400363}
364
365function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000366 const apps = nodes.filter((n) => n.type === "app");
367 const noName = apps
368 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
369 .map(
370 (n): Message => ({
371 id: `${n.id}-no-name`,
372 type: "FATAL",
373 nodeId: n.id,
374 message: "Name the service",
375 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
376 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
377 onClick: (store) => {
378 store.updateNode(n.id, { selected: true });
379 store.updateNodeData<"app">(n.id, {
380 activeField: "name",
381 });
382 },
383 }),
384 );
385 const noSource = apps
386 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
387 .map(
388 (n): Message => ({
389 id: `${n.id}-no-repo`,
390 type: "FATAL",
391 nodeId: n.id,
392 message: "Connect to source repository",
393 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
394 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
395 }),
396 );
397 const noRuntime = apps
398 .filter((n) => n.data == null || n.data.type == null)
399 .map(
400 (n): Message => ({
401 id: `${n.id}-no-runtime`,
402 type: "FATAL",
403 nodeId: n.id,
404 message: "Choose runtime",
405 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
406 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
407 onClick: (store) => {
408 store.updateNode(n.id, { selected: true });
409 store.updateNodeData<"app">(n.id, {
410 activeField: "type",
411 });
412 },
413 }),
414 );
415 const noPorts = apps
416 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
417 .map(
418 (n): Message => ({
419 id: `${n.id}-no-ports`,
420 type: "INFO",
421 nodeId: n.id,
422 message: "Expose ports",
423 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
424 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
425 }),
426 );
427 const noIngress = apps.flatMap((n): Message[] => {
428 if (n.data == null) {
429 return [];
430 }
431 return (n.data.ports || [])
432 .filter(
433 (p) =>
434 !nodes
435 .filter((i) => i.type === "gateway-https")
436 .some((i) => {
437 if (
438 i.data &&
439 i.data.https &&
440 i.data.https.serviceId === n.id &&
441 i.data.https.portId === p.id
442 ) {
443 return true;
444 }
445 return false;
446 }),
447 )
448 .map(
449 (p): Message => ({
450 id: `${n.id}-${p.id}-no-ingress`,
451 type: "WARNING",
452 nodeId: n.id,
gio33046722025-05-16 14:49:55 +0000453 message: `Connect to gateway: ${p.name} - ${p.value}`,
giod0026612025-05-08 13:00:36 +0000454 onHighlight: (store) => {
455 store.updateNode(n.id, { selected: true });
456 store.setHighlightCategory("gateways", true);
457 },
458 onLooseHighlight: (store) => {
459 store.updateNode(n.id, { selected: false });
460 store.setHighlightCategory("gateways", false);
461 },
462 }),
463 );
464 });
465 const multipleIngress = apps
466 .filter((n) => n.data != null && n.data.ports != null)
467 .flatMap((n) =>
468 n.data.ports.map((p): Message | undefined => {
469 const ing = nodes
470 .filter((i) => i.type === "gateway-https")
471 .filter(
472 (i) =>
473 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
474 );
475 if (ing.length < 2) {
476 return undefined;
477 }
478 return {
479 id: `${n.id}-${p.id}-multiple-ingress`,
480 type: "FATAL",
481 nodeId: n.id,
482 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
483 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
484 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
485 };
486 }),
487 )
488 .filter((m) => m !== undefined);
489 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400490}
491
gioa71316d2025-05-24 09:41:36 +0400492function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
493 const apps = nodes.filter((n) => n.type === "app");
494 return apps
495 .filter((n) => n.data.info != null)
496 .flatMap((n) => {
497 return n.data
498 .info!.configVars.map((cv): Message | undefined => {
499 if (cv.semanticType === "PORT") {
500 if (
501 !(n.data.envVars || []).some(
502 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
503 )
504 ) {
505 return {
506 id: `${n.id}-missing-port-${cv.name}`,
507 type: "WARNING",
508 nodeId: n.id,
509 message: `Service requires port env variable ${cv.name}`,
510 };
511 }
512 }
513 if (cv.category === "EnvironmentVariable") {
514 if (
515 !(n.data.envVars || []).some(
516 (p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
517 )
518 ) {
519 if (cv.semanticType === "FILESYSTEM_PATH") {
520 return {
521 id: `${n.id}-missing-env-${cv.name}`,
522 type: "FATAL",
523 nodeId: n.id,
524 message: `Service requires env variable ${cv.name}, representing filesystem path`,
525 };
526 } else if (cv.semanticType === "POSTGRES_URL") {
527 return {
528 id: `${n.id}-missing-env-${cv.name}`,
529 type: "FATAL",
530 nodeId: n.id,
531 message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
532 };
533 }
534 }
535 }
536 return undefined;
537 })
538 .filter((m) => m !== undefined);
539 });
540}
541
gio5f2f1002025-03-20 18:38:48 +0400542function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000543 const ing = nodes.filter((n) => n.type === "gateway-https");
544 const noNetwork: Message[] = ing
545 .filter(
546 (n) =>
547 n.data == null ||
548 n.data.network == null ||
549 n.data.network == "" ||
550 n.data.subdomain == null ||
551 n.data.subdomain == "",
552 )
553 .map(
554 (n): Message => ({
555 id: `${n.id}-no-network`,
556 type: "FATAL",
557 nodeId: n.id,
558 message: "Network and subdomain must be defined",
559 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
560 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
561 }),
562 );
563 const notConnected: Message[] = ing
564 .filter(
565 (n) =>
566 n.data == null ||
567 n.data.https == null ||
568 n.data.https.serviceId == null ||
569 n.data.https.serviceId == "" ||
570 n.data.https.portId == null ||
571 n.data.https.portId == "",
572 )
573 .map((n) => ({
574 id: `${n.id}-not-connected`,
575 type: "FATAL",
576 nodeId: n.id,
577 message: "Connect to a service port",
578 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
579 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
580 }));
581 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400582}
583
584function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000585 const ing = nodes.filter((n) => n.type === "gateway-tcp");
586 const noNetwork: Message[] = ing
587 .filter(
588 (n) =>
589 n.data == null ||
590 n.data.network == null ||
591 n.data.network == "" ||
592 n.data.subdomain == null ||
593 n.data.subdomain == "",
594 )
595 .map(
596 (n): Message => ({
597 id: `${n.id}-no-network`,
598 type: "FATAL",
599 nodeId: n.id,
600 message: "Network and subdomain must be defined",
601 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
602 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
603 }),
604 );
605 const notConnected: Message[] = ing
606 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
607 .map((n) => ({
608 id: `${n.id}-not-connected`,
609 type: "FATAL",
610 nodeId: n.id,
611 message: "Connect to a service port",
612 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
613 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
614 }));
615 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400616}