Canvas: Generate graph state out of dodo-app config
Restructure code, create shared config lib.
Change-Id: I2cf06d35c486d4557484daf8618a2c215316fa7e
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
new file mode 100644
index 0000000..53fd64c
--- /dev/null
+++ b/apps/canvas/config/src/config.ts
@@ -0,0 +1,598 @@
+import {
+ AppNode,
+ BoundEnvVar,
+ Env,
+ GatewayHttpsNode,
+ GatewayTCPNode,
+ MongoDBNode,
+ Network,
+ NetworkNode,
+ Port,
+ PostgreSQLNode,
+ ServiceNode,
+ VolumeNode,
+} from "./graph.js";
+import { Edge } from "@xyflow/react";
+import { v4 as uuidv4 } from "uuid";
+import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
+
+export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
+ try {
+ if (appId == null || env.managerAddr == null) {
+ return null;
+ }
+ 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] },
+ })),
+ );
+ });
+ };
+ return {
+ input: {
+ appId: appId,
+ managerAddr: env.managerAddr,
+ },
+ service: nodes
+ .filter((n) => n.type === "app")
+ .map((n): Service => {
+ return {
+ nodeId: n.id,
+ type: n.data.type,
+ name: n.data.label,
+ source: {
+ 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
+ : "/",
+ },
+ 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) => ({
+ 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,
+ },
+ auth:
+ i.data.auth?.enabled || false
+ ? {
+ enabled: true,
+ groups: i.data.auth!.groups,
+ noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
+ }
+ : {
+ enabled: false,
+ },
+ }),
+ ),
+ expose: findExpose(n),
+ preBuildCommands: n.data.preBuildCommands
+ ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
+ : [],
+ dev: {
+ enabled: n.data.dev ? n.data.dev.enabled : false,
+ username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
+ codeServer:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ ssh:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ },
+ };
+ }),
+ 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 { input: { appId: "qweqwe", managerAddr: "" } };
+ }
+}
+
+export type Graph = {
+ nodes: AppNode[];
+ edges: Edge[];
+};
+
+export function configToGraph(config: Config, networks: Network[], current?: Graph): Graph {
+ if (current == null) {
+ current = { nodes: [], edges: [] };
+ }
+ const ret: Graph = {
+ nodes: [],
+ edges: [],
+ };
+ if (networks.length === 0) {
+ return ret;
+ }
+ 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?.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,
+ env: [],
+ ports: (s.ports || []).map(
+ (p): Port => ({
+ id: uuidv4(),
+ name: p.name,
+ value: p.value,
+ }),
+ ),
+ envVars: (s.env || []).map((e): BoundEnvVar => {
+ if (e.alias != null) {
+ return {
+ id: uuidv4(),
+ name: e.name,
+ source: null,
+ 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") || "",
+ // TODO(gio): dev
+ isChoosingPortToConnect: false,
+ },
+ // TODO(gio): generate position
+ position:
+ existing != null
+ ? existing.position
+ : {
+ x: 0,
+ y: 0,
+ },
+ };
+ });
+ const serviceGateways = config.service?.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;
+ }
+ console.log("!!!", i.network, networks);
+ 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 || [],
+ noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
+ }
+ : {
+ enabled: false,
+ groups: [],
+ noAuthPathPatterns: [],
+ },
+ },
+ position: {
+ x: 0,
+ y: 0,
+ },
+ };
+ });
+ });
+ const exposures = new Map<string, GatewayTCPNode>();
+ config.service
+ ?.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,
+ volumeId: "", // TODO(gio): volume
+ 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,
+ volumeId: "", // TODO(gio): volume
+ 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,
+ ...ret.nodes,
+ ...(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",
+ },
+ ];
+ });
+ ret.edges = [...envVarEdges, ...exposureEdges, ...ingressEdges];
+ return ret;
+}
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
new file mode 100644
index 0000000..e8741f9
--- /dev/null
+++ b/apps/canvas/config/src/graph.ts
@@ -0,0 +1,320 @@
+import { z } from "zod";
+import { Node } from "@xyflow/react";
+import { Domain, ServiceType, VolumeType } from "./types.js";
+
+export const serviceAnalyzisSchema = z.object({
+ name: z.string(),
+ location: z.string(),
+ configVars: z.array(
+ z.object({
+ name: z.string(),
+ category: z.enum(["CommandLineFlag", "EnvironmentVariable"]),
+ type: z.optional(z.enum(["String", "Number", "Boolean"])),
+ semanticType: z.optional(
+ z.enum([
+ "EXPANDED_ENV_VAR",
+ "PORT",
+ "FILESYSTEM_PATH",
+ "DATABASE_URL",
+ "SQLITE_PATH",
+ "POSTGRES_URL",
+ "POSTGRES_PASSWORD",
+ "POSTGRES_USER",
+ "POSTGRES_DB",
+ "POSTGRES_PORT",
+ "POSTGRES_HOST",
+ "POSTGRES_SSL",
+ "MONGO_URL",
+ "MONGO_PASSWORD",
+ "MONGO_USER",
+ "MONGO_DB",
+ "MONGO_PORT",
+ "MONGO_HOST",
+ "MONGO_SSL",
+ ]),
+ ),
+ }),
+ ),
+});
+
+export type BoundEnvVar =
+ | {
+ id: string;
+ source: string | null;
+ }
+ | {
+ id: string;
+ source: string | null;
+ name: string;
+ isEditting: boolean;
+ }
+ | {
+ id: string;
+ source: string | null;
+ name: string;
+ alias: string;
+ isEditting: boolean;
+ }
+ | {
+ id: string;
+ source: string | null;
+ portId: string;
+ name: string;
+ alias: string;
+ isEditting: boolean;
+ };
+
+export type EnvVar = {
+ name: string;
+ value: string;
+};
+
+export type InitData = {
+ label: string;
+ envVars: BoundEnvVar[];
+ ports: Port[];
+};
+
+export type NodeData = InitData & {
+ activeField?: string | undefined;
+ state?: string | null;
+};
+
+export type PortConnectedTo = {
+ serviceId: string;
+ portId: string;
+};
+
+export type NetworkData = NodeData & {
+ domain: string;
+};
+
+export type NetworkNode = Node<NetworkData> & {
+ type: "network";
+};
+
+export type GatewayHttpsData = NodeData & {
+ readonly?: boolean;
+ network?: string;
+ subdomain?: string;
+ https?: PortConnectedTo;
+ auth?: {
+ enabled: boolean;
+ groups: string[];
+ noAuthPathPatterns: string[];
+ };
+};
+
+export type GatewayHttpsNode = Node<GatewayHttpsData> & {
+ type: "gateway-https";
+};
+
+export type GatewayTCPData = NodeData & {
+ readonly?: boolean;
+ network?: string;
+ subdomain?: string;
+ exposed: PortConnectedTo[];
+ selected?: {
+ serviceId?: string;
+ portId?: string;
+ };
+};
+
+export type GatewayTCPNode = Node<GatewayTCPData> & {
+ type: "gateway-tcp";
+};
+
+export type Port = {
+ id: string;
+ name: string;
+ value: number;
+};
+
+export type ServiceData = NodeData & {
+ type: ServiceType;
+ repository?:
+ | {
+ id: number;
+ repoNodeId: string;
+ }
+ | {
+ id: number;
+ repoNodeId: string;
+ branch: string;
+ }
+ | {
+ id: number;
+ repoNodeId: string;
+ branch: string;
+ rootDir: string;
+ };
+ env: string[];
+ volume: string[];
+ preBuildCommands: string;
+ isChoosingPortToConnect: boolean;
+ dev?:
+ | {
+ enabled: false;
+ expose?: Domain;
+ }
+ | {
+ enabled: true;
+ expose?: Domain;
+ codeServerNodeId: string;
+ sshNodeId: string;
+ };
+ info?: z.infer<typeof serviceAnalyzisSchema>;
+};
+
+export type ServiceNode = Node<ServiceData> & {
+ type: "app";
+};
+
+export type VolumeData = NodeData & {
+ type: VolumeType;
+ size: string;
+ attachedTo: string[];
+};
+
+export type VolumeNode = Node<VolumeData> & {
+ type: "volume";
+};
+
+export type PostgreSQLData = NodeData & {
+ volumeId: string;
+};
+
+export type PostgreSQLNode = Node<PostgreSQLData> & {
+ type: "postgresql";
+};
+
+export type MongoDBData = NodeData & {
+ volumeId: string;
+};
+
+export type MongoDBNode = Node<MongoDBData> & {
+ type: "mongodb";
+};
+
+export type GithubData = NodeData & {
+ repository?: {
+ id: number;
+ sshURL: string;
+ fullName: string;
+ };
+};
+
+export type GithubNode = Node<GithubData> & {
+ type: "github";
+};
+
+export type NANode = Node<NodeData> & {
+ type: undefined;
+};
+
+export type AppNode =
+ | NetworkNode
+ | GatewayHttpsNode
+ | GatewayTCPNode
+ | ServiceNode
+ | VolumeNode
+ | PostgreSQLNode
+ | MongoDBNode
+ | GithubNode
+ | NANode;
+
+export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
+
+export const networkSchema = z.object({
+ name: z.string().min(1),
+ domain: z.string().min(1),
+ hasAuth: z.boolean(),
+});
+
+export type Network = z.infer<typeof networkSchema>;
+
+export const accessSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("https"),
+ name: z.string(),
+ address: z.string(),
+ }),
+ z.object({
+ type: z.literal("ssh"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("tcp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("udp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("postgresql"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+ z.object({
+ type: z.literal("mongodb"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+]);
+
+export const serviceInfoSchema = z.object({
+ name: z.string(),
+ workers: z.array(
+ z.object({
+ id: z.string(),
+ commit: z.optional(
+ z.object({
+ hash: z.string(),
+ message: z.string(),
+ }),
+ ),
+ commands: z.optional(
+ z.array(
+ z.object({
+ command: z.string(),
+ state: z.string(),
+ }),
+ ),
+ ),
+ }),
+ ),
+});
+
+export const envSchema = z.object({
+ managerAddr: z.optional(z.string().min(1)),
+ instanceId: z.optional(z.string().min(1)),
+ deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
+ networks: z.array(networkSchema).default([]),
+ integrations: z.object({
+ github: z.boolean(),
+ }),
+ services: z.array(serviceInfoSchema),
+ user: z.object({
+ id: z.string(),
+ username: z.string(),
+ }),
+ access: z.array(accessSchema),
+});
+
+export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
+export type Env = z.infer<typeof envSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
new file mode 100644
index 0000000..d064f1c
--- /dev/null
+++ b/apps/canvas/config/src/index.ts
@@ -0,0 +1,51 @@
+export {
+ Auth,
+ AuthDisabled,
+ AuthEnabled,
+ Config,
+ ConfigSchema,
+ ConfigWithInputSchema,
+ Domain,
+ Ingress,
+ MongoDB,
+ PortDomain,
+ PortValue,
+ PostgreSQL,
+ Service,
+ ServiceTypes,
+ Volume,
+ ConfigWithInput,
+ VolumeType,
+} from "./types.js";
+
+export {
+ AppNode,
+ NodeType,
+ Network,
+ ServiceNode,
+ BoundEnvVar,
+ GatewayTCPNode,
+ GatewayHttpsNode,
+ GithubNode,
+ serviceAnalyzisSchema,
+ ServiceData,
+ VolumeNode,
+ PostgreSQLNode,
+ MongoDBNode,
+ Port,
+ EnvVar,
+ NodeData,
+ InitData,
+ NetworkData,
+ GatewayHttpsData,
+ GatewayTCPData,
+ ServiceInfo,
+ Env,
+ VolumeData,
+ PostgreSQLData,
+ MongoDBData,
+ GithubData,
+ envSchema,
+} from "./graph.js";
+
+export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
new file mode 100644
index 0000000..fe417b8
--- /dev/null
+++ b/apps/canvas/config/src/types.ts
@@ -0,0 +1,155 @@
+import { z } from "zod";
+
+const AuthDisabledSchema = z.object({
+ enabled: z.literal(false),
+});
+
+const AuthEnabledSchema = z.object({
+ enabled: z.literal(true),
+ groups: z.array(z.string()),
+ noAuthPathPatterns: z.array(z.string()),
+});
+
+const AuthSchema = z.union([AuthDisabledSchema, AuthEnabledSchema]);
+
+const IngressSchema = z.object({
+ nodeId: z.string().optional(),
+ network: z.string(),
+ subdomain: z.string(),
+ port: z.union([z.object({ name: z.string() }), z.object({ value: z.string() })]),
+ auth: AuthSchema,
+});
+
+const DomainSchema = z.object({
+ nodeId: z.string().optional(),
+ network: z.string(),
+ subdomain: z.string(),
+});
+
+const PortValueSchema = z.union([
+ z.object({
+ name: z.string(),
+ }),
+ z.object({
+ value: z.number(),
+ }),
+]);
+
+const PortDomainSchema = DomainSchema.extend({
+ port: PortValueSchema,
+});
+
+export const ServiceTypes = [
+ "deno:2.2.0",
+ "golang:1.20.0",
+ "golang:1.22.0",
+ "golang:1.24.0",
+ "hugo:latest",
+ "php:8.2-apache",
+ "nextjs:deno-2.0.0",
+ "nodejs:23.1.0",
+ "nodejs:24.0.2",
+] as const;
+
+const ServiceTypeSchema = z.enum(ServiceTypes);
+
+const ServiceSchema = z.object({
+ nodeId: z.string().optional(),
+ type: ServiceTypeSchema,
+ name: z.string(),
+ source: z.object({
+ repository: z.string(),
+ branch: z.string(),
+ rootDir: z.string(),
+ }),
+ ports: z
+ .array(
+ z.object({
+ name: z.string(),
+ value: z.number(),
+ protocol: z.enum(["TCP", "UDP"]),
+ }),
+ )
+ .optional(),
+ env: z
+ .array(
+ z.object({
+ name: z.string(),
+ alias: z.string().optional(),
+ }),
+ )
+ .optional(),
+ ingress: z.array(IngressSchema).optional(),
+ expose: z.array(PortDomainSchema).optional(),
+ volume: z.array(z.string()).optional(),
+ preBuildCommands: z.array(z.object({ bin: z.string() })).optional(),
+ dev: z
+ .object({
+ enabled: z.boolean(),
+ username: z.string().optional(),
+ ssh: DomainSchema.optional(),
+ codeServer: DomainSchema.optional(),
+ })
+ .optional(),
+});
+
+const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
+
+const VolumeSchema = z.object({
+ nodeId: z.string().optional(),
+ name: z.string(),
+ size: z.string(),
+ accessMode: VolumeTypeSchema,
+});
+
+const PostgreSQLSchema = z.object({
+ nodeId: z.string().optional(),
+ name: z.string(),
+ size: z.string(),
+ expose: z.array(DomainSchema).optional(),
+});
+
+const MongoDBSchema = z.object({
+ nodeId: z.string().optional(),
+ name: z.string(),
+ size: z.string(),
+ expose: z.array(DomainSchema).optional(),
+});
+
+export const ConfigSchema = z.object({
+ service: z.array(ServiceSchema).optional(),
+ volume: z.array(VolumeSchema).optional(),
+ postgresql: z.array(PostgreSQLSchema).optional(),
+ mongodb: z.array(MongoDBSchema).optional(),
+});
+
+export const InputSchema = z.object({
+ appId: z.string(),
+ managerAddr: z.string(),
+ key: z
+ .object({
+ public: z.string(),
+ private: z.string(),
+ })
+ .optional(),
+});
+
+export const ConfigWithInputSchema = ConfigSchema.extend({
+ input: InputSchema,
+});
+
+export type AuthDisabled = z.infer<typeof AuthDisabledSchema>;
+export type AuthEnabled = z.infer<typeof AuthEnabledSchema>;
+export type Auth = z.infer<typeof AuthSchema>;
+export type Ingress = z.infer<typeof IngressSchema>;
+export type Domain = z.infer<typeof DomainSchema>;
+export type PortValue = z.infer<typeof PortValueSchema>;
+export type PortDomain = z.infer<typeof PortDomainSchema>;
+export type ServiceType = z.infer<typeof ServiceTypeSchema>;
+export type Service = z.infer<typeof ServiceSchema>;
+export type VolumeType = z.infer<typeof VolumeTypeSchema>;
+export type Volume = z.infer<typeof VolumeSchema>;
+export type PostgreSQL = z.infer<typeof PostgreSQLSchema>;
+export type MongoDB = z.infer<typeof MongoDBSchema>;
+export type Config = z.infer<typeof ConfigSchema>;
+export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;