blob: 9568c7e56dd724139e5dd9fda2781dda348f04c2 [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 },
gio69ff7592025-07-03 06:27:21 +0000130 ...(n.data.model?.name === "gemini" && {
131 model: {
132 name: "gemini",
133 geminiApiKey: n.data.model.apiKey,
134 },
135 }),
136 ...(n.data.model?.name === "claude" && {
137 model: {
138 name: "claude",
139 anthropicApiKey: n.data.model.apiKey,
140 },
141 }),
gio69148322025-06-19 23:16:12 +0400142 };
143 });
gioc31bf142025-06-16 07:48:20 +0000144 return {
gio69148322025-06-19 23:16:12 +0400145 service: services.filter((s) => !isAgent(s)),
146 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000147 volume: nodes
148 .filter((n) => n.type === "volume")
149 .map(
150 (n): Volume => ({
151 nodeId: n.id,
152 name: n.data.label,
153 accessMode: n.data.type,
154 size: n.data.size,
155 }),
156 ),
157 postgresql: nodes
158 .filter((n) => n.type === "postgresql")
159 .map(
160 (n): PostgreSQL => ({
161 nodeId: n.id,
162 name: n.data.label,
163 size: "1Gi", // TODO(gio)
164 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
165 }),
166 ),
167 mongodb: nodes
168 .filter((n) => n.type === "mongodb")
169 .map(
170 (n): MongoDB => ({
171 nodeId: n.id,
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 };
178 } catch (e) {
179 console.log(e);
gio69148322025-06-19 23:16:12 +0400180 return null;
gioc31bf142025-06-16 07:48:20 +0000181 }
182}
183
184export type Graph = {
185 nodes: AppNode[];
186 edges: Edge[];
187};
188
gio9b7421a2025-06-18 12:31:13 +0000189export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000190 if (current == null) {
191 current = { nodes: [], edges: [] };
192 }
193 const ret: Graph = {
194 nodes: [],
195 edges: [],
196 };
197 if (networks.length === 0) {
198 return ret;
199 }
gio69ff7592025-07-03 06:27:21 +0000200 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400201 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000202 .map((s): GithubNode | null => {
203 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400204 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000205 );
gio69148322025-06-19 23:16:12 +0400206 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000207 if (repo == null) {
208 return null;
209 }
210 return {
211 id: existing != null ? existing.id : uuidv4(),
212 type: "github",
213 data: {
214 label: repo.full_name,
215 repository: {
216 id: repo.id,
217 sshURL: repo.ssh_url,
218 fullName: repo.full_name,
219 },
220 envVars: [],
221 ports: [],
222 },
223 position:
224 existing != null
225 ? existing.position
226 : {
227 x: 0,
228 y: 0,
229 },
230 };
231 })
232 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000233 const networkNodes = networks.map((n): NetworkNode => {
234 let existing: NetworkNode | undefined = undefined;
235 existing = current.nodes
236 .filter((i): i is NetworkNode => i.type === "network")
237 .find((i) => i.data.domain === n.domain);
238 return {
239 id: n.domain,
240 type: "network",
241 data: {
242 label: n.name,
243 domain: n.domain,
244 envVars: [],
245 ports: [],
246 },
247 position: existing != null ? existing.position : { x: 0, y: 0 },
248 };
249 });
gio69148322025-06-19 23:16:12 +0400250 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000251 let existing: ServiceNode | null = null;
252 if (s.nodeId !== undefined) {
253 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
254 }
255 return {
256 id: existing != null ? existing.id : uuidv4(),
257 type: "app",
258 data: {
259 label: s.name,
260 type: s.type,
261 env: [],
gio69148322025-06-19 23:16:12 +0400262 repository:
263 s.source != null
264 ? {
265 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
266 .repository!.id,
267 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
268 .id,
269 branch: s.source!.branch,
270 rootDir: s.source!.rootDir,
271 }
272 : undefined,
gioc31bf142025-06-16 07:48:20 +0000273 ports: (s.ports || []).map(
274 (p): Port => ({
275 id: uuidv4(),
276 name: p.name,
277 value: p.value,
278 }),
279 ),
280 envVars: (s.env || []).map((e): BoundEnvVar => {
281 if (e.alias != null) {
282 return {
283 id: uuidv4(),
284 name: e.name,
285 source: null,
286 alias: e.alias,
287 isEditting: false,
288 };
289 } else {
290 return {
291 id: uuidv4(),
292 name: e.name,
293 source: null,
294 isEditting: false,
295 };
296 }
297 }),
298 volume: s.volume || [],
299 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000300 ...(s.model != null && {
301 model:
302 s.model.name === "gemini"
303 ? { name: "gemini", apiKey: s.model.geminiApiKey }
304 : { name: "claude", apiKey: s.model.anthropicApiKey },
305 }),
gioc31bf142025-06-16 07:48:20 +0000306 // TODO(gio): dev
307 isChoosingPortToConnect: false,
308 },
309 // TODO(gio): generate position
310 position:
311 existing != null
312 ? existing.position
313 : {
314 x: 0,
315 y: 0,
316 },
317 };
318 });
gio69ff7592025-07-03 06:27:21 +0000319 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
320 (s, index): GatewayHttpsNode[] => {
321 return (s.ingress || []).map((i): GatewayHttpsNode => {
322 let existing: GatewayHttpsNode | null = null;
323 if (i.nodeId !== undefined) {
324 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
325 }
326 return {
327 id: existing != null ? existing.id : uuidv4(),
328 type: "gateway-https",
329 data: {
330 label: i.subdomain,
331 envVars: [],
332 ports: [],
333 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
334 subdomain: i.subdomain,
335 https: {
336 serviceId: services![index]!.id,
337 portId: services![index]!.data.ports.find((p) => {
338 const port = i.port;
339 if ("name" in port) {
340 return p.name === port.name;
341 } else {
342 return `${p.value}` === port.value;
343 }
344 })!.id,
345 },
346 auth: i.auth.enabled
347 ? {
348 enabled: true,
349 groups: i.auth.groups || [],
350 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
351 }
352 : {
353 enabled: false,
354 groups: [],
355 noAuthPathPatterns: [],
356 },
gioc31bf142025-06-16 07:48:20 +0000357 },
gio69ff7592025-07-03 06:27:21 +0000358 position: {
359 x: 0,
360 y: 0,
361 },
362 };
363 });
364 },
365 );
gioc31bf142025-06-16 07:48:20 +0000366 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000367 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000368 ?.flatMap((s, index): GatewayTCPNode[] => {
369 return (s.expose || []).map((e): GatewayTCPNode => {
370 let existing: GatewayTCPNode | null = null;
371 if (e.nodeId !== undefined) {
372 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
373 }
374 return {
375 id: existing != null ? existing.id : uuidv4(),
376 type: "gateway-tcp",
377 data: {
378 label: e.subdomain,
379 envVars: [],
380 ports: [],
381 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
382 subdomain: e.subdomain,
383 exposed: [
384 {
385 serviceId: services![index]!.id,
386 portId: services![index]!.data.ports.find((p) => {
387 const port = e.port;
388 if ("name" in port) {
389 return p.name === port.name;
390 } else {
391 return p.value === port.value;
392 }
393 })!.id,
394 },
395 ],
396 },
397 position: existing != null ? existing.position : { x: 0, y: 0 },
398 };
399 });
400 })
401 .forEach((n) => {
402 const key = `${n.data.network}-${n.data.subdomain}`;
403 if (!exposures.has(key)) {
404 exposures.set(key, n);
405 } else {
406 exposures.get(key)!.data.exposed.push(...n.data.exposed);
407 }
408 });
409 const volumes = config.volume?.map((v): VolumeNode => {
410 let existing: VolumeNode | null = null;
411 if (v.nodeId !== undefined) {
412 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
413 }
414 return {
415 id: existing != null ? existing.id : uuidv4(),
416 type: "volume",
417 data: {
418 label: v.name,
419 type: v.accessMode,
420 size: v.size,
421 attachedTo: [],
422 envVars: [],
423 ports: [],
424 },
425 position:
426 existing != null
427 ? existing.position
428 : {
429 x: 0,
430 y: 0,
431 },
432 };
433 });
434 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
435 let existing: PostgreSQLNode | null = null;
436 if (p.nodeId !== undefined) {
437 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
438 }
439 return {
440 id: existing != null ? existing.id : uuidv4(),
441 type: "postgresql",
442 data: {
443 label: p.name,
444 volumeId: "", // TODO(gio): volume
445 envVars: [],
446 ports: [
447 {
448 id: "connection",
449 name: "connection",
450 value: 5432,
451 },
452 ],
453 },
454 position:
455 existing != null
456 ? existing.position
457 : {
458 x: 0,
459 y: 0,
460 },
461 };
462 });
463 config.postgresql
464 ?.flatMap((p, index): GatewayTCPNode[] => {
465 return (p.expose || []).map((e): GatewayTCPNode => {
466 let existing: GatewayTCPNode | null = null;
467 if (e.nodeId !== undefined) {
468 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
469 }
470 return {
471 id: existing != null ? existing.id : uuidv4(),
472 type: "gateway-tcp",
473 data: {
474 label: e.subdomain,
475 envVars: [],
476 ports: [],
477 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
478 subdomain: e.subdomain,
479 exposed: [
480 {
481 serviceId: postgresql![index]!.id,
482 portId: "connection",
483 },
484 ],
485 },
486 position: existing != null ? existing.position : { x: 0, y: 0 },
487 };
488 });
489 })
490 .forEach((n) => {
491 const key = `${n.data.network}-${n.data.subdomain}`;
492 if (!exposures.has(key)) {
493 exposures.set(key, n);
494 } else {
495 exposures.get(key)!.data.exposed.push(...n.data.exposed);
496 }
497 });
498 const mongodb = config.mongodb?.map((m): MongoDBNode => {
499 let existing: MongoDBNode | null = null;
500 if (m.nodeId !== undefined) {
501 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
502 }
503 return {
504 id: existing != null ? existing.id : uuidv4(),
505 type: "mongodb",
506 data: {
507 label: m.name,
508 volumeId: "", // TODO(gio): volume
509 envVars: [],
510 ports: [
511 {
512 id: "connection",
513 name: "connection",
514 value: 27017,
515 },
516 ],
517 },
518 position:
519 existing != null
520 ? existing.position
521 : {
522 x: 0,
523 y: 0,
524 },
525 };
526 });
527 config.mongodb
528 ?.flatMap((p, index): GatewayTCPNode[] => {
529 return (p.expose || []).map((e): GatewayTCPNode => {
530 let existing: GatewayTCPNode | null = null;
531 if (e.nodeId !== undefined) {
532 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
533 }
534 return {
535 id: existing != null ? existing.id : uuidv4(),
536 type: "gateway-tcp",
537 data: {
538 label: e.subdomain,
539 envVars: [],
540 ports: [],
541 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
542 subdomain: e.subdomain,
543 exposed: [
544 {
545 serviceId: mongodb![index]!.id,
546 portId: "connection",
547 },
548 ],
549 },
550 position: existing != null ? existing.position : { x: 0, y: 0 },
551 };
552 });
553 })
554 .forEach((n) => {
555 const key = `${n.data.network}-${n.data.subdomain}`;
556 if (!exposures.has(key)) {
557 exposures.set(key, n);
558 } else {
559 exposures.get(key)!.data.exposed.push(...n.data.exposed);
560 }
561 });
562 ret.nodes = [
563 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000564 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000565 ...(services || []),
566 ...(serviceGateways || []),
567 ...(volumes || []),
568 ...(postgresql || []),
569 ...(mongodb || []),
570 ...(exposures.values() || []),
571 ];
572 services?.forEach((s) => {
573 s.data.envVars.forEach((e) => {
574 if (!("name" in e)) {
575 return;
576 }
577 if (!e.name.startsWith("DODO_")) {
578 return;
579 }
580 let r: {
581 type: string;
582 name: string;
583 } | null = null;
584 if (e.name.startsWith("DODO_PORT_")) {
585 return;
586 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
587 r = {
588 type: "postgresql",
589 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
590 };
591 } else if (e.name.startsWith("DODO_MONGODB_")) {
592 r = {
593 type: "mongodb",
594 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
595 };
596 } else if (e.name.startsWith("DODO_VOLUME_")) {
597 r = {
598 type: "volume",
599 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
600 };
601 }
602 if (r != null) {
603 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
604 }
605 });
606 });
607 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
608 return n.data.envVars.flatMap((e): Edge[] => {
609 if (e.source == null) {
610 return [];
611 }
612 const sn = ret.nodes.find((n) => n.id === e.source!)!;
613 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
614 return [
615 {
616 id: uuidv4(),
617 source: e.source!,
618 sourceHandle: sourceHandle,
619 target: n.id,
620 targetHandle: "env_var",
621 },
622 ];
623 });
624 });
625 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
626 return n.data.exposed.flatMap((e): Edge[] => {
627 return [
628 {
629 id: uuidv4(),
630 source: e.serviceId,
631 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
632 target: n.id,
633 targetHandle: "tcp",
634 },
635 {
636 id: uuidv4(),
637 source: n.id,
638 sourceHandle: "subdomain",
639 target: n.data.network!,
640 targetHandle: "subdomain",
641 },
642 ];
643 });
644 });
645 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
646 return [
647 {
648 id: uuidv4(),
649 source: n.data.https!.serviceId,
650 sourceHandle: "ports",
651 target: n.id,
652 targetHandle: "https",
653 },
654 {
655 id: uuidv4(),
656 source: n.id,
657 sourceHandle: "subdomain",
658 target: n.data.network!,
659 targetHandle: "subdomain",
660 },
661 ];
662 });
gio69ff7592025-07-03 06:27:21 +0000663 const repoEdges = (services || [])
664 .map((s): Edge | null => {
665 if (s.data.repository == null) {
666 return null;
667 }
668 return {
669 id: uuidv4(),
670 source: s.data.repository!.repoNodeId!,
671 sourceHandle: "repository",
672 target: s.id,
673 targetHandle: "repository",
674 };
675 })
676 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000677 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000678 return ret;
679}