blob: dc3b1224c09a8aee8f9f0290aae36652c4721795 [file] [log] [blame]
gioc31bf142025-06-16 07:48:20 +00001import {
2 AppNode,
3 BoundEnvVar,
4 Env,
5 GatewayHttpsNode,
6 GatewayTCPNode,
gio9b7421a2025-06-18 12:31:13 +00007 GithubNode,
gioc31bf142025-06-16 07:48:20 +00008 MongoDBNode,
9 Network,
10 NetworkNode,
11 Port,
12 PostgreSQLNode,
13 ServiceNode,
14 VolumeNode,
15} from "./graph.js";
16import { Edge } from "@xyflow/react";
17import { v4 as uuidv4 } from "uuid";
18import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
gio9b7421a2025-06-18 12:31:13 +000019import { GithubRepository } from "./github.js";
gioc31bf142025-06-16 07:48:20 +000020
21export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
22 try {
23 if (appId == null || env.managerAddr == null) {
24 return null;
25 }
26 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
27 const ingressNodes = nodes
28 .filter((n) => n.type === "gateway-https")
29 .filter((n) => n.data.https !== undefined && !n.data.readonly);
30 const tcpNodes = nodes
31 .filter((n) => n.type === "gateway-tcp")
32 .filter((n) => n.data.exposed !== undefined && !n.data.readonly);
33 const findExpose = (n: AppNode): PortDomain[] => {
34 return n.data.ports
35 .map((p) => [n.id, p.id, p.name])
36 .flatMap((sp) => {
37 return tcpNodes.flatMap((i) =>
38 (i.data.exposed || [])
39 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
40 .map(() => ({
41 nodeId: i.id,
42 network: networkMap.get(i.data.network!)!,
43 subdomain: i.data.subdomain!,
44 port: { name: sp[2] },
45 })),
46 );
47 });
48 };
49 return {
50 input: {
51 appId: appId,
52 managerAddr: env.managerAddr,
53 },
54 service: nodes
55 .filter((n) => n.type === "app")
56 .map((n): Service => {
57 return {
58 nodeId: n.id,
59 type: n.data.type,
60 name: n.data.label,
61 source: {
62 repository: nodes
63 .filter((i) => i.type === "github")
64 .find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
65 branch:
66 n.data.repository != undefined && "branch" in n.data.repository
67 ? n.data.repository.branch
68 : "main",
69 rootDir:
70 n.data.repository != undefined && "rootDir" in n.data.repository
71 ? n.data.repository.rootDir
72 : "/",
73 },
74 ports: (n.data.ports || [])
75 .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
76 .map((p) => ({
77 name: p.name.toLowerCase(),
78 value: p.value,
79 protocol: "TCP", // TODO(gio)
80 })),
81 env: (n.data.envVars || [])
82 .filter((e) => "name" in e)
83 .map((e) => ({
84 name: e.name,
85 alias: "alias" in e ? e.alias : undefined,
86 })),
87 ingress: ingressNodes
88 .filter((i) => i.data.https!.serviceId === n.id)
89 .map(
90 (i): Ingress => ({
91 nodeId: i.id,
92 network: networkMap.get(i.data.network!)!,
93 subdomain: i.data.subdomain!,
94 port: {
95 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
96 },
97 auth:
98 i.data.auth?.enabled || false
99 ? {
100 enabled: true,
101 groups: i.data.auth!.groups,
102 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
103 }
104 : {
105 enabled: false,
106 },
107 }),
108 ),
109 expose: findExpose(n),
110 preBuildCommands: n.data.preBuildCommands
111 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
112 : [],
113 dev: {
114 enabled: n.data.dev ? n.data.dev.enabled : false,
115 username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
116 codeServer:
117 n.data.dev?.enabled && n.data.dev.expose != null
118 ? {
119 network: networkMap.get(n.data.dev.expose.network)!,
120 subdomain: n.data.dev.expose.subdomain,
121 }
122 : undefined,
123 ssh:
124 n.data.dev?.enabled && n.data.dev.expose != null
125 ? {
126 network: networkMap.get(n.data.dev.expose.network)!,
127 subdomain: n.data.dev.expose.subdomain,
128 }
129 : undefined,
130 },
131 };
132 }),
133 volume: nodes
134 .filter((n) => n.type === "volume")
135 .map(
136 (n): Volume => ({
137 nodeId: n.id,
138 name: n.data.label,
139 accessMode: n.data.type,
140 size: n.data.size,
141 }),
142 ),
143 postgresql: nodes
144 .filter((n) => n.type === "postgresql")
145 .map(
146 (n): PostgreSQL => ({
147 nodeId: n.id,
148 name: n.data.label,
149 size: "1Gi", // TODO(gio)
150 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
151 }),
152 ),
153 mongodb: nodes
154 .filter((n) => n.type === "mongodb")
155 .map(
156 (n): MongoDB => ({
157 nodeId: n.id,
158 name: n.data.label,
159 size: "1Gi", // TODO(gio)
160 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
161 }),
162 ),
163 };
164 } catch (e) {
165 console.log(e);
166 return { input: { appId: "qweqwe", managerAddr: "" } };
167 }
168}
169
170export type Graph = {
171 nodes: AppNode[];
172 edges: Edge[];
173};
174
gio9b7421a2025-06-18 12:31:13 +0000175export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000176 if (current == null) {
177 current = { nodes: [], edges: [] };
178 }
179 const ret: Graph = {
180 nodes: [],
181 edges: [],
182 };
183 if (networks.length === 0) {
184 return ret;
185 }
gio9b7421a2025-06-18 12:31:13 +0000186 const repoNodes = (config.service || [])
187 .filter((s) => s.source.repository != null)
188 .map((s): GithubNode | null => {
189 const existing = current.nodes.find(
190 (n) => n.type === "github" && n.data.repository?.sshURL === s.source.repository,
191 );
192 const repo = repos.find((r) => r.ssh_url === s.source.repository);
193 if (repo == null) {
194 return null;
195 }
196 return {
197 id: existing != null ? existing.id : uuidv4(),
198 type: "github",
199 data: {
200 label: repo.full_name,
201 repository: {
202 id: repo.id,
203 sshURL: repo.ssh_url,
204 fullName: repo.full_name,
205 },
206 envVars: [],
207 ports: [],
208 },
209 position:
210 existing != null
211 ? existing.position
212 : {
213 x: 0,
214 y: 0,
215 },
216 };
217 })
218 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000219 const networkNodes = networks.map((n): NetworkNode => {
220 let existing: NetworkNode | undefined = undefined;
221 existing = current.nodes
222 .filter((i): i is NetworkNode => i.type === "network")
223 .find((i) => i.data.domain === n.domain);
224 return {
225 id: n.domain,
226 type: "network",
227 data: {
228 label: n.name,
229 domain: n.domain,
230 envVars: [],
231 ports: [],
232 },
233 position: existing != null ? existing.position : { x: 0, y: 0 },
234 };
235 });
236 const services = config.service?.map((s): ServiceNode => {
237 let existing: ServiceNode | null = null;
238 if (s.nodeId !== undefined) {
239 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
240 }
241 return {
242 id: existing != null ? existing.id : uuidv4(),
243 type: "app",
244 data: {
245 label: s.name,
246 type: s.type,
247 env: [],
gio9b7421a2025-06-18 12:31:13 +0000248 repository: {
249 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.data.repository!.id,
250 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.id,
251 branch: s.source.branch,
252 rootDir: s.source.rootDir,
253 },
gioc31bf142025-06-16 07:48:20 +0000254 ports: (s.ports || []).map(
255 (p): Port => ({
256 id: uuidv4(),
257 name: p.name,
258 value: p.value,
259 }),
260 ),
261 envVars: (s.env || []).map((e): BoundEnvVar => {
262 if (e.alias != null) {
263 return {
264 id: uuidv4(),
265 name: e.name,
266 source: null,
267 alias: e.alias,
268 isEditting: false,
269 };
270 } else {
271 return {
272 id: uuidv4(),
273 name: e.name,
274 source: null,
275 isEditting: false,
276 };
277 }
278 }),
279 volume: s.volume || [],
280 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
281 // TODO(gio): dev
282 isChoosingPortToConnect: false,
283 },
284 // TODO(gio): generate position
285 position:
286 existing != null
287 ? existing.position
288 : {
289 x: 0,
290 y: 0,
291 },
292 };
293 });
294 const serviceGateways = config.service?.flatMap((s, index): GatewayHttpsNode[] => {
295 return (s.ingress || []).map((i): GatewayHttpsNode => {
296 let existing: GatewayHttpsNode | null = null;
297 if (i.nodeId !== undefined) {
298 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
299 }
300 console.log("!!!", i.network, networks);
301 return {
302 id: existing != null ? existing.id : uuidv4(),
303 type: "gateway-https",
304 data: {
305 label: i.subdomain,
306 envVars: [],
307 ports: [],
308 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
309 subdomain: i.subdomain,
310 https: {
311 serviceId: services![index]!.id,
312 portId: services![index]!.data.ports.find((p) => {
313 const port = i.port;
314 if ("name" in port) {
315 return p.name === port.name;
316 } else {
317 return `${p.value}` === port.value;
318 }
319 })!.id,
320 },
321 auth: i.auth.enabled
322 ? {
323 enabled: true,
324 groups: i.auth.groups || [],
325 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
326 }
327 : {
328 enabled: false,
329 groups: [],
330 noAuthPathPatterns: [],
331 },
332 },
333 position: {
334 x: 0,
335 y: 0,
336 },
337 };
338 });
339 });
340 const exposures = new Map<string, GatewayTCPNode>();
341 config.service
342 ?.flatMap((s, index): GatewayTCPNode[] => {
343 return (s.expose || []).map((e): GatewayTCPNode => {
344 let existing: GatewayTCPNode | null = null;
345 if (e.nodeId !== undefined) {
346 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
347 }
348 return {
349 id: existing != null ? existing.id : uuidv4(),
350 type: "gateway-tcp",
351 data: {
352 label: e.subdomain,
353 envVars: [],
354 ports: [],
355 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
356 subdomain: e.subdomain,
357 exposed: [
358 {
359 serviceId: services![index]!.id,
360 portId: services![index]!.data.ports.find((p) => {
361 const port = e.port;
362 if ("name" in port) {
363 return p.name === port.name;
364 } else {
365 return p.value === port.value;
366 }
367 })!.id,
368 },
369 ],
370 },
371 position: existing != null ? existing.position : { x: 0, y: 0 },
372 };
373 });
374 })
375 .forEach((n) => {
376 const key = `${n.data.network}-${n.data.subdomain}`;
377 if (!exposures.has(key)) {
378 exposures.set(key, n);
379 } else {
380 exposures.get(key)!.data.exposed.push(...n.data.exposed);
381 }
382 });
383 const volumes = config.volume?.map((v): VolumeNode => {
384 let existing: VolumeNode | null = null;
385 if (v.nodeId !== undefined) {
386 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
387 }
388 return {
389 id: existing != null ? existing.id : uuidv4(),
390 type: "volume",
391 data: {
392 label: v.name,
393 type: v.accessMode,
394 size: v.size,
395 attachedTo: [],
396 envVars: [],
397 ports: [],
398 },
399 position:
400 existing != null
401 ? existing.position
402 : {
403 x: 0,
404 y: 0,
405 },
406 };
407 });
408 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
409 let existing: PostgreSQLNode | null = null;
410 if (p.nodeId !== undefined) {
411 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
412 }
413 return {
414 id: existing != null ? existing.id : uuidv4(),
415 type: "postgresql",
416 data: {
417 label: p.name,
418 volumeId: "", // TODO(gio): volume
419 envVars: [],
420 ports: [
421 {
422 id: "connection",
423 name: "connection",
424 value: 5432,
425 },
426 ],
427 },
428 position:
429 existing != null
430 ? existing.position
431 : {
432 x: 0,
433 y: 0,
434 },
435 };
436 });
437 config.postgresql
438 ?.flatMap((p, index): GatewayTCPNode[] => {
439 return (p.expose || []).map((e): GatewayTCPNode => {
440 let existing: GatewayTCPNode | null = null;
441 if (e.nodeId !== undefined) {
442 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
443 }
444 return {
445 id: existing != null ? existing.id : uuidv4(),
446 type: "gateway-tcp",
447 data: {
448 label: e.subdomain,
449 envVars: [],
450 ports: [],
451 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
452 subdomain: e.subdomain,
453 exposed: [
454 {
455 serviceId: postgresql![index]!.id,
456 portId: "connection",
457 },
458 ],
459 },
460 position: existing != null ? existing.position : { x: 0, y: 0 },
461 };
462 });
463 })
464 .forEach((n) => {
465 const key = `${n.data.network}-${n.data.subdomain}`;
466 if (!exposures.has(key)) {
467 exposures.set(key, n);
468 } else {
469 exposures.get(key)!.data.exposed.push(...n.data.exposed);
470 }
471 });
472 const mongodb = config.mongodb?.map((m): MongoDBNode => {
473 let existing: MongoDBNode | null = null;
474 if (m.nodeId !== undefined) {
475 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
476 }
477 return {
478 id: existing != null ? existing.id : uuidv4(),
479 type: "mongodb",
480 data: {
481 label: m.name,
482 volumeId: "", // TODO(gio): volume
483 envVars: [],
484 ports: [
485 {
486 id: "connection",
487 name: "connection",
488 value: 27017,
489 },
490 ],
491 },
492 position:
493 existing != null
494 ? existing.position
495 : {
496 x: 0,
497 y: 0,
498 },
499 };
500 });
501 config.mongodb
502 ?.flatMap((p, index): GatewayTCPNode[] => {
503 return (p.expose || []).map((e): GatewayTCPNode => {
504 let existing: GatewayTCPNode | null = null;
505 if (e.nodeId !== undefined) {
506 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
507 }
508 return {
509 id: existing != null ? existing.id : uuidv4(),
510 type: "gateway-tcp",
511 data: {
512 label: e.subdomain,
513 envVars: [],
514 ports: [],
515 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
516 subdomain: e.subdomain,
517 exposed: [
518 {
519 serviceId: mongodb![index]!.id,
520 portId: "connection",
521 },
522 ],
523 },
524 position: existing != null ? existing.position : { x: 0, y: 0 },
525 };
526 });
527 })
528 .forEach((n) => {
529 const key = `${n.data.network}-${n.data.subdomain}`;
530 if (!exposures.has(key)) {
531 exposures.set(key, n);
532 } else {
533 exposures.get(key)!.data.exposed.push(...n.data.exposed);
534 }
535 });
536 ret.nodes = [
537 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000538 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000539 ...(services || []),
540 ...(serviceGateways || []),
541 ...(volumes || []),
542 ...(postgresql || []),
543 ...(mongodb || []),
544 ...(exposures.values() || []),
545 ];
546 services?.forEach((s) => {
547 s.data.envVars.forEach((e) => {
548 if (!("name" in e)) {
549 return;
550 }
551 if (!e.name.startsWith("DODO_")) {
552 return;
553 }
554 let r: {
555 type: string;
556 name: string;
557 } | null = null;
558 if (e.name.startsWith("DODO_PORT_")) {
559 return;
560 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
561 r = {
562 type: "postgresql",
563 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
564 };
565 } else if (e.name.startsWith("DODO_MONGODB_")) {
566 r = {
567 type: "mongodb",
568 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
569 };
570 } else if (e.name.startsWith("DODO_VOLUME_")) {
571 r = {
572 type: "volume",
573 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
574 };
575 }
576 if (r != null) {
577 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
578 }
579 });
580 });
581 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
582 return n.data.envVars.flatMap((e): Edge[] => {
583 if (e.source == null) {
584 return [];
585 }
586 const sn = ret.nodes.find((n) => n.id === e.source!)!;
587 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
588 return [
589 {
590 id: uuidv4(),
591 source: e.source!,
592 sourceHandle: sourceHandle,
593 target: n.id,
594 targetHandle: "env_var",
595 },
596 ];
597 });
598 });
599 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
600 return n.data.exposed.flatMap((e): Edge[] => {
601 return [
602 {
603 id: uuidv4(),
604 source: e.serviceId,
605 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
606 target: n.id,
607 targetHandle: "tcp",
608 },
609 {
610 id: uuidv4(),
611 source: n.id,
612 sourceHandle: "subdomain",
613 target: n.data.network!,
614 targetHandle: "subdomain",
615 },
616 ];
617 });
618 });
619 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
620 return [
621 {
622 id: uuidv4(),
623 source: n.data.https!.serviceId,
624 sourceHandle: "ports",
625 target: n.id,
626 targetHandle: "https",
627 },
628 {
629 id: uuidv4(),
630 source: n.id,
631 sourceHandle: "subdomain",
632 target: n.data.network!,
633 targetHandle: "subdomain",
634 },
635 ];
636 });
gio9b7421a2025-06-18 12:31:13 +0000637 const repoEdges = (services || []).map((s): Edge => {
638 return {
639 id: uuidv4(),
640 source: s.data.repository!.repoNodeId!,
641 sourceHandle: "repository",
642 target: s.id,
643 targetHandle: "repository",
644 };
645 });
646 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000647 return ret;
648}