blob: b0aa7446294821155de53965c90b3b77603cd674 [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)
gio1dacf1c2025-07-03 16:39:04 +000078 .map((e) => {
79 if ("value" in e) {
80 return { name: e.name, value: e.value };
81 }
82 return {
83 name: e.name,
84 alias: "alias" in e ? e.alias : undefined,
85 };
86 }),
gio69148322025-06-19 23:16:12 +040087 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: {
gio67d6d5f2025-07-02 15:49:54 +000095 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name.toLowerCase(),
gio69148322025-06-19 23:16:12 +040096 },
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 : [],
gio2f393c12025-07-01 08:02:48 +0000113 dev: n.data.dev?.enabled
gio69148322025-06-19 23:16:12 +0400114 ? {
gio2f393c12025-07-01 08:02:48 +0000115 enabled: true,
116 username: env.user.username,
117 codeServer:
118 n.data.dev.expose != null
119 ? {
120 network: networkMap.get(n.data.dev.expose.network)!,
121 subdomain: n.data.dev.expose.subdomain,
122 }
123 : undefined,
124 ssh:
125 n.data.dev.expose != null
126 ? {
127 network: networkMap.get(n.data.dev.expose.network)!,
128 subdomain: n.data.dev.expose.subdomain,
129 }
130 : undefined,
gio69148322025-06-19 23:16:12 +0400131 }
gio2f393c12025-07-01 08:02:48 +0000132 : {
133 enabled: false,
134 },
gio69ff7592025-07-03 06:27:21 +0000135 ...(n.data.model?.name === "gemini" && {
136 model: {
137 name: "gemini",
138 geminiApiKey: n.data.model.apiKey,
139 },
140 }),
141 ...(n.data.model?.name === "claude" && {
142 model: {
143 name: "claude",
144 anthropicApiKey: n.data.model.apiKey,
145 },
146 }),
gio69148322025-06-19 23:16:12 +0400147 };
148 });
gioc31bf142025-06-16 07:48:20 +0000149 return {
gio69148322025-06-19 23:16:12 +0400150 service: services.filter((s) => !isAgent(s)),
151 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000152 volume: nodes
153 .filter((n) => n.type === "volume")
154 .map(
155 (n): Volume => ({
156 nodeId: n.id,
157 name: n.data.label,
158 accessMode: n.data.type,
159 size: n.data.size,
160 }),
161 ),
162 postgresql: nodes
163 .filter((n) => n.type === "postgresql")
164 .map(
165 (n): PostgreSQL => ({
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 mongodb: nodes
173 .filter((n) => n.type === "mongodb")
174 .map(
175 (n): MongoDB => ({
176 nodeId: n.id,
177 name: n.data.label,
178 size: "1Gi", // TODO(gio)
179 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
180 }),
181 ),
182 };
183 } catch (e) {
184 console.log(e);
gio69148322025-06-19 23:16:12 +0400185 return null;
gioc31bf142025-06-16 07:48:20 +0000186 }
187}
188
189export type Graph = {
190 nodes: AppNode[];
191 edges: Edge[];
192};
193
gio9b7421a2025-06-18 12:31:13 +0000194export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000195 if (current == null) {
196 current = { nodes: [], edges: [] };
197 }
198 const ret: Graph = {
199 nodes: [],
200 edges: [],
201 };
202 if (networks.length === 0) {
203 return ret;
204 }
gio69ff7592025-07-03 06:27:21 +0000205 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400206 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000207 .map((s): GithubNode | null => {
208 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400209 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000210 );
gio69148322025-06-19 23:16:12 +0400211 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000212 if (repo == null) {
213 return null;
214 }
215 return {
216 id: existing != null ? existing.id : uuidv4(),
217 type: "github",
218 data: {
219 label: repo.full_name,
220 repository: {
221 id: repo.id,
222 sshURL: repo.ssh_url,
223 fullName: repo.full_name,
224 },
225 envVars: [],
226 ports: [],
227 },
228 position:
229 existing != null
230 ? existing.position
231 : {
232 x: 0,
233 y: 0,
234 },
235 };
236 })
237 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000238 const networkNodes = networks.map((n): NetworkNode => {
239 let existing: NetworkNode | undefined = undefined;
240 existing = current.nodes
241 .filter((i): i is NetworkNode => i.type === "network")
242 .find((i) => i.data.domain === n.domain);
243 return {
244 id: n.domain,
245 type: "network",
246 data: {
247 label: n.name,
248 domain: n.domain,
249 envVars: [],
250 ports: [],
251 },
252 position: existing != null ? existing.position : { x: 0, y: 0 },
253 };
254 });
gio69148322025-06-19 23:16:12 +0400255 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000256 let existing: ServiceNode | null = null;
257 if (s.nodeId !== undefined) {
258 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
259 }
260 return {
261 id: existing != null ? existing.id : uuidv4(),
262 type: "app",
263 data: {
264 label: s.name,
265 type: s.type,
266 env: [],
gio69148322025-06-19 23:16:12 +0400267 repository:
268 s.source != null
269 ? {
270 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
271 .repository!.id,
272 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
273 .id,
274 branch: s.source!.branch,
275 rootDir: s.source!.rootDir,
276 }
277 : undefined,
gioc31bf142025-06-16 07:48:20 +0000278 ports: (s.ports || []).map(
279 (p): Port => ({
280 id: uuidv4(),
281 name: p.name,
282 value: p.value,
283 }),
284 ),
285 envVars: (s.env || []).map((e): BoundEnvVar => {
gio1dacf1c2025-07-03 16:39:04 +0000286 if (e.value != null) {
gioc31bf142025-06-16 07:48:20 +0000287 return {
288 id: uuidv4(),
gioc31bf142025-06-16 07:48:20 +0000289 source: null,
gio1dacf1c2025-07-03 16:39:04 +0000290 name: e.name,
291 value: e.value,
292 };
293 } else if (e.alias != null && e.alias !== "") {
294 return {
295 id: uuidv4(),
296 source: null,
297 name: e.name,
gioc31bf142025-06-16 07:48:20 +0000298 alias: e.alias,
299 isEditting: false,
300 };
301 } else {
302 return {
303 id: uuidv4(),
304 name: e.name,
305 source: null,
306 isEditting: false,
307 };
308 }
309 }),
310 volume: s.volume || [],
311 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000312 ...(s.model != null && {
313 model:
314 s.model.name === "gemini"
315 ? { name: "gemini", apiKey: s.model.geminiApiKey }
316 : { name: "claude", apiKey: s.model.anthropicApiKey },
317 }),
gioc31bf142025-06-16 07:48:20 +0000318 // TODO(gio): dev
319 isChoosingPortToConnect: false,
320 },
321 // TODO(gio): generate position
322 position:
323 existing != null
324 ? existing.position
325 : {
326 x: 0,
327 y: 0,
328 },
329 };
330 });
gio69ff7592025-07-03 06:27:21 +0000331 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
332 (s, index): GatewayHttpsNode[] => {
333 return (s.ingress || []).map((i): GatewayHttpsNode => {
334 let existing: GatewayHttpsNode | null = null;
335 if (i.nodeId !== undefined) {
336 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
337 }
338 return {
339 id: existing != null ? existing.id : uuidv4(),
340 type: "gateway-https",
341 data: {
342 label: i.subdomain,
343 envVars: [],
344 ports: [],
345 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
346 subdomain: i.subdomain,
347 https: {
348 serviceId: services![index]!.id,
349 portId: services![index]!.data.ports.find((p) => {
350 const port = i.port;
351 if ("name" in port) {
352 return p.name === port.name;
353 } else {
354 return `${p.value}` === port.value;
355 }
356 })!.id,
357 },
358 auth: i.auth.enabled
359 ? {
360 enabled: true,
361 groups: i.auth.groups || [],
362 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
363 }
364 : {
365 enabled: false,
366 groups: [],
367 noAuthPathPatterns: [],
368 },
gioc31bf142025-06-16 07:48:20 +0000369 },
gio69ff7592025-07-03 06:27:21 +0000370 position: {
371 x: 0,
372 y: 0,
373 },
374 };
375 });
376 },
377 );
gioc31bf142025-06-16 07:48:20 +0000378 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000379 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000380 ?.flatMap((s, index): GatewayTCPNode[] => {
381 return (s.expose || []).map((e): GatewayTCPNode => {
382 let existing: GatewayTCPNode | null = null;
383 if (e.nodeId !== undefined) {
384 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
385 }
386 return {
387 id: existing != null ? existing.id : uuidv4(),
388 type: "gateway-tcp",
389 data: {
390 label: e.subdomain,
391 envVars: [],
392 ports: [],
393 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
394 subdomain: e.subdomain,
395 exposed: [
396 {
397 serviceId: services![index]!.id,
398 portId: services![index]!.data.ports.find((p) => {
399 const port = e.port;
400 if ("name" in port) {
401 return p.name === port.name;
402 } else {
403 return p.value === port.value;
404 }
405 })!.id,
406 },
407 ],
408 },
409 position: existing != null ? existing.position : { x: 0, y: 0 },
410 };
411 });
412 })
413 .forEach((n) => {
414 const key = `${n.data.network}-${n.data.subdomain}`;
415 if (!exposures.has(key)) {
416 exposures.set(key, n);
417 } else {
418 exposures.get(key)!.data.exposed.push(...n.data.exposed);
419 }
420 });
421 const volumes = config.volume?.map((v): VolumeNode => {
422 let existing: VolumeNode | null = null;
423 if (v.nodeId !== undefined) {
424 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
425 }
426 return {
427 id: existing != null ? existing.id : uuidv4(),
428 type: "volume",
429 data: {
430 label: v.name,
431 type: v.accessMode,
432 size: v.size,
433 attachedTo: [],
434 envVars: [],
435 ports: [],
436 },
437 position:
438 existing != null
439 ? existing.position
440 : {
441 x: 0,
442 y: 0,
443 },
444 };
445 });
446 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
447 let existing: PostgreSQLNode | null = null;
448 if (p.nodeId !== undefined) {
449 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
450 }
451 return {
452 id: existing != null ? existing.id : uuidv4(),
453 type: "postgresql",
454 data: {
455 label: p.name,
456 volumeId: "", // TODO(gio): volume
457 envVars: [],
458 ports: [
459 {
460 id: "connection",
461 name: "connection",
462 value: 5432,
463 },
464 ],
465 },
466 position:
467 existing != null
468 ? existing.position
469 : {
470 x: 0,
471 y: 0,
472 },
473 };
474 });
475 config.postgresql
476 ?.flatMap((p, index): GatewayTCPNode[] => {
477 return (p.expose || []).map((e): GatewayTCPNode => {
478 let existing: GatewayTCPNode | null = null;
479 if (e.nodeId !== undefined) {
480 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
481 }
482 return {
483 id: existing != null ? existing.id : uuidv4(),
484 type: "gateway-tcp",
485 data: {
486 label: e.subdomain,
487 envVars: [],
488 ports: [],
489 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
490 subdomain: e.subdomain,
491 exposed: [
492 {
493 serviceId: postgresql![index]!.id,
494 portId: "connection",
495 },
496 ],
497 },
498 position: existing != null ? existing.position : { x: 0, y: 0 },
499 };
500 });
501 })
502 .forEach((n) => {
503 const key = `${n.data.network}-${n.data.subdomain}`;
504 if (!exposures.has(key)) {
505 exposures.set(key, n);
506 } else {
507 exposures.get(key)!.data.exposed.push(...n.data.exposed);
508 }
509 });
510 const mongodb = config.mongodb?.map((m): MongoDBNode => {
511 let existing: MongoDBNode | null = null;
512 if (m.nodeId !== undefined) {
513 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
514 }
515 return {
516 id: existing != null ? existing.id : uuidv4(),
517 type: "mongodb",
518 data: {
519 label: m.name,
520 volumeId: "", // TODO(gio): volume
521 envVars: [],
522 ports: [
523 {
524 id: "connection",
525 name: "connection",
526 value: 27017,
527 },
528 ],
529 },
530 position:
531 existing != null
532 ? existing.position
533 : {
534 x: 0,
535 y: 0,
536 },
537 };
538 });
539 config.mongodb
540 ?.flatMap((p, index): GatewayTCPNode[] => {
541 return (p.expose || []).map((e): GatewayTCPNode => {
542 let existing: GatewayTCPNode | null = null;
543 if (e.nodeId !== undefined) {
544 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
545 }
546 return {
547 id: existing != null ? existing.id : uuidv4(),
548 type: "gateway-tcp",
549 data: {
550 label: e.subdomain,
551 envVars: [],
552 ports: [],
553 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
554 subdomain: e.subdomain,
555 exposed: [
556 {
557 serviceId: mongodb![index]!.id,
558 portId: "connection",
559 },
560 ],
561 },
562 position: existing != null ? existing.position : { x: 0, y: 0 },
563 };
564 });
565 })
566 .forEach((n) => {
567 const key = `${n.data.network}-${n.data.subdomain}`;
568 if (!exposures.has(key)) {
569 exposures.set(key, n);
570 } else {
571 exposures.get(key)!.data.exposed.push(...n.data.exposed);
572 }
573 });
574 ret.nodes = [
575 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000576 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000577 ...(services || []),
578 ...(serviceGateways || []),
579 ...(volumes || []),
580 ...(postgresql || []),
581 ...(mongodb || []),
582 ...(exposures.values() || []),
583 ];
584 services?.forEach((s) => {
585 s.data.envVars.forEach((e) => {
586 if (!("name" in e)) {
587 return;
588 }
589 if (!e.name.startsWith("DODO_")) {
590 return;
591 }
592 let r: {
593 type: string;
594 name: string;
595 } | null = null;
596 if (e.name.startsWith("DODO_PORT_")) {
597 return;
598 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
599 r = {
600 type: "postgresql",
601 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
602 };
603 } else if (e.name.startsWith("DODO_MONGODB_")) {
604 r = {
605 type: "mongodb",
606 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
607 };
608 } else if (e.name.startsWith("DODO_VOLUME_")) {
609 r = {
610 type: "volume",
611 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
612 };
613 }
614 if (r != null) {
615 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
616 }
617 });
618 });
619 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
620 return n.data.envVars.flatMap((e): Edge[] => {
621 if (e.source == null) {
622 return [];
623 }
624 const sn = ret.nodes.find((n) => n.id === e.source!)!;
625 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
626 return [
627 {
628 id: uuidv4(),
629 source: e.source!,
630 sourceHandle: sourceHandle,
631 target: n.id,
632 targetHandle: "env_var",
633 },
634 ];
635 });
636 });
637 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
638 return n.data.exposed.flatMap((e): Edge[] => {
639 return [
640 {
641 id: uuidv4(),
642 source: e.serviceId,
643 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
644 target: n.id,
645 targetHandle: "tcp",
646 },
647 {
648 id: uuidv4(),
649 source: n.id,
650 sourceHandle: "subdomain",
651 target: n.data.network!,
652 targetHandle: "subdomain",
653 },
654 ];
655 });
656 });
657 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
658 return [
659 {
660 id: uuidv4(),
661 source: n.data.https!.serviceId,
662 sourceHandle: "ports",
663 target: n.id,
664 targetHandle: "https",
665 },
666 {
667 id: uuidv4(),
668 source: n.id,
669 sourceHandle: "subdomain",
670 target: n.data.network!,
671 targetHandle: "subdomain",
672 },
673 ];
674 });
gio69ff7592025-07-03 06:27:21 +0000675 const repoEdges = (services || [])
676 .map((s): Edge | null => {
677 if (s.data.repository == null) {
678 return null;
679 }
680 return {
681 id: uuidv4(),
682 source: s.data.repository!.repoNodeId!,
683 sourceHandle: "repository",
684 target: s.id,
685 targetHandle: "repository",
686 };
687 })
688 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000689 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000690 return ret;
691}