blob: dcc43189952392c239f7994437dee9d6582e876a [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[];
gio8a5f12f2025-07-05 07:02:31 +0000192 viewport?: { x: number; y: number; zoom: number };
gioc31bf142025-06-16 07:48:20 +0000193};
194
gio9b7421a2025-06-18 12:31:13 +0000195export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000196 if (current == null) {
197 current = { nodes: [], edges: [] };
198 }
199 const ret: Graph = {
200 nodes: [],
201 edges: [],
202 };
203 if (networks.length === 0) {
204 return ret;
205 }
gio69ff7592025-07-03 06:27:21 +0000206 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400207 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000208 .map((s): GithubNode | null => {
209 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400210 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000211 );
gio69148322025-06-19 23:16:12 +0400212 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000213 if (repo == null) {
214 return null;
215 }
216 return {
217 id: existing != null ? existing.id : uuidv4(),
218 type: "github",
219 data: {
220 label: repo.full_name,
221 repository: {
222 id: repo.id,
223 sshURL: repo.ssh_url,
224 fullName: repo.full_name,
225 },
226 envVars: [],
227 ports: [],
228 },
229 position:
230 existing != null
231 ? existing.position
232 : {
233 x: 0,
234 y: 0,
235 },
236 };
237 })
238 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000239 const networkNodes = networks.map((n): NetworkNode => {
240 let existing: NetworkNode | undefined = undefined;
241 existing = current.nodes
242 .filter((i): i is NetworkNode => i.type === "network")
243 .find((i) => i.data.domain === n.domain);
244 return {
245 id: n.domain,
246 type: "network",
247 data: {
248 label: n.name,
249 domain: n.domain,
250 envVars: [],
251 ports: [],
252 },
253 position: existing != null ? existing.position : { x: 0, y: 0 },
254 };
255 });
gio69148322025-06-19 23:16:12 +0400256 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000257 let existing: ServiceNode | null = null;
258 if (s.nodeId !== undefined) {
259 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
260 }
261 return {
262 id: existing != null ? existing.id : uuidv4(),
263 type: "app",
264 data: {
265 label: s.name,
266 type: s.type,
267 env: [],
gio69148322025-06-19 23:16:12 +0400268 repository:
269 s.source != null
270 ? {
271 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
272 .repository!.id,
273 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
274 .id,
275 branch: s.source!.branch,
276 rootDir: s.source!.rootDir,
277 }
278 : undefined,
gioc31bf142025-06-16 07:48:20 +0000279 ports: (s.ports || []).map(
280 (p): Port => ({
281 id: uuidv4(),
282 name: p.name,
283 value: p.value,
284 }),
285 ),
286 envVars: (s.env || []).map((e): BoundEnvVar => {
gio1dacf1c2025-07-03 16:39:04 +0000287 if (e.value != null) {
gioc31bf142025-06-16 07:48:20 +0000288 return {
289 id: uuidv4(),
gioc31bf142025-06-16 07:48:20 +0000290 source: null,
gio1dacf1c2025-07-03 16:39:04 +0000291 name: e.name,
292 value: e.value,
293 };
294 } else if (e.alias != null && e.alias !== "") {
295 return {
296 id: uuidv4(),
297 source: null,
298 name: e.name,
gioc31bf142025-06-16 07:48:20 +0000299 alias: e.alias,
300 isEditting: false,
301 };
302 } else {
303 return {
304 id: uuidv4(),
305 name: e.name,
306 source: null,
307 isEditting: false,
308 };
309 }
310 }),
311 volume: s.volume || [],
312 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000313 ...(s.model != null && {
314 model:
315 s.model.name === "gemini"
316 ? { name: "gemini", apiKey: s.model.geminiApiKey }
317 : { name: "claude", apiKey: s.model.anthropicApiKey },
318 }),
gioc31bf142025-06-16 07:48:20 +0000319 // TODO(gio): dev
320 isChoosingPortToConnect: false,
321 },
322 // TODO(gio): generate position
323 position:
324 existing != null
325 ? existing.position
326 : {
327 x: 0,
328 y: 0,
329 },
330 };
331 });
gio69ff7592025-07-03 06:27:21 +0000332 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
333 (s, index): GatewayHttpsNode[] => {
334 return (s.ingress || []).map((i): GatewayHttpsNode => {
335 let existing: GatewayHttpsNode | null = null;
336 if (i.nodeId !== undefined) {
337 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
338 }
339 return {
340 id: existing != null ? existing.id : uuidv4(),
341 type: "gateway-https",
342 data: {
343 label: i.subdomain,
344 envVars: [],
345 ports: [],
346 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
347 subdomain: i.subdomain,
348 https: {
349 serviceId: services![index]!.id,
350 portId: services![index]!.data.ports.find((p) => {
351 const port = i.port;
352 if ("name" in port) {
353 return p.name === port.name;
354 } else {
355 return `${p.value}` === port.value;
356 }
357 })!.id,
358 },
359 auth: i.auth.enabled
360 ? {
361 enabled: true,
362 groups: i.auth.groups || [],
363 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
364 }
365 : {
366 enabled: false,
367 groups: [],
368 noAuthPathPatterns: [],
369 },
gioc31bf142025-06-16 07:48:20 +0000370 },
gio69ff7592025-07-03 06:27:21 +0000371 position: {
372 x: 0,
373 y: 0,
374 },
375 };
376 });
377 },
378 );
gioc31bf142025-06-16 07:48:20 +0000379 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000380 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000381 ?.flatMap((s, index): GatewayTCPNode[] => {
382 return (s.expose || []).map((e): GatewayTCPNode => {
383 let existing: GatewayTCPNode | null = null;
384 if (e.nodeId !== undefined) {
385 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
386 }
387 return {
388 id: existing != null ? existing.id : uuidv4(),
389 type: "gateway-tcp",
390 data: {
391 label: e.subdomain,
392 envVars: [],
393 ports: [],
394 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
395 subdomain: e.subdomain,
396 exposed: [
397 {
398 serviceId: services![index]!.id,
399 portId: services![index]!.data.ports.find((p) => {
400 const port = e.port;
401 if ("name" in port) {
402 return p.name === port.name;
403 } else {
404 return p.value === port.value;
405 }
406 })!.id,
407 },
408 ],
409 },
410 position: existing != null ? existing.position : { x: 0, y: 0 },
411 };
412 });
413 })
414 .forEach((n) => {
415 const key = `${n.data.network}-${n.data.subdomain}`;
416 if (!exposures.has(key)) {
417 exposures.set(key, n);
418 } else {
419 exposures.get(key)!.data.exposed.push(...n.data.exposed);
420 }
421 });
422 const volumes = config.volume?.map((v): VolumeNode => {
423 let existing: VolumeNode | null = null;
424 if (v.nodeId !== undefined) {
425 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
426 }
427 return {
428 id: existing != null ? existing.id : uuidv4(),
429 type: "volume",
430 data: {
431 label: v.name,
432 type: v.accessMode,
433 size: v.size,
434 attachedTo: [],
435 envVars: [],
436 ports: [],
437 },
438 position:
439 existing != null
440 ? existing.position
441 : {
442 x: 0,
443 y: 0,
444 },
445 };
446 });
447 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
448 let existing: PostgreSQLNode | null = null;
449 if (p.nodeId !== undefined) {
450 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
451 }
452 return {
453 id: existing != null ? existing.id : uuidv4(),
454 type: "postgresql",
455 data: {
456 label: p.name,
457 volumeId: "", // TODO(gio): volume
458 envVars: [],
459 ports: [
460 {
461 id: "connection",
462 name: "connection",
463 value: 5432,
464 },
465 ],
466 },
467 position:
468 existing != null
469 ? existing.position
470 : {
471 x: 0,
472 y: 0,
473 },
474 };
475 });
476 config.postgresql
477 ?.flatMap((p, index): GatewayTCPNode[] => {
478 return (p.expose || []).map((e): GatewayTCPNode => {
479 let existing: GatewayTCPNode | null = null;
480 if (e.nodeId !== undefined) {
481 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
482 }
483 return {
484 id: existing != null ? existing.id : uuidv4(),
485 type: "gateway-tcp",
486 data: {
487 label: e.subdomain,
488 envVars: [],
489 ports: [],
490 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
491 subdomain: e.subdomain,
492 exposed: [
493 {
494 serviceId: postgresql![index]!.id,
495 portId: "connection",
496 },
497 ],
498 },
499 position: existing != null ? existing.position : { x: 0, y: 0 },
500 };
501 });
502 })
503 .forEach((n) => {
504 const key = `${n.data.network}-${n.data.subdomain}`;
505 if (!exposures.has(key)) {
506 exposures.set(key, n);
507 } else {
508 exposures.get(key)!.data.exposed.push(...n.data.exposed);
509 }
510 });
511 const mongodb = config.mongodb?.map((m): MongoDBNode => {
512 let existing: MongoDBNode | null = null;
513 if (m.nodeId !== undefined) {
514 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
515 }
516 return {
517 id: existing != null ? existing.id : uuidv4(),
518 type: "mongodb",
519 data: {
520 label: m.name,
521 volumeId: "", // TODO(gio): volume
522 envVars: [],
523 ports: [
524 {
525 id: "connection",
526 name: "connection",
527 value: 27017,
528 },
529 ],
530 },
531 position:
532 existing != null
533 ? existing.position
534 : {
535 x: 0,
536 y: 0,
537 },
538 };
539 });
540 config.mongodb
541 ?.flatMap((p, index): GatewayTCPNode[] => {
542 return (p.expose || []).map((e): GatewayTCPNode => {
543 let existing: GatewayTCPNode | null = null;
544 if (e.nodeId !== undefined) {
545 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
546 }
547 return {
548 id: existing != null ? existing.id : uuidv4(),
549 type: "gateway-tcp",
550 data: {
551 label: e.subdomain,
552 envVars: [],
553 ports: [],
554 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
555 subdomain: e.subdomain,
556 exposed: [
557 {
558 serviceId: mongodb![index]!.id,
559 portId: "connection",
560 },
561 ],
562 },
563 position: existing != null ? existing.position : { x: 0, y: 0 },
564 };
565 });
566 })
567 .forEach((n) => {
568 const key = `${n.data.network}-${n.data.subdomain}`;
569 if (!exposures.has(key)) {
570 exposures.set(key, n);
571 } else {
572 exposures.get(key)!.data.exposed.push(...n.data.exposed);
573 }
574 });
575 ret.nodes = [
576 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000577 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000578 ...(services || []),
579 ...(serviceGateways || []),
580 ...(volumes || []),
581 ...(postgresql || []),
582 ...(mongodb || []),
583 ...(exposures.values() || []),
584 ];
585 services?.forEach((s) => {
586 s.data.envVars.forEach((e) => {
587 if (!("name" in e)) {
588 return;
589 }
590 if (!e.name.startsWith("DODO_")) {
591 return;
592 }
593 let r: {
594 type: string;
595 name: string;
596 } | null = null;
597 if (e.name.startsWith("DODO_PORT_")) {
598 return;
599 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
600 r = {
601 type: "postgresql",
602 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
603 };
604 } else if (e.name.startsWith("DODO_MONGODB_")) {
605 r = {
606 type: "mongodb",
607 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
608 };
609 } else if (e.name.startsWith("DODO_VOLUME_")) {
610 r = {
611 type: "volume",
612 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
613 };
614 }
615 if (r != null) {
616 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
617 }
618 });
619 });
620 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
621 return n.data.envVars.flatMap((e): Edge[] => {
622 if (e.source == null) {
623 return [];
624 }
625 const sn = ret.nodes.find((n) => n.id === e.source!)!;
626 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
627 return [
628 {
629 id: uuidv4(),
630 source: e.source!,
631 sourceHandle: sourceHandle,
632 target: n.id,
633 targetHandle: "env_var",
634 },
635 ];
636 });
637 });
638 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
639 return n.data.exposed.flatMap((e): Edge[] => {
640 return [
641 {
642 id: uuidv4(),
643 source: e.serviceId,
644 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
645 target: n.id,
646 targetHandle: "tcp",
647 },
648 {
649 id: uuidv4(),
650 source: n.id,
651 sourceHandle: "subdomain",
652 target: n.data.network!,
653 targetHandle: "subdomain",
654 },
655 ];
656 });
657 });
658 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
659 return [
660 {
661 id: uuidv4(),
662 source: n.data.https!.serviceId,
663 sourceHandle: "ports",
664 target: n.id,
665 targetHandle: "https",
666 },
667 {
668 id: uuidv4(),
669 source: n.id,
670 sourceHandle: "subdomain",
671 target: n.data.network!,
672 targetHandle: "subdomain",
673 },
674 ];
675 });
gio69ff7592025-07-03 06:27:21 +0000676 const repoEdges = (services || [])
677 .map((s): Edge | null => {
678 if (s.data.repository == null) {
679 return null;
680 }
681 return {
682 id: uuidv4(),
683 source: s.data.repository!.repoNodeId!,
684 sourceHandle: "repository",
685 target: s.id,
686 targetHandle: "repository",
687 };
688 })
689 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000690 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000691 return ret;
692}