blob: d91b5355cee93ab18a3a39efadd4082b333fc8f8 [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,
117 username: env.user.username,
118 codeServer:
119 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 ssh:
126 n.data.dev.expose != null
127 ? {
128 network: networkMap.get(n.data.dev.expose.network)!,
129 subdomain: n.data.dev.expose.subdomain,
130 }
131 : undefined,
gio69148322025-06-19 23:16:12 +0400132 }
gio2f393c12025-07-01 08:02:48 +0000133 : {
134 enabled: false,
135 },
gio69ff7592025-07-03 06:27:21 +0000136 ...(n.data.model?.name === "gemini" && {
137 model: {
138 name: "gemini",
139 geminiApiKey: n.data.model.apiKey,
140 },
141 }),
142 ...(n.data.model?.name === "claude" && {
143 model: {
144 name: "claude",
145 anthropicApiKey: n.data.model.apiKey,
146 },
147 }),
gio69148322025-06-19 23:16:12 +0400148 };
149 });
gioc31bf142025-06-16 07:48:20 +0000150 return {
gio69148322025-06-19 23:16:12 +0400151 service: services.filter((s) => !isAgent(s)),
152 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000153 volume: nodes
154 .filter((n) => n.type === "volume")
155 .map(
156 (n): Volume => ({
157 nodeId: n.id,
158 name: n.data.label,
159 accessMode: n.data.type,
160 size: n.data.size,
161 }),
162 ),
163 postgresql: nodes
164 .filter((n) => n.type === "postgresql")
165 .map(
166 (n): PostgreSQL => ({
167 nodeId: n.id,
168 name: n.data.label,
169 size: "1Gi", // TODO(gio)
170 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
171 }),
172 ),
173 mongodb: nodes
174 .filter((n) => n.type === "mongodb")
175 .map(
176 (n): MongoDB => ({
177 nodeId: n.id,
178 name: n.data.label,
179 size: "1Gi", // TODO(gio)
180 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
181 }),
182 ),
183 };
184 } catch (e) {
185 console.log(e);
gio69148322025-06-19 23:16:12 +0400186 return null;
gioc31bf142025-06-16 07:48:20 +0000187 }
188}
189
gio9b7421a2025-06-18 12:31:13 +0000190export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000191 if (current == null) {
192 current = { nodes: [], edges: [] };
193 }
194 const ret: Graph = {
195 nodes: [],
196 edges: [],
197 };
198 if (networks.length === 0) {
199 return ret;
200 }
gio69ff7592025-07-03 06:27:21 +0000201 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400202 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000203 .map((s): GithubNode | null => {
204 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400205 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000206 );
gio69148322025-06-19 23:16:12 +0400207 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000208 if (repo == null) {
209 return null;
210 }
211 return {
212 id: existing != null ? existing.id : uuidv4(),
213 type: "github",
214 data: {
215 label: repo.full_name,
216 repository: {
217 id: repo.id,
218 sshURL: repo.ssh_url,
219 fullName: repo.full_name,
220 },
221 envVars: [],
222 ports: [],
223 },
224 position:
225 existing != null
226 ? existing.position
227 : {
228 x: 0,
229 y: 0,
230 },
231 };
232 })
233 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000234 const networkNodes = networks.map((n): NetworkNode => {
235 let existing: NetworkNode | undefined = undefined;
236 existing = current.nodes
237 .filter((i): i is NetworkNode => i.type === "network")
238 .find((i) => i.data.domain === n.domain);
239 return {
240 id: n.domain,
241 type: "network",
242 data: {
243 label: n.name,
244 domain: n.domain,
245 envVars: [],
246 ports: [],
247 },
248 position: existing != null ? existing.position : { x: 0, y: 0 },
249 };
250 });
gio69148322025-06-19 23:16:12 +0400251 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000252 let existing: ServiceNode | null = null;
253 if (s.nodeId !== undefined) {
254 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
255 }
256 return {
257 id: existing != null ? existing.id : uuidv4(),
258 type: "app",
259 data: {
260 label: s.name,
261 type: s.type,
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 => {
gio1dacf1c2025-07-03 16:39:04 +0000281 if (e.value != null) {
gioc31bf142025-06-16 07:48:20 +0000282 return {
283 id: uuidv4(),
gioc31bf142025-06-16 07:48:20 +0000284 source: null,
gio1dacf1c2025-07-03 16:39:04 +0000285 name: e.name,
286 value: e.value,
287 };
288 } else if (e.alias != null && e.alias !== "") {
289 return {
290 id: uuidv4(),
291 source: null,
292 name: e.name,
gioc31bf142025-06-16 07:48:20 +0000293 alias: e.alias,
294 isEditting: false,
295 };
296 } else {
297 return {
298 id: uuidv4(),
299 name: e.name,
300 source: null,
301 isEditting: false,
302 };
303 }
304 }),
305 volume: s.volume || [],
306 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000307 ...(s.model != null && {
308 model:
309 s.model.name === "gemini"
310 ? { name: "gemini", apiKey: s.model.geminiApiKey }
311 : { name: "claude", apiKey: s.model.anthropicApiKey },
312 }),
gioc31bf142025-06-16 07:48:20 +0000313 // TODO(gio): dev
314 isChoosingPortToConnect: false,
315 },
316 // TODO(gio): generate position
317 position:
318 existing != null
319 ? existing.position
320 : {
321 x: 0,
322 y: 0,
323 },
324 };
325 });
gio69ff7592025-07-03 06:27:21 +0000326 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
327 (s, index): GatewayHttpsNode[] => {
328 return (s.ingress || []).map((i): GatewayHttpsNode => {
329 let existing: GatewayHttpsNode | null = null;
330 if (i.nodeId !== undefined) {
331 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
332 }
333 return {
334 id: existing != null ? existing.id : uuidv4(),
335 type: "gateway-https",
336 data: {
337 label: i.subdomain,
338 envVars: [],
339 ports: [],
340 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
341 subdomain: i.subdomain,
342 https: {
343 serviceId: services![index]!.id,
344 portId: services![index]!.data.ports.find((p) => {
345 const port = i.port;
346 if ("name" in port) {
347 return p.name === port.name;
348 } else {
349 return `${p.value}` === port.value;
350 }
351 })!.id,
352 },
353 auth: i.auth.enabled
354 ? {
355 enabled: true,
356 groups: i.auth.groups || [],
357 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
358 }
359 : {
360 enabled: false,
361 groups: [],
362 noAuthPathPatterns: [],
363 },
gioc31bf142025-06-16 07:48:20 +0000364 },
gio69ff7592025-07-03 06:27:21 +0000365 position: {
366 x: 0,
367 y: 0,
368 },
369 };
370 });
371 },
372 );
gioc31bf142025-06-16 07:48:20 +0000373 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000374 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000375 ?.flatMap((s, index): GatewayTCPNode[] => {
376 return (s.expose || []).map((e): GatewayTCPNode => {
377 let existing: GatewayTCPNode | null = null;
378 if (e.nodeId !== undefined) {
379 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
380 }
381 return {
382 id: existing != null ? existing.id : uuidv4(),
383 type: "gateway-tcp",
384 data: {
385 label: e.subdomain,
386 envVars: [],
387 ports: [],
388 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
389 subdomain: e.subdomain,
390 exposed: [
391 {
392 serviceId: services![index]!.id,
393 portId: services![index]!.data.ports.find((p) => {
394 const port = e.port;
395 if ("name" in port) {
396 return p.name === port.name;
397 } else {
398 return p.value === port.value;
399 }
400 })!.id,
401 },
402 ],
403 },
404 position: existing != null ? existing.position : { x: 0, y: 0 },
405 };
406 });
407 })
408 .forEach((n) => {
409 const key = `${n.data.network}-${n.data.subdomain}`;
410 if (!exposures.has(key)) {
411 exposures.set(key, n);
412 } else {
413 exposures.get(key)!.data.exposed.push(...n.data.exposed);
414 }
415 });
416 const volumes = config.volume?.map((v): VolumeNode => {
417 let existing: VolumeNode | null = null;
418 if (v.nodeId !== undefined) {
419 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
420 }
421 return {
422 id: existing != null ? existing.id : uuidv4(),
423 type: "volume",
424 data: {
425 label: v.name,
426 type: v.accessMode,
427 size: v.size,
428 attachedTo: [],
429 envVars: [],
430 ports: [],
431 },
432 position:
433 existing != null
434 ? existing.position
435 : {
436 x: 0,
437 y: 0,
438 },
439 };
440 });
441 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
442 let existing: PostgreSQLNode | null = null;
443 if (p.nodeId !== undefined) {
444 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
445 }
446 return {
447 id: existing != null ? existing.id : uuidv4(),
448 type: "postgresql",
449 data: {
450 label: p.name,
gioc31bf142025-06-16 07:48:20 +0000451 envVars: [],
452 ports: [
453 {
454 id: "connection",
455 name: "connection",
456 value: 5432,
457 },
458 ],
459 },
460 position:
461 existing != null
462 ? existing.position
463 : {
464 x: 0,
465 y: 0,
466 },
467 };
468 });
469 config.postgresql
470 ?.flatMap((p, index): GatewayTCPNode[] => {
471 return (p.expose || []).map((e): GatewayTCPNode => {
472 let existing: GatewayTCPNode | null = null;
473 if (e.nodeId !== undefined) {
474 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
475 }
476 return {
477 id: existing != null ? existing.id : uuidv4(),
478 type: "gateway-tcp",
479 data: {
480 label: e.subdomain,
481 envVars: [],
482 ports: [],
483 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
484 subdomain: e.subdomain,
485 exposed: [
486 {
487 serviceId: postgresql![index]!.id,
488 portId: "connection",
489 },
490 ],
491 },
492 position: existing != null ? existing.position : { x: 0, y: 0 },
493 };
494 });
495 })
496 .forEach((n) => {
497 const key = `${n.data.network}-${n.data.subdomain}`;
498 if (!exposures.has(key)) {
499 exposures.set(key, n);
500 } else {
501 exposures.get(key)!.data.exposed.push(...n.data.exposed);
502 }
503 });
504 const mongodb = config.mongodb?.map((m): MongoDBNode => {
505 let existing: MongoDBNode | null = null;
506 if (m.nodeId !== undefined) {
507 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
508 }
509 return {
510 id: existing != null ? existing.id : uuidv4(),
511 type: "mongodb",
512 data: {
513 label: m.name,
gioc31bf142025-06-16 07:48:20 +0000514 envVars: [],
515 ports: [
516 {
517 id: "connection",
518 name: "connection",
519 value: 27017,
520 },
521 ],
522 },
523 position:
524 existing != null
525 ? existing.position
526 : {
527 x: 0,
528 y: 0,
529 },
530 };
531 });
532 config.mongodb
533 ?.flatMap((p, index): GatewayTCPNode[] => {
534 return (p.expose || []).map((e): GatewayTCPNode => {
535 let existing: GatewayTCPNode | null = null;
536 if (e.nodeId !== undefined) {
537 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
538 }
539 return {
540 id: existing != null ? existing.id : uuidv4(),
541 type: "gateway-tcp",
542 data: {
543 label: e.subdomain,
544 envVars: [],
545 ports: [],
546 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
547 subdomain: e.subdomain,
548 exposed: [
549 {
550 serviceId: mongodb![index]!.id,
551 portId: "connection",
552 },
553 ],
554 },
555 position: existing != null ? existing.position : { x: 0, y: 0 },
556 };
557 });
558 })
559 .forEach((n) => {
560 const key = `${n.data.network}-${n.data.subdomain}`;
561 if (!exposures.has(key)) {
562 exposures.set(key, n);
563 } else {
564 exposures.get(key)!.data.exposed.push(...n.data.exposed);
565 }
566 });
567 ret.nodes = [
568 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000569 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000570 ...(services || []),
571 ...(serviceGateways || []),
572 ...(volumes || []),
573 ...(postgresql || []),
574 ...(mongodb || []),
575 ...(exposures.values() || []),
576 ];
577 services?.forEach((s) => {
578 s.data.envVars.forEach((e) => {
579 if (!("name" in e)) {
580 return;
581 }
582 if (!e.name.startsWith("DODO_")) {
583 return;
584 }
585 let r: {
586 type: string;
587 name: string;
588 } | null = null;
589 if (e.name.startsWith("DODO_PORT_")) {
590 return;
591 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
592 r = {
593 type: "postgresql",
594 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
595 };
596 } else if (e.name.startsWith("DODO_MONGODB_")) {
597 r = {
598 type: "mongodb",
599 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
600 };
601 } else if (e.name.startsWith("DODO_VOLUME_")) {
602 r = {
603 type: "volume",
604 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
605 };
606 }
607 if (r != null) {
608 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
609 }
610 });
611 });
612 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
613 return n.data.envVars.flatMap((e): Edge[] => {
614 if (e.source == null) {
615 return [];
616 }
617 const sn = ret.nodes.find((n) => n.id === e.source!)!;
618 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
619 return [
620 {
621 id: uuidv4(),
622 source: e.source!,
623 sourceHandle: sourceHandle,
624 target: n.id,
625 targetHandle: "env_var",
626 },
627 ];
628 });
629 });
630 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
631 return n.data.exposed.flatMap((e): Edge[] => {
632 return [
633 {
634 id: uuidv4(),
635 source: e.serviceId,
636 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
637 target: n.id,
638 targetHandle: "tcp",
639 },
640 {
641 id: uuidv4(),
642 source: n.id,
643 sourceHandle: "subdomain",
644 target: n.data.network!,
645 targetHandle: "subdomain",
646 },
647 ];
648 });
649 });
650 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
651 return [
652 {
653 id: uuidv4(),
654 source: n.data.https!.serviceId,
655 sourceHandle: "ports",
656 target: n.id,
657 targetHandle: "https",
658 },
659 {
660 id: uuidv4(),
661 source: n.id,
662 sourceHandle: "subdomain",
663 target: n.data.network!,
664 targetHandle: "subdomain",
665 },
666 ];
667 });
gio69ff7592025-07-03 06:27:21 +0000668 const repoEdges = (services || [])
669 .map((s): Edge | null => {
670 if (s.data.repository == null) {
671 return null;
672 }
673 return {
674 id: uuidv4(),
675 source: s.data.repository!.repoNodeId!,
676 sourceHandle: "repository",
677 target: s.id,
678 targetHandle: "repository",
679 };
680 })
681 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000682 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000683 return ret;
684}