blob: 7fd7066b6f089f6c05243857ba75aed243029fc3 [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
gio43e0aad2025-08-01 16:17:27 +0400115 ? n.data.dev.mode === "VM"
116 ? {
117 enabled: true,
118 mode: "VM",
119 username: env.user.username,
120 codeServer:
121 n.data.dev.expose != null
122 ? {
123 network: networkMap.get(n.data.dev.expose.network)!,
124 subdomain: n.data.dev.expose.subdomain,
125 }
126 : undefined,
127 ssh:
128 n.data.dev.expose != null
129 ? {
130 network: networkMap.get(n.data.dev.expose.network)!,
131 subdomain: n.data.dev.expose.subdomain,
132 }
133 : undefined,
134 }
135 : {
136 enabled: true,
137 mode: "PROXY",
138 address: n.data.dev.address,
139 vpn: {
140 enabled: true,
141 username: env.user.username,
142 },
143 }
gio2f393c12025-07-01 08:02:48 +0000144 : {
145 enabled: false,
146 },
gio69ff7592025-07-03 06:27:21 +0000147 ...(n.data.model?.name === "gemini" && {
148 model: {
149 name: "gemini",
150 geminiApiKey: n.data.model.apiKey,
151 },
152 }),
153 ...(n.data.model?.name === "claude" && {
154 model: {
155 name: "claude",
156 anthropicApiKey: n.data.model.apiKey,
157 },
158 }),
gio69148322025-06-19 23:16:12 +0400159 };
160 });
gioc31bf142025-06-16 07:48:20 +0000161 return {
gio69148322025-06-19 23:16:12 +0400162 service: services.filter((s) => !isAgent(s)),
163 agent: services.filter(isAgent),
gioc31bf142025-06-16 07:48:20 +0000164 volume: nodes
165 .filter((n) => n.type === "volume")
166 .map(
167 (n): Volume => ({
168 nodeId: n.id,
169 name: n.data.label,
170 accessMode: n.data.type,
171 size: n.data.size,
172 }),
173 ),
174 postgresql: nodes
175 .filter((n) => n.type === "postgresql")
176 .map(
177 (n): PostgreSQL => ({
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 mongodb: nodes
185 .filter((n) => n.type === "mongodb")
186 .map(
187 (n): MongoDB => ({
188 nodeId: n.id,
189 name: n.data.label,
190 size: "1Gi", // TODO(gio)
191 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
192 }),
193 ),
194 };
195 } catch (e) {
196 console.log(e);
gio69148322025-06-19 23:16:12 +0400197 return null;
gioc31bf142025-06-16 07:48:20 +0000198 }
199}
200
gio9b7421a2025-06-18 12:31:13 +0000201export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
gioc31bf142025-06-16 07:48:20 +0000202 if (current == null) {
203 current = { nodes: [], edges: [] };
204 }
205 const ret: Graph = {
206 nodes: [],
207 edges: [],
208 };
209 if (networks.length === 0) {
210 return ret;
211 }
gio69ff7592025-07-03 06:27:21 +0000212 const repoNodes = [...(config.service || []), ...(config.agent || [])]
gio69148322025-06-19 23:16:12 +0400213 .filter((s) => s.source?.repository != null)
gio9b7421a2025-06-18 12:31:13 +0000214 .map((s): GithubNode | null => {
215 const existing = current.nodes.find(
gio69148322025-06-19 23:16:12 +0400216 (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
gio9b7421a2025-06-18 12:31:13 +0000217 );
gio69148322025-06-19 23:16:12 +0400218 const repo = repos.find((r) => r.ssh_url === s.source!.repository);
gio9b7421a2025-06-18 12:31:13 +0000219 if (repo == null) {
220 return null;
221 }
222 return {
223 id: existing != null ? existing.id : uuidv4(),
224 type: "github",
225 data: {
226 label: repo.full_name,
227 repository: {
228 id: repo.id,
229 sshURL: repo.ssh_url,
230 fullName: repo.full_name,
231 },
232 envVars: [],
233 ports: [],
234 },
235 position:
236 existing != null
237 ? existing.position
238 : {
239 x: 0,
240 y: 0,
241 },
242 };
243 })
244 .filter((n) => n != null);
gioc31bf142025-06-16 07:48:20 +0000245 const networkNodes = networks.map((n): NetworkNode => {
246 let existing: NetworkNode | undefined = undefined;
247 existing = current.nodes
248 .filter((i): i is NetworkNode => i.type === "network")
249 .find((i) => i.data.domain === n.domain);
250 return {
251 id: n.domain,
252 type: "network",
253 data: {
254 label: n.name,
255 domain: n.domain,
256 envVars: [],
257 ports: [],
258 },
259 position: existing != null ? existing.position : { x: 0, y: 0 },
260 };
261 });
gio69148322025-06-19 23:16:12 +0400262 const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
gioc31bf142025-06-16 07:48:20 +0000263 let existing: ServiceNode | null = null;
264 if (s.nodeId !== undefined) {
265 existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
266 }
267 return {
268 id: existing != null ? existing.id : uuidv4(),
269 type: "app",
270 data: {
271 label: s.name,
272 type: s.type,
gio69148322025-06-19 23:16:12 +0400273 repository:
274 s.source != null
275 ? {
276 id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
277 .repository!.id,
278 repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
279 .id,
280 branch: s.source!.branch,
281 rootDir: s.source!.rootDir,
282 }
283 : undefined,
gioc31bf142025-06-16 07:48:20 +0000284 ports: (s.ports || []).map(
285 (p): Port => ({
286 id: uuidv4(),
287 name: p.name,
288 value: p.value,
289 }),
290 ),
291 envVars: (s.env || []).map((e): BoundEnvVar => {
gio1dacf1c2025-07-03 16:39:04 +0000292 if (e.value != null) {
gioc31bf142025-06-16 07:48:20 +0000293 return {
294 id: uuidv4(),
gioc31bf142025-06-16 07:48:20 +0000295 source: null,
gio1dacf1c2025-07-03 16:39:04 +0000296 name: e.name,
297 value: e.value,
298 };
299 } else if (e.alias != null && e.alias !== "") {
300 return {
301 id: uuidv4(),
302 source: null,
303 name: e.name,
gioc31bf142025-06-16 07:48:20 +0000304 alias: e.alias,
305 isEditting: false,
306 };
307 } else {
308 return {
309 id: uuidv4(),
310 name: e.name,
311 source: null,
312 isEditting: false,
313 };
314 }
315 }),
316 volume: s.volume || [],
317 preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
gio69ff7592025-07-03 06:27:21 +0000318 ...(s.model != null && {
319 model:
320 s.model.name === "gemini"
321 ? { name: "gemini", apiKey: s.model.geminiApiKey }
322 : { name: "claude", apiKey: s.model.anthropicApiKey },
323 }),
gio43e0aad2025-08-01 16:17:27 +0400324 dev: s.dev?.enabled
325 ? s.dev.mode === "VM"
326 ? {
327 enabled: true,
328 mode: "VM",
329 expose: s.dev.ssh
330 ? {
331 network: s.dev.ssh.network,
332 subdomain: s.dev.ssh.subdomain,
333 }
334 : undefined,
335 codeServerNodeId: uuidv4(), // TODO: proper node tracking
336 sshNodeId: uuidv4(), // TODO: proper node tracking
337 }
338 : {
339 enabled: true,
340 mode: "PROXY",
341 address: s.dev.address,
342 }
343 : {
344 enabled: false,
345 },
gioc31bf142025-06-16 07:48:20 +0000346 isChoosingPortToConnect: false,
347 },
348 // TODO(gio): generate position
349 position:
350 existing != null
351 ? existing.position
352 : {
353 x: 0,
354 y: 0,
355 },
356 };
357 });
gio69ff7592025-07-03 06:27:21 +0000358 const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
359 (s, index): GatewayHttpsNode[] => {
360 return (s.ingress || []).map((i): GatewayHttpsNode => {
361 let existing: GatewayHttpsNode | null = null;
362 if (i.nodeId !== undefined) {
363 existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
364 }
365 return {
366 id: existing != null ? existing.id : uuidv4(),
367 type: "gateway-https",
368 data: {
369 label: i.subdomain,
370 envVars: [],
371 ports: [],
372 network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
373 subdomain: i.subdomain,
374 https: {
375 serviceId: services![index]!.id,
376 portId: services![index]!.data.ports.find((p) => {
377 const port = i.port;
378 if ("name" in port) {
379 return p.name === port.name;
380 } else {
381 return `${p.value}` === port.value;
382 }
383 })!.id,
384 },
385 auth: i.auth.enabled
386 ? {
387 enabled: true,
388 groups: i.auth.groups || [],
389 noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
390 }
391 : {
392 enabled: false,
393 groups: [],
394 noAuthPathPatterns: [],
395 },
gioc31bf142025-06-16 07:48:20 +0000396 },
gio69ff7592025-07-03 06:27:21 +0000397 position: {
398 x: 0,
399 y: 0,
400 },
401 };
402 });
403 },
404 );
gioc31bf142025-06-16 07:48:20 +0000405 const exposures = new Map<string, GatewayTCPNode>();
gio69ff7592025-07-03 06:27:21 +0000406 [...(config.service || []), ...(config.agent || [])]
gioc31bf142025-06-16 07:48:20 +0000407 ?.flatMap((s, index): GatewayTCPNode[] => {
408 return (s.expose || []).map((e): GatewayTCPNode => {
409 let existing: GatewayTCPNode | null = null;
410 if (e.nodeId !== undefined) {
411 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
412 }
413 return {
414 id: existing != null ? existing.id : uuidv4(),
415 type: "gateway-tcp",
416 data: {
417 label: e.subdomain,
418 envVars: [],
419 ports: [],
420 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
421 subdomain: e.subdomain,
422 exposed: [
423 {
424 serviceId: services![index]!.id,
425 portId: services![index]!.data.ports.find((p) => {
426 const port = e.port;
427 if ("name" in port) {
428 return p.name === port.name;
429 } else {
430 return p.value === port.value;
431 }
432 })!.id,
433 },
434 ],
435 },
436 position: existing != null ? existing.position : { x: 0, y: 0 },
437 };
438 });
439 })
440 .forEach((n) => {
441 const key = `${n.data.network}-${n.data.subdomain}`;
442 if (!exposures.has(key)) {
443 exposures.set(key, n);
444 } else {
445 exposures.get(key)!.data.exposed.push(...n.data.exposed);
446 }
447 });
448 const volumes = config.volume?.map((v): VolumeNode => {
449 let existing: VolumeNode | null = null;
450 if (v.nodeId !== undefined) {
451 existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
452 }
453 return {
454 id: existing != null ? existing.id : uuidv4(),
455 type: "volume",
456 data: {
457 label: v.name,
458 type: v.accessMode,
459 size: v.size,
460 attachedTo: [],
461 envVars: [],
462 ports: [],
463 },
464 position:
465 existing != null
466 ? existing.position
467 : {
468 x: 0,
469 y: 0,
470 },
471 };
472 });
473 const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
474 let existing: PostgreSQLNode | null = null;
475 if (p.nodeId !== undefined) {
476 existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
477 }
478 return {
479 id: existing != null ? existing.id : uuidv4(),
480 type: "postgresql",
481 data: {
482 label: p.name,
gioc31bf142025-06-16 07:48:20 +0000483 envVars: [],
484 ports: [
485 {
486 id: "connection",
487 name: "connection",
488 value: 5432,
489 },
490 ],
491 },
492 position:
493 existing != null
494 ? existing.position
495 : {
496 x: 0,
497 y: 0,
498 },
499 };
500 });
501 config.postgresql
502 ?.flatMap((p, index): GatewayTCPNode[] => {
503 return (p.expose || []).map((e): GatewayTCPNode => {
504 let existing: GatewayTCPNode | null = null;
505 if (e.nodeId !== undefined) {
506 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
507 }
508 return {
509 id: existing != null ? existing.id : uuidv4(),
510 type: "gateway-tcp",
511 data: {
512 label: e.subdomain,
513 envVars: [],
514 ports: [],
515 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
516 subdomain: e.subdomain,
517 exposed: [
518 {
519 serviceId: postgresql![index]!.id,
520 portId: "connection",
521 },
522 ],
523 },
524 position: existing != null ? existing.position : { x: 0, y: 0 },
525 };
526 });
527 })
528 .forEach((n) => {
529 const key = `${n.data.network}-${n.data.subdomain}`;
530 if (!exposures.has(key)) {
531 exposures.set(key, n);
532 } else {
533 exposures.get(key)!.data.exposed.push(...n.data.exposed);
534 }
535 });
536 const mongodb = config.mongodb?.map((m): MongoDBNode => {
537 let existing: MongoDBNode | null = null;
538 if (m.nodeId !== undefined) {
539 existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
540 }
541 return {
542 id: existing != null ? existing.id : uuidv4(),
543 type: "mongodb",
544 data: {
545 label: m.name,
gioc31bf142025-06-16 07:48:20 +0000546 envVars: [],
547 ports: [
548 {
549 id: "connection",
550 name: "connection",
551 value: 27017,
552 },
553 ],
554 },
555 position:
556 existing != null
557 ? existing.position
558 : {
559 x: 0,
560 y: 0,
561 },
562 };
563 });
564 config.mongodb
565 ?.flatMap((p, index): GatewayTCPNode[] => {
566 return (p.expose || []).map((e): GatewayTCPNode => {
567 let existing: GatewayTCPNode | null = null;
568 if (e.nodeId !== undefined) {
569 existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
570 }
571 return {
572 id: existing != null ? existing.id : uuidv4(),
573 type: "gateway-tcp",
574 data: {
575 label: e.subdomain,
576 envVars: [],
577 ports: [],
578 network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
579 subdomain: e.subdomain,
580 exposed: [
581 {
582 serviceId: mongodb![index]!.id,
583 portId: "connection",
584 },
585 ],
586 },
587 position: existing != null ? existing.position : { x: 0, y: 0 },
588 };
589 });
590 })
591 .forEach((n) => {
592 const key = `${n.data.network}-${n.data.subdomain}`;
593 if (!exposures.has(key)) {
594 exposures.set(key, n);
595 } else {
596 exposures.get(key)!.data.exposed.push(...n.data.exposed);
597 }
598 });
599 ret.nodes = [
600 ...networkNodes,
gio9b7421a2025-06-18 12:31:13 +0000601 ...repoNodes,
gioc31bf142025-06-16 07:48:20 +0000602 ...(services || []),
603 ...(serviceGateways || []),
604 ...(volumes || []),
605 ...(postgresql || []),
606 ...(mongodb || []),
607 ...(exposures.values() || []),
608 ];
609 services?.forEach((s) => {
610 s.data.envVars.forEach((e) => {
611 if (!("name" in e)) {
612 return;
613 }
614 if (!e.name.startsWith("DODO_")) {
615 return;
616 }
617 let r: {
618 type: string;
619 name: string;
620 } | null = null;
621 if (e.name.startsWith("DODO_PORT_")) {
622 return;
623 } else if (e.name.startsWith("DODO_POSTGRESQL_")) {
624 r = {
625 type: "postgresql",
626 name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
627 };
628 } else if (e.name.startsWith("DODO_MONGODB_")) {
629 r = {
630 type: "mongodb",
631 name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
632 };
633 } else if (e.name.startsWith("DODO_VOLUME_")) {
634 r = {
635 type: "volume",
636 name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
637 };
638 }
639 if (r != null) {
640 e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
641 }
642 });
643 });
644 const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
645 return n.data.envVars.flatMap((e): Edge[] => {
646 if (e.source == null) {
647 return [];
648 }
649 const sn = ret.nodes.find((n) => n.id === e.source!)!;
650 const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
651 return [
652 {
653 id: uuidv4(),
654 source: e.source!,
655 sourceHandle: sourceHandle,
656 target: n.id,
657 targetHandle: "env_var",
658 },
659 ];
660 });
661 });
662 const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
663 return n.data.exposed.flatMap((e): Edge[] => {
664 return [
665 {
666 id: uuidv4(),
667 source: e.serviceId,
668 sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
669 target: n.id,
670 targetHandle: "tcp",
671 },
672 {
673 id: uuidv4(),
674 source: n.id,
675 sourceHandle: "subdomain",
676 target: n.data.network!,
677 targetHandle: "subdomain",
678 },
679 ];
680 });
681 });
682 const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
683 return [
684 {
685 id: uuidv4(),
686 source: n.data.https!.serviceId,
687 sourceHandle: "ports",
688 target: n.id,
689 targetHandle: "https",
690 },
691 {
692 id: uuidv4(),
693 source: n.id,
694 sourceHandle: "subdomain",
695 target: n.data.network!,
696 targetHandle: "subdomain",
697 },
698 ];
699 });
gio69ff7592025-07-03 06:27:21 +0000700 const repoEdges = (services || [])
701 .map((s): Edge | null => {
702 if (s.data.repository == null) {
703 return null;
704 }
705 return {
706 id: uuidv4(),
707 source: s.data.repository!.repoNodeId!,
708 sourceHandle: "repository",
709 target: s.id,
710 targetHandle: "repository",
711 };
712 })
713 .filter((e) => e != null);
gio9b7421a2025-06-18 12:31:13 +0000714 ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
gioc31bf142025-06-16 07:48:20 +0000715 return ret;
716}