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