blob: ea744314f725a14a06f5e4d23160ad366cbb941c [file] [log] [blame]
import {
AppNode,
BoundEnvVar,
Env,
Edge,
GatewayHttpsNode,
GatewayTCPNode,
GithubNode,
MongoDBNode,
Network,
NetworkNode,
Port,
PostgreSQLNode,
ServiceNode,
VolumeNode,
Graph,
} from "./graph.js";
import { v4 as uuidv4 } from "uuid";
import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
import { GithubRepository } from "./github.js";
export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
try {
const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
const ingressNodes = nodes
.filter((n) => n.type === "gateway-https")
.filter((n) => n.data.https !== undefined && !n.data.readonly);
const tcpNodes = nodes
.filter((n) => n.type === "gateway-tcp")
.filter((n) => n.data.exposed !== undefined && !n.data.readonly);
const findExpose = (n: AppNode): PortDomain[] => {
return n.data.ports
.map((p) => [n.id, p.id, p.name])
.flatMap((sp) => {
return tcpNodes.flatMap((i) =>
(i.data.exposed || [])
.filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
.map(() => ({
nodeId: i.id,
network: networkMap.get(i.data.network!)!,
subdomain: i.data.subdomain!,
port: { name: sp[2] },
})),
);
});
};
const services = nodes
.filter((n) => n.type === "app")
.map((n): Service => {
return {
nodeId: n.id,
type: n.data.type,
name: n.data.label,
source:
n.data.repository != undefined
? {
repository: nodes
.filter((i) => i.type === "github")
.find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
branch:
n.data.repository != undefined && "branch" in n.data.repository
? n.data.repository.branch
: "main",
rootDir:
n.data.repository != undefined && "rootDir" in n.data.repository
? n.data.repository.rootDir
: "/",
}
: undefined,
ports: (n.data.ports || [])
.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
.map((p) => ({
name: p.name.toLowerCase(),
value: p.value,
protocol: "TCP", // TODO(gio)
})),
env: (n.data.envVars || [])
.filter((e) => "name" in e)
.map((e) => {
if ("value" in e) {
return { name: e.name, value: e.value };
}
return {
name: e.name,
alias: "alias" in e ? e.alias : undefined,
};
}),
ingress: ingressNodes
.filter((i) => i.data.https!.serviceId === n.id)
.map(
(i): Ingress => ({
nodeId: i.id,
network: networkMap.get(i.data.network!)!,
subdomain: i.data.subdomain!,
port: {
name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name.toLowerCase(),
},
auth:
i.data.auth?.enabled || false
? {
enabled: true,
groups: (i.data.auth!.groups || []).join(","),
noAuthPathPatterns: i.data.auth!.noAuthPathPatterns || [],
}
: {
enabled: false,
},
}),
),
expose: findExpose(n),
preBuildCommands: n.data.preBuildCommands
? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
: [],
dev: n.data.dev?.enabled
? n.data.dev.mode === "VM"
? {
enabled: true,
mode: "VM",
username: env.user.username,
codeServer:
n.data.dev.expose != null
? {
network: networkMap.get(n.data.dev.expose.network)!,
subdomain: n.data.dev.expose.subdomain,
}
: undefined,
ssh:
n.data.dev.expose != null
? {
network: networkMap.get(n.data.dev.expose.network)!,
subdomain: n.data.dev.expose.subdomain,
}
: undefined,
}
: {
enabled: true,
mode: "PROXY",
address: n.data.dev.address,
vpn: {
enabled: true,
username: env.user.username,
},
}
: {
enabled: false,
},
...(n.data.model?.name === "gemini" && {
model: {
name: "gemini",
geminiApiKey: n.data.model.apiKey,
},
}),
...(n.data.model?.name === "claude" && {
model: {
name: "claude",
anthropicApiKey: n.data.model.apiKey,
},
}),
};
});
return {
service: services.filter((s) => !isAgent(s)),
agent: services.filter(isAgent),
volume: nodes
.filter((n) => n.type === "volume")
.map(
(n): Volume => ({
nodeId: n.id,
name: n.data.label,
accessMode: n.data.type,
size: n.data.size,
}),
),
postgresql: nodes
.filter((n) => n.type === "postgresql")
.map(
(n): PostgreSQL => ({
nodeId: n.id,
name: n.data.label,
size: "1Gi", // TODO(gio)
expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
}),
),
mongodb: nodes
.filter((n) => n.type === "mongodb")
.map(
(n): MongoDB => ({
nodeId: n.id,
name: n.data.label,
size: "1Gi", // TODO(gio)
expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
}),
),
};
} catch (e) {
console.log(e);
return null;
}
}
export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
if (current == null) {
current = { nodes: [], edges: [] };
}
const ret: Graph = {
nodes: [],
edges: [],
};
if (networks.length === 0) {
return ret;
}
const repoNodes = [...(config.service || []), ...(config.agent || [])]
.filter((s) => s.source?.repository != null)
.map((s): GithubNode | null => {
const existing = current.nodes.find(
(n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
);
const repo = repos.find((r) => r.ssh_url === s.source!.repository);
if (repo == null) {
return null;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "github",
data: {
label: repo.full_name,
repository: {
id: repo.id,
sshURL: repo.ssh_url,
fullName: repo.full_name,
},
envVars: [],
ports: [],
},
position:
existing != null
? existing.position
: {
x: 0,
y: 0,
},
};
})
.filter((n) => n != null);
const networkNodes = networks.map((n): NetworkNode => {
let existing: NetworkNode | undefined = undefined;
existing = current.nodes
.filter((i): i is NetworkNode => i.type === "network")
.find((i) => i.data.domain === n.domain);
return {
id: n.domain,
type: "network",
data: {
label: n.name,
domain: n.domain,
envVars: [],
ports: [],
},
position: existing != null ? existing.position : { x: 0, y: 0 },
};
});
const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
let existing: ServiceNode | null = null;
if (s.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "app",
data: {
label: s.name,
type: s.type,
repository:
s.source != null
? {
id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
.repository!.id,
repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
.id,
branch: s.source!.branch,
rootDir: s.source!.rootDir,
}
: undefined,
ports: (s.ports || []).map(
(p): Port => ({
id: uuidv4(),
name: p.name,
value: p.value,
}),
),
envVars: (s.env || []).map((e): BoundEnvVar => {
if (e.value != null) {
return {
id: uuidv4(),
source: null,
name: e.name,
value: e.value,
};
} else if (e.alias != null && e.alias !== "") {
return {
id: uuidv4(),
source: null,
name: e.name,
alias: e.alias,
isEditting: false,
};
} else {
return {
id: uuidv4(),
name: e.name,
source: null,
isEditting: false,
};
}
}),
volume: s.volume || [],
preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
...(s.model != null && {
model:
s.model.name === "gemini"
? { name: "gemini", apiKey: s.model.geminiApiKey }
: { name: "claude", apiKey: s.model.anthropicApiKey },
}),
dev: s.dev?.enabled
? s.dev.mode === "VM"
? {
enabled: true,
mode: "VM",
expose: s.dev.ssh
? {
network: s.dev.ssh.network,
subdomain: s.dev.ssh.subdomain,
}
: undefined,
codeServerNodeId: uuidv4(), // TODO: proper node tracking
sshNodeId: uuidv4(), // TODO: proper node tracking
}
: {
enabled: true,
mode: "PROXY",
address: s.dev.address,
}
: {
enabled: false,
},
isChoosingPortToConnect: false,
},
// TODO(gio): generate position
position:
existing != null
? existing.position
: {
x: 0,
y: 0,
},
};
});
const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
(s, index): GatewayHttpsNode[] => {
return (s.ingress || []).map((i): GatewayHttpsNode => {
let existing: GatewayHttpsNode | null = null;
if (i.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "gateway-https",
data: {
label: i.subdomain,
envVars: [],
ports: [],
network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
subdomain: i.subdomain,
https: {
serviceId: services![index]!.id,
portId: services![index]!.data.ports.find((p) => {
const port = i.port;
if ("name" in port) {
return p.name === port.name;
} else {
return `${p.value}` === port.value;
}
})!.id,
},
auth: i.auth.enabled
? {
enabled: true,
groups: (i.auth.groups || "").split(","),
noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
}
: {
enabled: false,
groups: [],
noAuthPathPatterns: [],
},
},
position: {
x: 0,
y: 0,
},
};
});
},
);
const exposures = new Map<string, GatewayTCPNode>();
[...(config.service || []), ...(config.agent || [])]
?.flatMap((s, index): GatewayTCPNode[] => {
return (s.expose || []).map((e): GatewayTCPNode => {
let existing: GatewayTCPNode | null = null;
if (e.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "gateway-tcp",
data: {
label: e.subdomain,
envVars: [],
ports: [],
network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
subdomain: e.subdomain,
exposed: [
{
serviceId: services![index]!.id,
portId: services![index]!.data.ports.find((p) => {
const port = e.port;
if ("name" in port) {
return p.name === port.name;
} else {
return p.value === port.value;
}
})!.id,
},
],
},
position: existing != null ? existing.position : { x: 0, y: 0 },
};
});
})
.forEach((n) => {
const key = `${n.data.network}-${n.data.subdomain}`;
if (!exposures.has(key)) {
exposures.set(key, n);
} else {
exposures.get(key)!.data.exposed.push(...n.data.exposed);
}
});
const volumes = config.volume?.map((v): VolumeNode => {
let existing: VolumeNode | null = null;
if (v.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "volume",
data: {
label: v.name,
type: v.accessMode,
size: v.size,
attachedTo: [],
envVars: [],
ports: [],
},
position:
existing != null
? existing.position
: {
x: 0,
y: 0,
},
};
});
const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
let existing: PostgreSQLNode | null = null;
if (p.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "postgresql",
data: {
label: p.name,
envVars: [],
ports: [
{
id: "connection",
name: "connection",
value: 5432,
},
],
},
position:
existing != null
? existing.position
: {
x: 0,
y: 0,
},
};
});
config.postgresql
?.flatMap((p, index): GatewayTCPNode[] => {
return (p.expose || []).map((e): GatewayTCPNode => {
let existing: GatewayTCPNode | null = null;
if (e.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "gateway-tcp",
data: {
label: e.subdomain,
envVars: [],
ports: [],
network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
subdomain: e.subdomain,
exposed: [
{
serviceId: postgresql![index]!.id,
portId: "connection",
},
],
},
position: existing != null ? existing.position : { x: 0, y: 0 },
};
});
})
.forEach((n) => {
const key = `${n.data.network}-${n.data.subdomain}`;
if (!exposures.has(key)) {
exposures.set(key, n);
} else {
exposures.get(key)!.data.exposed.push(...n.data.exposed);
}
});
const mongodb = config.mongodb?.map((m): MongoDBNode => {
let existing: MongoDBNode | null = null;
if (m.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "mongodb",
data: {
label: m.name,
envVars: [],
ports: [
{
id: "connection",
name: "connection",
value: 27017,
},
],
},
position:
existing != null
? existing.position
: {
x: 0,
y: 0,
},
};
});
config.mongodb
?.flatMap((p, index): GatewayTCPNode[] => {
return (p.expose || []).map((e): GatewayTCPNode => {
let existing: GatewayTCPNode | null = null;
if (e.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
}
return {
id: existing != null ? existing.id : uuidv4(),
type: "gateway-tcp",
data: {
label: e.subdomain,
envVars: [],
ports: [],
network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
subdomain: e.subdomain,
exposed: [
{
serviceId: mongodb![index]!.id,
portId: "connection",
},
],
},
position: existing != null ? existing.position : { x: 0, y: 0 },
};
});
})
.forEach((n) => {
const key = `${n.data.network}-${n.data.subdomain}`;
if (!exposures.has(key)) {
exposures.set(key, n);
} else {
exposures.get(key)!.data.exposed.push(...n.data.exposed);
}
});
ret.nodes = [
...networkNodes,
...repoNodes,
...(services || []),
...(serviceGateways || []),
...(volumes || []),
...(postgresql || []),
...(mongodb || []),
...(exposures.values() || []),
];
services?.forEach((s) => {
s.data.envVars.forEach((e) => {
if (!("name" in e)) {
return;
}
if (!e.name.startsWith("DODO_")) {
return;
}
let r: {
type: string;
name: string;
} | null = null;
if (e.name.startsWith("DODO_PORT_")) {
return;
} else if (e.name.startsWith("DODO_POSTGRESQL_")) {
r = {
type: "postgresql",
name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
};
} else if (e.name.startsWith("DODO_MONGODB_")) {
r = {
type: "mongodb",
name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
};
} else if (e.name.startsWith("DODO_VOLUME_")) {
r = {
type: "volume",
name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
};
}
if (r != null) {
e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
}
});
});
const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
return n.data.envVars.flatMap((e): Edge[] => {
if (e.source == null) {
return [];
}
const sn = ret.nodes.find((n) => n.id === e.source!)!;
const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
return [
{
id: uuidv4(),
source: e.source!,
sourceHandle: sourceHandle,
target: n.id,
targetHandle: "env_var",
},
];
});
});
const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
return n.data.exposed.flatMap((e): Edge[] => {
return [
{
id: uuidv4(),
source: e.serviceId,
sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
target: n.id,
targetHandle: "tcp",
},
{
id: uuidv4(),
source: n.id,
sourceHandle: "subdomain",
target: n.data.network!,
targetHandle: "subdomain",
},
];
});
});
const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
return [
{
id: uuidv4(),
source: n.data.https!.serviceId,
sourceHandle: "ports",
target: n.id,
targetHandle: "https",
},
{
id: uuidv4(),
source: n.id,
sourceHandle: "subdomain",
target: n.data.network!,
targetHandle: "subdomain",
},
];
});
const repoEdges = (services || [])
.map((s): Edge | null => {
if (s.data.repository == null) {
return null;
}
return {
id: uuidv4(),
source: s.data.repository!.repoNodeId!,
sourceHandle: "repository",
target: s.id,
targetHandle: "repository",
};
})
.filter((e) => e != null);
ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
return ret;
}