blob: ea7f8928a61230ca3d80a4b7c2896d8ec2cf4441 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
2
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 }[];
gio5f2f1002025-03-20 18:38:48 +040060};
61
62export type Volume = {
giod0026612025-05-08 13:00:36 +000063 name: string;
64 accessMode: VolumeType;
65 size: string;
gio5f2f1002025-03-20 18:38:48 +040066};
67
68export type PostgreSQL = {
giod0026612025-05-08 13:00:36 +000069 name: string;
70 size: string;
71 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type MongoDB = {
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 Config = {
giod0026612025-05-08 13:00:36 +000081 service?: Service[];
82 volume?: Volume[];
83 postgresql?: PostgreSQL[];
84 mongodb?: MongoDB[];
gio5f2f1002025-03-20 18:38:48 +040085};
86
87export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
giod0026612025-05-08 13:00:36 +000088 try {
89 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
90 const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
91 const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
92 const findExpose = (n: AppNode): PortDomain[] => {
93 return n.data.ports
94 .map((p) => [n.id, p.id, p.name])
95 .flatMap((sp) => {
96 return tcpNodes.flatMap((i) =>
97 (i.data.exposed || [])
98 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
99 .map(() => ({
100 network: networkMap.get(i.data.network!)!,
101 subdomain: i.data.subdomain!,
102 port: { name: sp[2] },
103 })),
104 );
105 });
106 };
107 return {
108 service: nodes
109 .filter((n) => n.type === "app")
110 .map((n): Service => {
111 return {
112 type: n.data.type,
113 name: n.data.label,
114 source: {
115 repository: nodes
116 .filter((i) => i.type === "github")
117 .find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
118 branch: n.data.repository.branch,
119 rootDir: n.data.repository.rootDir,
120 },
121 ports: (n.data.ports || []).map((p) => ({
122 name: p.name,
123 value: p.value,
124 protocol: "TCP", // TODO(gio)
125 })),
126 env: (n.data.envVars || [])
127 .filter((e) => "name" in e)
128 .map((e) => ({
129 name: e.name,
130 alias: "alias" in e ? e.alias : undefined,
131 })),
132 ingress: ingressNodes
133 .filter((i) => i.data.https!.serviceId === n.id)
134 .map(
135 (i: GatewayHttpsNode): Ingress => ({
136 network: networkMap.get(i.data.network!)!,
137 subdomain: i.data.subdomain!,
138 port: {
139 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
140 },
141 auth:
142 i.data.auth?.enabled || false
143 ? {
144 enabled: true,
145 groups: i.data.auth!.groups,
146 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
147 }
148 : {
149 enabled: false,
150 },
151 }),
152 ),
153 expose: findExpose(n),
154 preBuildCommands: n.data.preBuildCommands
155 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
156 : [],
157 };
158 }),
159 volume: nodes
160 .filter((n) => n.type === "volume")
161 .map(
162 (n): Volume => ({
163 name: n.data.label,
164 accessMode: n.data.type,
165 size: n.data.size,
166 }),
167 ),
168 postgresql: nodes
169 .filter((n) => n.type === "postgresql")
170 .map(
171 (n): PostgreSQL => ({
172 name: n.data.label,
173 size: "1Gi", // TODO(gio)
174 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
175 }),
176 ),
177 mongodb: nodes
178 .filter((n) => n.type === "mongodb")
179 .map(
180 (n): MongoDB => ({
181 name: n.data.label,
182 size: "1Gi", // TODO(gio)
183 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
184 }),
185 ),
186 };
187 } catch (e) {
188 console.log(e);
189 return null;
190 }
gio5f2f1002025-03-20 18:38:48 +0400191}
192
193export interface Validator {
giod0026612025-05-08 13:00:36 +0000194 (nodes: AppNode[]): Message[];
gio5f2f1002025-03-20 18:38:48 +0400195}
196
197function CombineValidators(...v: Validator[]): Validator {
giod0026612025-05-08 13:00:36 +0000198 return (n) => v.flatMap((v) => v(n));
gio5f2f1002025-03-20 18:38:48 +0400199}
200
201function MessageTypeToNumber(t: MessageType) {
giod0026612025-05-08 13:00:36 +0000202 switch (t) {
203 case "FATAL":
204 return 0;
205 case "WARNING":
206 return 1;
207 case "INFO":
208 return 2;
209 }
gio5f2f1002025-03-20 18:38:48 +0400210}
211
212function NodeTypeToNumber(t?: NodeType) {
giod0026612025-05-08 13:00:36 +0000213 switch (t) {
214 case "github":
215 return 0;
216 case "app":
217 return 1;
218 case "volume":
219 return 2;
220 case "postgresql":
221 return 3;
222 case "mongodb":
223 return 4;
224 case "gateway-https":
225 return 5;
226 case undefined:
227 return 100;
228 }
gio5f2f1002025-03-20 18:38:48 +0400229}
230
231function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +0000232 return (n) => {
233 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
234 return v(n).sort((a, b) => {
235 const at = MessageTypeToNumber(a.type);
236 const bt = MessageTypeToNumber(b.type);
237 if (a.nodeId === undefined && b.nodeId === undefined) {
238 if (at !== bt) {
239 return at - bt;
240 }
241 return a.id.localeCompare(b.id);
242 }
243 if (a.nodeId === undefined) {
244 return -1;
245 }
246 if (b.nodeId === undefined) {
247 return 1;
248 }
249 if (a.nodeId === b.nodeId) {
250 if (at !== bt) {
251 return at - bt;
252 }
253 return a.id.localeCompare(b.id);
254 }
255 const ant = nt.get(a.id)!;
256 const bnt = nt.get(b.id)!;
257 if (ant !== bnt) {
258 return ant - bnt;
259 }
260 return a.id.localeCompare(b.id);
261 });
262 };
gio5f2f1002025-03-20 18:38:48 +0400263}
264
265export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +0000266 return SortingValidator(
267 CombineValidators(
268 EmptyValidator,
269 GitRepositoryValidator,
270 ServiceValidator,
271 GatewayHTTPSValidator,
272 GatewayTCPValidator,
273 ),
274 );
gio5f2f1002025-03-20 18:38:48 +0400275}
276
277function EmptyValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000278 nodes = nodes.filter((n) => n.type !== "network");
279 if (nodes.length > 0) {
280 return [];
281 }
282 return [
283 {
284 id: "no-nodes",
285 type: "FATAL",
286 message: "Start by importing application source code",
287 onHighlight: (store) => store.setHighlightCategory("repository", true),
288 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
289 },
290 ];
gio5f2f1002025-03-20 18:38:48 +0400291}
292
293function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000294 const git = nodes.filter((n) => n.type === "github");
295 const noAddress: Message[] = git
296 .filter((n) => n.data == null || n.data.repository == null)
297 .map(
298 (n) =>
299 ({
300 id: `${n.id}-no-address`,
301 type: "FATAL",
302 nodeId: n.id,
303 message: "Configure repository address",
304 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
305 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
306 }) satisfies Message,
307 );
308 const noApp = git
309 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
310 .map(
311 (n) =>
312 ({
313 id: `${n.id}-no-app`,
314 type: "WARNING",
315 nodeId: n.id,
316 message: "Connect to service",
317 onHighlight: (store) => store.setHighlightCategory("Services", true),
318 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
319 }) satisfies Message,
320 );
321 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400322}
323
324function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000325 const apps = nodes.filter((n) => n.type === "app");
326 const noName = apps
327 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
328 .map(
329 (n): Message => ({
330 id: `${n.id}-no-name`,
331 type: "FATAL",
332 nodeId: n.id,
333 message: "Name the service",
334 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
335 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
336 onClick: (store) => {
337 store.updateNode(n.id, { selected: true });
338 store.updateNodeData<"app">(n.id, {
339 activeField: "name",
340 });
341 },
342 }),
343 );
344 const noSource = apps
345 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
346 .map(
347 (n): Message => ({
348 id: `${n.id}-no-repo`,
349 type: "FATAL",
350 nodeId: n.id,
351 message: "Connect to source repository",
352 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
353 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
354 }),
355 );
356 const noRuntime = apps
357 .filter((n) => n.data == null || n.data.type == null)
358 .map(
359 (n): Message => ({
360 id: `${n.id}-no-runtime`,
361 type: "FATAL",
362 nodeId: n.id,
363 message: "Choose runtime",
364 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
365 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
366 onClick: (store) => {
367 store.updateNode(n.id, { selected: true });
368 store.updateNodeData<"app">(n.id, {
369 activeField: "type",
370 });
371 },
372 }),
373 );
374 const noPorts = apps
375 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
376 .map(
377 (n): Message => ({
378 id: `${n.id}-no-ports`,
379 type: "INFO",
380 nodeId: n.id,
381 message: "Expose ports",
382 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
383 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
384 }),
385 );
386 const noIngress = apps.flatMap((n): Message[] => {
387 if (n.data == null) {
388 return [];
389 }
390 return (n.data.ports || [])
391 .filter(
392 (p) =>
393 !nodes
394 .filter((i) => i.type === "gateway-https")
395 .some((i) => {
396 if (
397 i.data &&
398 i.data.https &&
399 i.data.https.serviceId === n.id &&
400 i.data.https.portId === p.id
401 ) {
402 return true;
403 }
404 return false;
405 }),
406 )
407 .map(
408 (p): Message => ({
409 id: `${n.id}-${p.id}-no-ingress`,
410 type: "WARNING",
411 nodeId: n.id,
412 message: `Connect to ingress: ${p.name} - ${p.value}`,
413 onHighlight: (store) => {
414 store.updateNode(n.id, { selected: true });
415 store.setHighlightCategory("gateways", true);
416 },
417 onLooseHighlight: (store) => {
418 store.updateNode(n.id, { selected: false });
419 store.setHighlightCategory("gateways", false);
420 },
421 }),
422 );
423 });
424 const multipleIngress = apps
425 .filter((n) => n.data != null && n.data.ports != null)
426 .flatMap((n) =>
427 n.data.ports.map((p): Message | undefined => {
428 const ing = nodes
429 .filter((i) => i.type === "gateway-https")
430 .filter(
431 (i) =>
432 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
433 );
434 if (ing.length < 2) {
435 return undefined;
436 }
437 return {
438 id: `${n.id}-${p.id}-multiple-ingress`,
439 type: "FATAL",
440 nodeId: n.id,
441 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
442 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
443 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
444 };
445 }),
446 )
447 .filter((m) => m !== undefined);
448 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400449}
450
451function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000452 const ing = nodes.filter((n) => n.type === "gateway-https");
453 const noNetwork: Message[] = ing
454 .filter(
455 (n) =>
456 n.data == null ||
457 n.data.network == null ||
458 n.data.network == "" ||
459 n.data.subdomain == null ||
460 n.data.subdomain == "",
461 )
462 .map(
463 (n): Message => ({
464 id: `${n.id}-no-network`,
465 type: "FATAL",
466 nodeId: n.id,
467 message: "Network and subdomain must be defined",
468 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
469 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
470 }),
471 );
472 const notConnected: Message[] = ing
473 .filter(
474 (n) =>
475 n.data == null ||
476 n.data.https == null ||
477 n.data.https.serviceId == null ||
478 n.data.https.serviceId == "" ||
479 n.data.https.portId == null ||
480 n.data.https.portId == "",
481 )
482 .map((n) => ({
483 id: `${n.id}-not-connected`,
484 type: "FATAL",
485 nodeId: n.id,
486 message: "Connect to a service port",
487 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
488 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
489 }));
490 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400491}
492
493function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000494 const ing = nodes.filter((n) => n.type === "gateway-tcp");
495 const noNetwork: Message[] = ing
496 .filter(
497 (n) =>
498 n.data == null ||
499 n.data.network == null ||
500 n.data.network == "" ||
501 n.data.subdomain == null ||
502 n.data.subdomain == "",
503 )
504 .map(
505 (n): Message => ({
506 id: `${n.id}-no-network`,
507 type: "FATAL",
508 nodeId: n.id,
509 message: "Network and subdomain must be defined",
510 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
511 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
512 }),
513 );
514 const notConnected: Message[] = ing
515 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
516 .map((n) => ({
517 id: `${n.id}-not-connected`,
518 type: "FATAL",
519 nodeId: n.id,
520 message: "Connect to a service port",
521 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
522 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
523 }));
524 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400525}