blob: 187f51c554953e7060c2e7388b3ed647ff62809e [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[] {
gio5cf364c2025-05-08 16:01:21 +0000278 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +0000279 return [];
280 }
281 return [
282 {
283 id: "no-nodes",
284 type: "FATAL",
285 message: "Start by importing application source code",
286 onHighlight: (store) => store.setHighlightCategory("repository", true),
287 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
288 },
289 ];
gio5f2f1002025-03-20 18:38:48 +0400290}
291
292function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000293 const git = nodes.filter((n) => n.type === "github");
294 const noAddress: Message[] = git
295 .filter((n) => n.data == null || n.data.repository == null)
296 .map(
297 (n) =>
298 ({
299 id: `${n.id}-no-address`,
300 type: "FATAL",
301 nodeId: n.id,
302 message: "Configure repository address",
303 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
304 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
305 }) satisfies Message,
306 );
307 const noApp = git
308 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
309 .map(
310 (n) =>
311 ({
312 id: `${n.id}-no-app`,
313 type: "WARNING",
314 nodeId: n.id,
315 message: "Connect to service",
316 onHighlight: (store) => store.setHighlightCategory("Services", true),
317 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
318 }) satisfies Message,
319 );
320 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400321}
322
323function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000324 const apps = nodes.filter((n) => n.type === "app");
325 const noName = apps
326 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
327 .map(
328 (n): Message => ({
329 id: `${n.id}-no-name`,
330 type: "FATAL",
331 nodeId: n.id,
332 message: "Name the service",
333 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
334 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
335 onClick: (store) => {
336 store.updateNode(n.id, { selected: true });
337 store.updateNodeData<"app">(n.id, {
338 activeField: "name",
339 });
340 },
341 }),
342 );
343 const noSource = apps
344 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
345 .map(
346 (n): Message => ({
347 id: `${n.id}-no-repo`,
348 type: "FATAL",
349 nodeId: n.id,
350 message: "Connect to source repository",
351 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
352 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
353 }),
354 );
355 const noRuntime = apps
356 .filter((n) => n.data == null || n.data.type == null)
357 .map(
358 (n): Message => ({
359 id: `${n.id}-no-runtime`,
360 type: "FATAL",
361 nodeId: n.id,
362 message: "Choose runtime",
363 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
364 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
365 onClick: (store) => {
366 store.updateNode(n.id, { selected: true });
367 store.updateNodeData<"app">(n.id, {
368 activeField: "type",
369 });
370 },
371 }),
372 );
373 const noPorts = apps
374 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
375 .map(
376 (n): Message => ({
377 id: `${n.id}-no-ports`,
378 type: "INFO",
379 nodeId: n.id,
380 message: "Expose ports",
381 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
382 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
383 }),
384 );
385 const noIngress = apps.flatMap((n): Message[] => {
386 if (n.data == null) {
387 return [];
388 }
389 return (n.data.ports || [])
390 .filter(
391 (p) =>
392 !nodes
393 .filter((i) => i.type === "gateway-https")
394 .some((i) => {
395 if (
396 i.data &&
397 i.data.https &&
398 i.data.https.serviceId === n.id &&
399 i.data.https.portId === p.id
400 ) {
401 return true;
402 }
403 return false;
404 }),
405 )
406 .map(
407 (p): Message => ({
408 id: `${n.id}-${p.id}-no-ingress`,
409 type: "WARNING",
410 nodeId: n.id,
411 message: `Connect to ingress: ${p.name} - ${p.value}`,
412 onHighlight: (store) => {
413 store.updateNode(n.id, { selected: true });
414 store.setHighlightCategory("gateways", true);
415 },
416 onLooseHighlight: (store) => {
417 store.updateNode(n.id, { selected: false });
418 store.setHighlightCategory("gateways", false);
419 },
420 }),
421 );
422 });
423 const multipleIngress = apps
424 .filter((n) => n.data != null && n.data.ports != null)
425 .flatMap((n) =>
426 n.data.ports.map((p): Message | undefined => {
427 const ing = nodes
428 .filter((i) => i.type === "gateway-https")
429 .filter(
430 (i) =>
431 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
432 );
433 if (ing.length < 2) {
434 return undefined;
435 }
436 return {
437 id: `${n.id}-${p.id}-multiple-ingress`,
438 type: "FATAL",
439 nodeId: n.id,
440 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
441 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
442 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
443 };
444 }),
445 )
446 .filter((m) => m !== undefined);
447 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400448}
449
450function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000451 const ing = nodes.filter((n) => n.type === "gateway-https");
452 const noNetwork: Message[] = ing
453 .filter(
454 (n) =>
455 n.data == null ||
456 n.data.network == null ||
457 n.data.network == "" ||
458 n.data.subdomain == null ||
459 n.data.subdomain == "",
460 )
461 .map(
462 (n): Message => ({
463 id: `${n.id}-no-network`,
464 type: "FATAL",
465 nodeId: n.id,
466 message: "Network and subdomain must be defined",
467 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
468 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
469 }),
470 );
471 const notConnected: Message[] = ing
472 .filter(
473 (n) =>
474 n.data == null ||
475 n.data.https == null ||
476 n.data.https.serviceId == null ||
477 n.data.https.serviceId == "" ||
478 n.data.https.portId == null ||
479 n.data.https.portId == "",
480 )
481 .map((n) => ({
482 id: `${n.id}-not-connected`,
483 type: "FATAL",
484 nodeId: n.id,
485 message: "Connect to a service port",
486 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
487 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
488 }));
489 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400490}
491
492function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000493 const ing = nodes.filter((n) => n.type === "gateway-tcp");
494 const noNetwork: Message[] = ing
495 .filter(
496 (n) =>
497 n.data == null ||
498 n.data.network == null ||
499 n.data.network == "" ||
500 n.data.subdomain == null ||
501 n.data.subdomain == "",
502 )
503 .map(
504 (n): Message => ({
505 id: `${n.id}-no-network`,
506 type: "FATAL",
507 nodeId: n.id,
508 message: "Network and subdomain must be defined",
509 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
510 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
511 }),
512 );
513 const notConnected: Message[] = ing
514 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
515 .map((n) => ({
516 id: `${n.id}-not-connected`,
517 type: "FATAL",
518 nodeId: n.id,
519 message: "Connect to a service port",
520 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
521 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
522 }));
523 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400524}