blob: f5957d40901cd5998218b840e77ab05d80ee7f70 [file] [log] [blame]
gioc31bf142025-06-16 07:48:20 +00001import {
2 AppNode,
3 BoundEnvVar,
4 Env,
gio10ff1342025-07-05 10:22:15 +00005 Edge,
gioc31bf142025-06-16 07:48:20 +00006 GatewayHttpsNode,
7 GatewayTCPNode,
gio9b7421a2025-06-18 12:31:13 +00008 GithubNode,
gioc31bf142025-06-16 07:48:20 +00009 MongoDBNode,
10 Network,
11 NetworkNode,
12 Port,
13 PostgreSQLNode,
14 ServiceNode,
15 VolumeNode,
gio10ff1342025-07-05 10:22:15 +000016 Graph,
gioc31bf142025-06-16 07:48:20 +000017} from "./graph.js";
gioc31bf142025-06-16 07:48:20 +000018import { v4 as uuidv4 } from "uuid";
gio69148322025-06-19 23:16:12 +040019import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
gio9b7421a2025-06-18 12:31:13 +000020import { GithubRepository } from "./github.js";
gioc31bf142025-06-16 07:48:20 +000021
gio69148322025-06-19 23:16:12 +040022export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
gioc31bf142025-06-16 07:48:20 +000023 try {
gioc31bf142025-06-16 07:48:20 +000024 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
25 const ingressNodes = nodes
26 .filter((n) => n.type === "gateway-https")
27 .filter((n) => n.data.https !== undefined && !n.data.readonly);
28 const tcpNodes = nodes
29 .filter((n) => n.type === "gateway-tcp")
30 .filter((n) => n.data.exposed !== undefined && !n.data.readonly);
31 const findExpose = (n: AppNode): PortDomain[] => {
32 return n.data.ports
33 .map((p) => [n.id, p.id, p.name])
34 .flatMap((sp) => {
35 return tcpNodes.flatMap((i) =>
36 (i.data.exposed || [])
37 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
38 .map(() => ({
39 nodeId: i.id,
40 network: networkMap.get(i.data.network!)!,
41 subdomain: i.data.subdomain!,
42 port: { name: sp[2] },
43 })),
44 );
45 });
46 };
gio69148322025-06-19 23:16:12 +040047 const services = nodes
48 .filter((n) => n.type === "app")
49 .map((n): Service => {
50 return {
51 nodeId: n.id,
52 type: n.data.type,
53 name: n.data.label,
54 source:
55 n.data.repository != undefined
56 ? {
57 repository: nodes
58 .filter((i) => i.type === "github")
59 .find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
60 branch:
61 n.data.repository != undefined && "branch" in n.data.repository
62 ? n.data.repository.branch
63 : "main",
64 rootDir:
65 n.data.repository != undefined && "rootDir" in n.data.repository
66 ? n.data.repository.rootDir
67 : "/",
68 }
69 : undefined,
70 ports: (n.data.ports || [])
71 .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
72 .map((p) => ({
gio67d6d5f2025-07-02 15:49:54 +000073 name: p.name.toLowerCase(),
gio69148322025-06-19 23:16:12 +040074 value: p.value,
75 protocol: "TCP", // TODO(gio)
76 })),
77 env: (n.data.envVars || [])
78 .filter((e) => "name" in e)
gio1dacf1c2025-07-03 16:39:04 +000079 .map((e) => {
80 if ("value" in e) {
81 return { name: e.name, value: e.value };
82 }
83 return {
84 name: e.name,
85 alias: "alias" in e ? e.alias : undefined,
86 };
87 }),
gio69148322025-06-19 23:16:12 +040088 ingress: ingressNodes
89 .filter((i) => i.data.https!.serviceId === n.id)
90 .map(
91 (i): Ingress => ({
92 nodeId: i.id,
93 network: networkMap.get(i.data.network!)!,
94 subdomain: i.data.subdomain!,
95 port: {
gio67d6d5f2025-07-02 15:49:54 +000096 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name.toLowerCase(),
gio69148322025-06-19 23:16:12 +040097 },
98 auth:
99 i.data.auth?.enabled || false
100 ? {
101 enabled: true,
102 groups: i.data.auth!.groups,
103 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
104 }
105 : {
106 enabled: false,
107 },
108 }),
109 ),
110 expose: findExpose(n),
111 preBuildCommands: n.data.preBuildCommands
112 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
113 : [],
gio2f393c12025-07-01 08:02:48 +0000114 dev: n.data.dev?.enabled
gio69148322025-06-19 23:16:12 +0400115 ? {
gio2f393c12025-07-01 08:02:48 +0000116 enabled: true,
gioe10ba162025-07-31 19:52:29 +0400117 mode: "VM",
gio2f393c12025-07-01 08:02:48 +0000118 username: env.user.username,
119 codeServer:
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,
126 ssh:
127 n.data.dev.expose != null
128 ? {
129 network: networkMap.get(n.data.dev.expose.network)!,
130 subdomain: n.data.dev.expose.subdomain,
131 }
132 : undefined,
gio69148322025-06-19 23:16:12 +0400133 }
gio2f393c12025-07-01 08:02:48 +0000134 : {
135 enabled: false,
136 },
gio69ff7592025-07-03 06:27:21 +0000137 ...(n.data.model?.name === "gemini" && {
138 model: {
139 name: "gemini",
140 geminiApiKey: n.data.model.apiKey,
141 },
142 }),
143 ...(n.data.model?.name === "claude" && {
144 model: {
145 name: "claude",
146 anthropicApiKey: n.data.model.apiKey,
147 },
148 }),
gio69148322025-06-19 23:16:12 +0400149 };
150 });
gioc31bf142025-06-16 07:48:20 +0000151 return {
gio69148322025-06-19 23:16:12 +0400152 service: services.filter((s) => !isAgent(s)),
153 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000154 volume: nodes
155 .filter((n) => n.type === "volume")
156 .map(
157 (n): Volume => ({
158 nodeId: n.id,
159 name: n.data.label,
160 accessMode: n.data.type,
161 size: n.data.size,
162 }),
163 ),
164 postgresql: nodes
165 .filter((n) => n.type === "postgresql")
166 .map(
167 (n): PostgreSQL => ({
168 nodeId: n.id,
169 name: n.data.label,
170 size: "1Gi", // TODO(gio)
171 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
172 }),
173 ),
174 mongodb: nodes
175 .filter((n) => n.type === "mongodb")
176 .map(
177 (n): MongoDB => ({
178 nodeId: n.id,
179 name: n.data.label,
180 size: "1Gi", // TODO(gio)
181 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
182 }),
183 ),
184 };
185 } catch (e) {
186 console.log(e);
gio69148322025-06-19 23:16:12 +0400187 return null;
gioc31bf142025-06-16 07:48:20 +0000188 }
189}
190
gio9b7421a2025-06-18 12:31:13 +0000191export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000192 if (current == null) {
193 current = { nodes: [], edges: [] };
194 }
195 const ret: Graph = {
196 nodes: [],
197 edges: [],
198 };
199 if (networks.length === 0) {
200 return ret;
201 }
gio69ff7592025-07-03 06:27:21 +0000202 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400203 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000204 .map((s): GithubNode | null => {
205 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400206 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000207 );
gio69148322025-06-19 23:16:12 +0400208 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000209 if (repo == null) {
210 return null;
211 }
212 return {
213 id: existing != null ? existing.id : uuidv4(),
214 type: "github",
215 data: {
216 label: repo.full_name,
217 repository: {
218 id: repo.id,
219 sshURL: repo.ssh_url,
220 fullName: repo.full_name,
221 },
222 envVars: [],
223 ports: [],
224 },
225 position:
226 existing != null
227 ? existing.position
228 : {
229 x: 0,
230 y: 0,
231 },
232 };
233 })
234 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000235 const networkNodes = networks.map((n): NetworkNode => {
236 let existing: NetworkNode | undefined = undefined;
237 existing = current.nodes
238 .filter((i): i is NetworkNode => i.type === "network")
239 .find((i) => i.data.domain === n.domain);
240 return {
241 id: n.domain,
242 type: "network",
243 data: {
244 label: n.name,
245 domain: n.domain,
246 envVars: [],
247 ports: [],
248 },
249 position: existing != null ? existing.position : { x: 0, y: 0 },
250 };
251 });
gio69148322025-06-19 23:16:12 +0400252 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000253 let existing: ServiceNode | null = null;
254 if (s.nodeId !== undefined) {
255 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
256 }
257 return {
258 id: existing != null ? existing.id : uuidv4(),
259 type: "app",
260 data: {
261 label: s.name,
262 type: s.type,
gio69148322025-06-19 23:16:12 +0400263 repository:
264 s.source != null
265 ? {
266 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
267 .repository!.id,
268 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
269 .id,
270 branch: s.source!.branch,
271 rootDir: s.source!.rootDir,
272 }
273 : undefined,
gioc31bf142025-06-16 07:48:20 +0000274 ports: (s.ports || []).map(
275 (p): Port => ({
276 id: uuidv4(),
277 name: p.name,
278 value: p.value,
279 }),
280 ),
281 envVars: (s.env || []).map((e): BoundEnvVar => {
gio1dacf1c2025-07-03 16:39:04 +0000282 if (e.value != null) {
gioc31bf142025-06-16 07:48:20 +0000283 return {
284 id: uuidv4(),
gioc31bf142025-06-16 07:48:20 +0000285 source: null,
gio1dacf1c2025-07-03 16:39:04 +0000286 name: e.name,
287 value: e.value,
288 };
289 } else if (e.alias != null && e.alias !== "") {
290 return {
291 id: uuidv4(),
292 source: null,
293 name: e.name,
gioc31bf142025-06-16 07:48:20 +0000294 alias: e.alias,
295 isEditting: false,
296 };
297 } else {
298 return {
299 id: uuidv4(),
300 name: e.name,
301 source: null,
302 isEditting: false,
303 };
304 }
305 }),
306 volume: s.volume || [],
307 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000308 ...(s.model != null && {
309 model:
310 s.model.name === "gemini"
311 ? { name: "gemini", apiKey: s.model.geminiApiKey }
312 : { name: "claude", apiKey: s.model.anthropicApiKey },
313 }),
gioc31bf142025-06-16 07:48:20 +0000314 // TODO(gio): dev
315 isChoosingPortToConnect: false,
316 },
317 // TODO(gio): generate position
318 position:
319 existing != null
320 ? existing.position
321 : {
322 x: 0,
323 y: 0,
324 },
325 };
326 });
gio69ff7592025-07-03 06:27:21 +0000327 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
328 (s, index): GatewayHttpsNode[] => {
329 return (s.ingress || []).map((i): GatewayHttpsNode => {
330 let existing: GatewayHttpsNode | null = null;
331 if (i.nodeId !== undefined) {
332 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
333 }
334 return {
335 id: existing != null ? existing.id : uuidv4(),
336 type: "gateway-https",
337 data: {
338 label: i.subdomain,
339 envVars: [],
340 ports: [],
341 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
342 subdomain: i.subdomain,
343 https: {
344 serviceId: services![index]!.id,
345 portId: services![index]!.data.ports.find((p) => {
346 const port = i.port;
347 if ("name" in port) {
348 return p.name === port.name;
349 } else {
350 return `${p.value}` === port.value;
351 }
352 })!.id,
353 },
354 auth: i.auth.enabled
355 ? {
356 enabled: true,
357 groups: i.auth.groups || [],
358 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
359 }
360 : {
361 enabled: false,
362 groups: [],
363 noAuthPathPatterns: [],
364 },
gioc31bf142025-06-16 07:48:20 +0000365 },
gio69ff7592025-07-03 06:27:21 +0000366 position: {
367 x: 0,
368 y: 0,
369 },
370 };
371 });
372 },
373 );
gioc31bf142025-06-16 07:48:20 +0000374 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000375 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000376 ?.flatMap((s, index): GatewayTCPNode[] => {
377 return (s.expose || []).map((e): GatewayTCPNode => {
378 let existing: GatewayTCPNode | null = null;
379 if (e.nodeId !== undefined) {
380 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
381 }
382 return {
383 id: existing != null ? existing.id : uuidv4(),
384 type: "gateway-tcp",
385 data: {
386 label: e.subdomain,
387 envVars: [],
388 ports: [],
389 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
390 subdomain: e.subdomain,
391 exposed: [
392 {
393 serviceId: services![index]!.id,
394 portId: services![index]!.data.ports.find((p) => {
395 const port = e.port;
396 if ("name" in port) {
397 return p.name === port.name;
398 } else {
399 return p.value === port.value;
400 }
401 })!.id,
402 },
403 ],
404 },
405 position: existing != null ? existing.position : { x: 0, y: 0 },
406 };
407 });
408 })
409 .forEach((n) => {
410 const key = `${n.data.network}-${n.data.subdomain}`;
411 if (!exposures.has(key)) {
412 exposures.set(key, n);
413 } else {
414 exposures.get(key)!.data.exposed.push(...n.data.exposed);
415 }
416 });
417 const volumes = config.volume?.map((v): VolumeNode => {
418 let existing: VolumeNode | null = null;
419 if (v.nodeId !== undefined) {
420 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
421 }
422 return {
423 id: existing != null ? existing.id : uuidv4(),
424 type: "volume",
425 data: {
426 label: v.name,
427 type: v.accessMode,
428 size: v.size,
429 attachedTo: [],
430 envVars: [],
431 ports: [],
432 },
433 position:
434 existing != null
435 ? existing.position
436 : {
437 x: 0,
438 y: 0,
439 },
440 };
441 });
442 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
443 let existing: PostgreSQLNode | null = null;
444 if (p.nodeId !== undefined) {
445 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
446 }
447 return {
448 id: existing != null ? existing.id : uuidv4(),
449 type: "postgresql",
450 data: {
451 label: p.name,
gioc31bf142025-06-16 07:48:20 +0000452 envVars: [],
453 ports: [
454 {
455 id: "connection",
456 name: "connection",
457 value: 5432,
458 },
459 ],
460 },
461 position:
462 existing != null
463 ? existing.position
464 : {
465 x: 0,
466 y: 0,
467 },
468 };
469 });
470 config.postgresql
471 ?.flatMap((p, index): GatewayTCPNode[] => {
472 return (p.expose || []).map((e): GatewayTCPNode => {
473 let existing: GatewayTCPNode | null = null;
474 if (e.nodeId !== undefined) {
475 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
476 }
477 return {
478 id: existing != null ? existing.id : uuidv4(),
479 type: "gateway-tcp",
480 data: {
481 label: e.subdomain,
482 envVars: [],
483 ports: [],
484 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
485 subdomain: e.subdomain,
486 exposed: [
487 {
488 serviceId: postgresql![index]!.id,
489 portId: "connection",
490 },
491 ],
492 },
493 position: existing != null ? existing.position : { x: 0, y: 0 },
494 };
495 });
496 })
497 .forEach((n) => {
498 const key = `${n.data.network}-${n.data.subdomain}`;
499 if (!exposures.has(key)) {
500 exposures.set(key, n);
501 } else {
502 exposures.get(key)!.data.exposed.push(...n.data.exposed);
503 }
504 });
505 const mongodb = config.mongodb?.map((m): MongoDBNode => {
506 let existing: MongoDBNode | null = null;
507 if (m.nodeId !== undefined) {
508 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
509 }
510 return {
511 id: existing != null ? existing.id : uuidv4(),
512 type: "mongodb",
513 data: {
514 label: m.name,
gioc31bf142025-06-16 07:48:20 +0000515 envVars: [],
516 ports: [
517 {
518 id: "connection",
519 name: "connection",
520 value: 27017,
521 },
522 ],
523 },
524 position:
525 existing != null
526 ? existing.position
527 : {
528 x: 0,
529 y: 0,
530 },
531 };
532 });
533 config.mongodb
534 ?.flatMap((p, index): GatewayTCPNode[] => {
535 return (p.expose || []).map((e): GatewayTCPNode => {
536 let existing: GatewayTCPNode | null = null;
537 if (e.nodeId !== undefined) {
538 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
539 }
540 return {
541 id: existing != null ? existing.id : uuidv4(),
542 type: "gateway-tcp",
543 data: {
544 label: e.subdomain,
545 envVars: [],
546 ports: [],
547 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
548 subdomain: e.subdomain,
549 exposed: [
550 {
551 serviceId: mongodb![index]!.id,
552 portId: "connection",
553 },
554 ],
555 },
556 position: existing != null ? existing.position : { x: 0, y: 0 },
557 };
558 });
559 })
560 .forEach((n) => {
561 const key = `${n.data.network}-${n.data.subdomain}`;
562 if (!exposures.has(key)) {
563 exposures.set(key, n);
564 } else {
565 exposures.get(key)!.data.exposed.push(...n.data.exposed);
566 }
567 });
568 ret.nodes = [
569 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000570 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000571 ...(services || []),
572 ...(serviceGateways || []),
573 ...(volumes || []),
574 ...(postgresql || []),
575 ...(mongodb || []),
576 ...(exposures.values() || []),
577 ];
578 services?.forEach((s) => {
579 s.data.envVars.forEach((e) => {
580 if (!("name" in e)) {
581 return;
582 }
583 if (!e.name.startsWith("DODO_")) {
584 return;
585 }
586 let r: {
587 type: string;
588 name: string;
589 } | null = null;
590 if (e.name.startsWith("DODO_PORT_")) {
591 return;
592 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
593 r = {
594 type: "postgresql",
595 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
596 };
597 } else if (e.name.startsWith("DODO_MONGODB_")) {
598 r = {
599 type: "mongodb",
600 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
601 };
602 } else if (e.name.startsWith("DODO_VOLUME_")) {
603 r = {
604 type: "volume",
605 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
606 };
607 }
608 if (r != null) {
609 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
610 }
611 });
612 });
613 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
614 return n.data.envVars.flatMap((e): Edge[] => {
615 if (e.source == null) {
616 return [];
617 }
618 const sn = ret.nodes.find((n) => n.id === e.source!)!;
619 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
620 return [
621 {
622 id: uuidv4(),
623 source: e.source!,
624 sourceHandle: sourceHandle,
625 target: n.id,
626 targetHandle: "env_var",
627 },
628 ];
629 });
630 });
631 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
632 return n.data.exposed.flatMap((e): Edge[] => {
633 return [
634 {
635 id: uuidv4(),
636 source: e.serviceId,
637 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
638 target: n.id,
639 targetHandle: "tcp",
640 },
641 {
642 id: uuidv4(),
643 source: n.id,
644 sourceHandle: "subdomain",
645 target: n.data.network!,
646 targetHandle: "subdomain",
647 },
648 ];
649 });
650 });
651 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
652 return [
653 {
654 id: uuidv4(),
655 source: n.data.https!.serviceId,
656 sourceHandle: "ports",
657 target: n.id,
658 targetHandle: "https",
659 },
660 {
661 id: uuidv4(),
662 source: n.id,
663 sourceHandle: "subdomain",
664 target: n.data.network!,
665 targetHandle: "subdomain",
666 },
667 ];
668 });
gio69ff7592025-07-03 06:27:21 +0000669 const repoEdges = (services || [])
670 .map((s): Edge | null => {
671 if (s.data.repository == null) {
672 return null;
673 }
674 return {
675 id: uuidv4(),
676 source: s.data.repository!.repoNodeId!,
677 sourceHandle: "repository",
678 target: s.id,
679 targetHandle: "repository",
680 };
681 })
682 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000683 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000684 return ret;
685}