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