blob: 6793bf74bfe235917d0904f7277df22a5d0d0f85 [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) => ({
72 name: p.name.toLowerCase(),
73 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: {
90 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
91 },
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 : [],
108 dev: {
109 enabled: n.data.dev ? n.data.dev.enabled : false,
110 username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
111 codeServer:
112 n.data.dev?.enabled && n.data.dev.expose != null
113 ? {
114 network: networkMap.get(n.data.dev.expose.network)!,
115 subdomain: n.data.dev.expose.subdomain,
116 }
117 : undefined,
118 ssh:
119 n.data.dev?.enabled && n.data.dev.expose != null
120 ? {
121 network: networkMap.get(n.data.dev.expose.network)!,
122 subdomain: n.data.dev.expose.subdomain,
123 }
124 : undefined,
125 },
126 agent: n.data.agent
127 ? {
128 geminiApiKey: n.data.agent.geminiApiKey,
129 }
130 : undefined,
131 };
132 });
gioc31bf142025-06-16 07:48:20 +0000133 return {
gio69148322025-06-19 23:16:12 +0400134 service: services.filter((s) => !isAgent(s)),
135 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000136 volume: nodes
137 .filter((n) => n.type === "volume")
138 .map(
139 (n): Volume => ({
140 nodeId: n.id,
141 name: n.data.label,
142 accessMode: n.data.type,
143 size: n.data.size,
144 }),
145 ),
146 postgresql: nodes
147 .filter((n) => n.type === "postgresql")
148 .map(
149 (n): PostgreSQL => ({
150 nodeId: n.id,
151 name: n.data.label,
152 size: "1Gi", // TODO(gio)
153 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
154 }),
155 ),
156 mongodb: nodes
157 .filter((n) => n.type === "mongodb")
158 .map(
159 (n): MongoDB => ({
160 nodeId: n.id,
161 name: n.data.label,
162 size: "1Gi", // TODO(gio)
163 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
164 }),
165 ),
166 };
167 } catch (e) {
168 console.log(e);
gio69148322025-06-19 23:16:12 +0400169 return null;
gioc31bf142025-06-16 07:48:20 +0000170 }
171}
172
173export type Graph = {
174 nodes: AppNode[];
175 edges: Edge[];
176};
177
gio9b7421a2025-06-18 12:31:13 +0000178export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000179 if (current == null) {
180 current = { nodes: [], edges: [] };
181 }
182 const ret: Graph = {
183 nodes: [],
184 edges: [],
185 };
186 if (networks.length === 0) {
187 return ret;
188 }
gio9b7421a2025-06-18 12:31:13 +0000189 const repoNodes = (config.service || [])
gio69148322025-06-19 23:16:12 +0400190 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000191 .map((s): GithubNode | null => {
192 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400193 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000194 );
gio69148322025-06-19 23:16:12 +0400195 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000196 if (repo == null) {
197 return null;
198 }
199 return {
200 id: existing != null ? existing.id : uuidv4(),
201 type: "github",
202 data: {
203 label: repo.full_name,
204 repository: {
205 id: repo.id,
206 sshURL: repo.ssh_url,
207 fullName: repo.full_name,
208 },
209 envVars: [],
210 ports: [],
211 },
212 position:
213 existing != null
214 ? existing.position
215 : {
216 x: 0,
217 y: 0,
218 },
219 };
220 })
221 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000222 const networkNodes = networks.map((n): NetworkNode => {
223 let existing: NetworkNode | undefined = undefined;
224 existing = current.nodes
225 .filter((i): i is NetworkNode => i.type === "network")
226 .find((i) => i.data.domain === n.domain);
227 return {
228 id: n.domain,
229 type: "network",
230 data: {
231 label: n.name,
232 domain: n.domain,
233 envVars: [],
234 ports: [],
235 },
236 position: existing != null ? existing.position : { x: 0, y: 0 },
237 };
238 });
gio69148322025-06-19 23:16:12 +0400239 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000240 let existing: ServiceNode | null = null;
241 if (s.nodeId !== undefined) {
242 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
243 }
244 return {
245 id: existing != null ? existing.id : uuidv4(),
246 type: "app",
247 data: {
248 label: s.name,
249 type: s.type,
250 env: [],
gio69148322025-06-19 23:16:12 +0400251 repository:
252 s.source != null
253 ? {
254 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
255 .repository!.id,
256 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
257 .id,
258 branch: s.source!.branch,
259 rootDir: s.source!.rootDir,
260 }
261 : undefined,
gioc31bf142025-06-16 07:48:20 +0000262 ports: (s.ports || []).map(
263 (p): Port => ({
264 id: uuidv4(),
265 name: p.name,
266 value: p.value,
267 }),
268 ),
269 envVars: (s.env || []).map((e): BoundEnvVar => {
270 if (e.alias != null) {
271 return {
272 id: uuidv4(),
273 name: e.name,
274 source: null,
275 alias: e.alias,
276 isEditting: false,
277 };
278 } else {
279 return {
280 id: uuidv4(),
281 name: e.name,
282 source: null,
283 isEditting: false,
284 };
285 }
286 }),
287 volume: s.volume || [],
288 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69148322025-06-19 23:16:12 +0400289 agent: s.agent,
gioc31bf142025-06-16 07:48:20 +0000290 // TODO(gio): dev
291 isChoosingPortToConnect: false,
292 },
293 // TODO(gio): generate position
294 position:
295 existing != null
296 ? existing.position
297 : {
298 x: 0,
299 y: 0,
300 },
301 };
302 });
303 const serviceGateways = config.service?.flatMap((s, index): GatewayHttpsNode[] => {
304 return (s.ingress || []).map((i): GatewayHttpsNode => {
305 let existing: GatewayHttpsNode | null = null;
306 if (i.nodeId !== undefined) {
307 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
308 }
309 console.log("!!!", i.network, networks);
310 return {
311 id: existing != null ? existing.id : uuidv4(),
312 type: "gateway-https",
313 data: {
314 label: i.subdomain,
315 envVars: [],
316 ports: [],
317 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
318 subdomain: i.subdomain,
319 https: {
320 serviceId: services![index]!.id,
321 portId: services![index]!.data.ports.find((p) => {
322 const port = i.port;
323 if ("name" in port) {
324 return p.name === port.name;
325 } else {
326 return `${p.value}` === port.value;
327 }
328 })!.id,
329 },
330 auth: i.auth.enabled
331 ? {
332 enabled: true,
333 groups: i.auth.groups || [],
334 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
335 }
336 : {
337 enabled: false,
338 groups: [],
339 noAuthPathPatterns: [],
340 },
341 },
342 position: {
343 x: 0,
344 y: 0,
345 },
346 };
347 });
348 });
349 const exposures = new Map<string, GatewayTCPNode>();
350 config.service
351 ?.flatMap((s, index): GatewayTCPNode[] => {
352 return (s.expose || []).map((e): GatewayTCPNode => {
353 let existing: GatewayTCPNode | null = null;
354 if (e.nodeId !== undefined) {
355 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
356 }
357 return {
358 id: existing != null ? existing.id : uuidv4(),
359 type: "gateway-tcp",
360 data: {
361 label: e.subdomain,
362 envVars: [],
363 ports: [],
364 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
365 subdomain: e.subdomain,
366 exposed: [
367 {
368 serviceId: services![index]!.id,
369 portId: services![index]!.data.ports.find((p) => {
370 const port = e.port;
371 if ("name" in port) {
372 return p.name === port.name;
373 } else {
374 return p.value === port.value;
375 }
376 })!.id,
377 },
378 ],
379 },
380 position: existing != null ? existing.position : { x: 0, y: 0 },
381 };
382 });
383 })
384 .forEach((n) => {
385 const key = `${n.data.network}-${n.data.subdomain}`;
386 if (!exposures.has(key)) {
387 exposures.set(key, n);
388 } else {
389 exposures.get(key)!.data.exposed.push(...n.data.exposed);
390 }
391 });
392 const volumes = config.volume?.map((v): VolumeNode => {
393 let existing: VolumeNode | null = null;
394 if (v.nodeId !== undefined) {
395 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
396 }
397 return {
398 id: existing != null ? existing.id : uuidv4(),
399 type: "volume",
400 data: {
401 label: v.name,
402 type: v.accessMode,
403 size: v.size,
404 attachedTo: [],
405 envVars: [],
406 ports: [],
407 },
408 position:
409 existing != null
410 ? existing.position
411 : {
412 x: 0,
413 y: 0,
414 },
415 };
416 });
417 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
418 let existing: PostgreSQLNode | null = null;
419 if (p.nodeId !== undefined) {
420 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
421 }
422 return {
423 id: existing != null ? existing.id : uuidv4(),
424 type: "postgresql",
425 data: {
426 label: p.name,
427 volumeId: "", // TODO(gio): volume
428 envVars: [],
429 ports: [
430 {
431 id: "connection",
432 name: "connection",
433 value: 5432,
434 },
435 ],
436 },
437 position:
438 existing != null
439 ? existing.position
440 : {
441 x: 0,
442 y: 0,
443 },
444 };
445 });
446 config.postgresql
447 ?.flatMap((p, index): GatewayTCPNode[] => {
448 return (p.expose || []).map((e): GatewayTCPNode => {
449 let existing: GatewayTCPNode | null = null;
450 if (e.nodeId !== undefined) {
451 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
452 }
453 return {
454 id: existing != null ? existing.id : uuidv4(),
455 type: "gateway-tcp",
456 data: {
457 label: e.subdomain,
458 envVars: [],
459 ports: [],
460 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
461 subdomain: e.subdomain,
462 exposed: [
463 {
464 serviceId: postgresql![index]!.id,
465 portId: "connection",
466 },
467 ],
468 },
469 position: existing != null ? existing.position : { x: 0, y: 0 },
470 };
471 });
472 })
473 .forEach((n) => {
474 const key = `${n.data.network}-${n.data.subdomain}`;
475 if (!exposures.has(key)) {
476 exposures.set(key, n);
477 } else {
478 exposures.get(key)!.data.exposed.push(...n.data.exposed);
479 }
480 });
481 const mongodb = config.mongodb?.map((m): MongoDBNode => {
482 let existing: MongoDBNode | null = null;
483 if (m.nodeId !== undefined) {
484 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
485 }
486 return {
487 id: existing != null ? existing.id : uuidv4(),
488 type: "mongodb",
489 data: {
490 label: m.name,
491 volumeId: "", // TODO(gio): volume
492 envVars: [],
493 ports: [
494 {
495 id: "connection",
496 name: "connection",
497 value: 27017,
498 },
499 ],
500 },
501 position:
502 existing != null
503 ? existing.position
504 : {
505 x: 0,
506 y: 0,
507 },
508 };
509 });
510 config.mongodb
511 ?.flatMap((p, index): GatewayTCPNode[] => {
512 return (p.expose || []).map((e): GatewayTCPNode => {
513 let existing: GatewayTCPNode | null = null;
514 if (e.nodeId !== undefined) {
515 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
516 }
517 return {
518 id: existing != null ? existing.id : uuidv4(),
519 type: "gateway-tcp",
520 data: {
521 label: e.subdomain,
522 envVars: [],
523 ports: [],
524 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
525 subdomain: e.subdomain,
526 exposed: [
527 {
528 serviceId: mongodb![index]!.id,
529 portId: "connection",
530 },
531 ],
532 },
533 position: existing != null ? existing.position : { x: 0, y: 0 },
534 };
535 });
536 })
537 .forEach((n) => {
538 const key = `${n.data.network}-${n.data.subdomain}`;
539 if (!exposures.has(key)) {
540 exposures.set(key, n);
541 } else {
542 exposures.get(key)!.data.exposed.push(...n.data.exposed);
543 }
544 });
545 ret.nodes = [
546 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000547 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000548 ...(services || []),
549 ...(serviceGateways || []),
550 ...(volumes || []),
551 ...(postgresql || []),
552 ...(mongodb || []),
553 ...(exposures.values() || []),
554 ];
555 services?.forEach((s) => {
556 s.data.envVars.forEach((e) => {
557 if (!("name" in e)) {
558 return;
559 }
560 if (!e.name.startsWith("DODO_")) {
561 return;
562 }
563 let r: {
564 type: string;
565 name: string;
566 } | null = null;
567 if (e.name.startsWith("DODO_PORT_")) {
568 return;
569 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
570 r = {
571 type: "postgresql",
572 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
573 };
574 } else if (e.name.startsWith("DODO_MONGODB_")) {
575 r = {
576 type: "mongodb",
577 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
578 };
579 } else if (e.name.startsWith("DODO_VOLUME_")) {
580 r = {
581 type: "volume",
582 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
583 };
584 }
585 if (r != null) {
586 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
587 }
588 });
589 });
590 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
591 return n.data.envVars.flatMap((e): Edge[] => {
592 if (e.source == null) {
593 return [];
594 }
595 const sn = ret.nodes.find((n) => n.id === e.source!)!;
596 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
597 return [
598 {
599 id: uuidv4(),
600 source: e.source!,
601 sourceHandle: sourceHandle,
602 target: n.id,
603 targetHandle: "env_var",
604 },
605 ];
606 });
607 });
608 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
609 return n.data.exposed.flatMap((e): Edge[] => {
610 return [
611 {
612 id: uuidv4(),
613 source: e.serviceId,
614 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
615 target: n.id,
616 targetHandle: "tcp",
617 },
618 {
619 id: uuidv4(),
620 source: n.id,
621 sourceHandle: "subdomain",
622 target: n.data.network!,
623 targetHandle: "subdomain",
624 },
625 ];
626 });
627 });
628 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
629 return [
630 {
631 id: uuidv4(),
632 source: n.data.https!.serviceId,
633 sourceHandle: "ports",
634 target: n.id,
635 targetHandle: "https",
636 },
637 {
638 id: uuidv4(),
639 source: n.id,
640 sourceHandle: "subdomain",
641 target: n.data.network!,
642 targetHandle: "subdomain",
643 },
644 ];
645 });
gio9b7421a2025-06-18 12:31:13 +0000646 const repoEdges = (services || []).map((s): Edge => {
647 return {
648 id: uuidv4(),
649 source: s.data.repository!.repoNodeId!,
650 sourceHandle: "repository",
651 target: s.id,
652 targetHandle: "repository",
653 };
654 });
655 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000656 return ret;
657}