Canvas: Use GraphSchema to validate state
Change-Id: I342c8959c97f3486c4a7cb2aff92fb930a2b3146
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 38122f8..9456d17 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -24,6 +24,7 @@
} from "config";
import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
import LogStore from "./log.js";
+import { GraphOrConfigSchema, GraphSchema } from "config/dist/graph.js";
async function generateKey(root: string): Promise<[string, string]> {
const privKeyPath = path.join(root, "key");
@@ -42,6 +43,13 @@
const projectMonitors = new Map<number, ProjectMonitor>();
+function parseGraph(data: string | null | undefined) {
+ if (data == null) {
+ return null;
+ }
+ return GraphSchema.safeParse(JSON.parse(data));
+}
+
const handleProjectCreate: express.Handler = async (req, resp) => {
try {
const tmpDir = tmp.dirSync().name;
@@ -94,26 +102,6 @@
}
};
-const handleSave: express.Handler = async (req, resp) => {
- try {
- await db.project.update({
- where: {
- id: Number(req.params["projectId"]),
- userId: resp.locals.userId,
- },
- data: {
- draft: JSON.stringify(req.body),
- },
- });
- resp.status(200);
- } catch (e) {
- console.log(e);
- resp.status(500);
- } finally {
- resp.end();
- }
-};
-
async function getState(projectId: number, userId: string, state: "deploy" | "draft"): Promise<Graph | null> {
const r = await db.project.findUnique({
where: {
@@ -131,7 +119,7 @@
let currentState: Graph | null = null;
if (state === "deploy") {
if (r.state != null) {
- currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
+ currentState = parseGraph(r.state)!.data!;
}
} else {
if (r.draft == null) {
@@ -142,10 +130,10 @@
viewport: { x: 0, y: 0, zoom: 1 },
};
} else {
- currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
+ currentState = parseGraph(r.state)!.data!;
}
} else {
- currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
+ currentState = parseGraph(r.draft)!.data!;
}
}
return currentState;
@@ -350,7 +338,7 @@
config.data,
getNetworks(resp.locals.username),
repos,
- p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
+ p.state ? parseGraph(p.state)!.data! : undefined,
),
);
await db.project.update({
@@ -431,13 +419,13 @@
}
};
-const handleSaveFromConfig: express.Handler = async (req, resp) => {
+const handleSave: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
const p = await db.project.findUnique({
where: {
id: projectId,
- // userId: resp.locals.userId, TODO(gio): validate
+ userId: resp.locals.userId,
},
select: {
instanceId: true,
@@ -453,10 +441,18 @@
resp.status(404);
return;
}
- const config = ConfigSchema.safeParse(req.body.config);
- if (!config.success) {
+ const gc = GraphOrConfigSchema.safeParse(req.body);
+ if (!gc.success) {
resp.status(400);
- resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
+ resp.write(JSON.stringify({ error: "Invalid configuration", issues: gc.error.format() }));
+ return;
+ }
+ if (gc.data.type === "graph") {
+ await db.project.update({
+ where: { id: projectId },
+ data: { draft: JSON.stringify(gc.data.graph) },
+ });
+ resp.status(200);
return;
}
let repos: GithubRepository[] = [];
@@ -466,10 +462,10 @@
}
const state = JSON.stringify(
configToGraph(
- config.data,
+ gc.data.config,
getNetworks(resp.locals.username),
repos,
- p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
+ p.state ? parseGraph(p.state)!.data! : undefined,
),
);
await db.project.update({
@@ -538,7 +534,7 @@
return;
}
- const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
+ const state = parseGraph(project.state)!.data!;
const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
const config = generateDodoConfig(projectId.toString(), state.nodes, env);
@@ -1204,7 +1200,6 @@
const projectRouter = express.Router();
projectRouter.use(auth);
projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
- projectRouter.post("/:projectId/saved/config", handleSaveFromConfig);
projectRouter.post("/:projectId/saved", handleSave);
projectRouter.get("/:projectId/state/stream/deploy", handleStateGetStream("deploy"));
projectRouter.get("/:projectId/state/stream/draft", handleStateGetStream("draft"));
@@ -1235,6 +1230,7 @@
internalApi.use(express.json());
internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
internalApi.get("/api/project/:projectId/config", handleConfigGet);
+ internalApi.post("/api/project/:projectId/saved", handleSave);
internalApi.post("/api/project/:projectId/deploy", handleDeploy);
internalApi.post("/api/validate-config", handleValidateConfig);
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index dcc4318..d91b535 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -2,6 +2,7 @@
AppNode,
BoundEnvVar,
Env,
+ Edge,
GatewayHttpsNode,
GatewayTCPNode,
GithubNode,
@@ -12,8 +13,8 @@
PostgreSQLNode,
ServiceNode,
VolumeNode,
+ Graph,
} from "./graph.js";
-import { Edge } from "@xyflow/react";
import { v4 as uuidv4 } from "uuid";
import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
import { GithubRepository } from "./github.js";
@@ -186,12 +187,6 @@
}
}
-export type Graph = {
- nodes: AppNode[];
- edges: Edge[];
- viewport?: { x: number; y: number; zoom: number };
-};
-
export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
if (current == null) {
current = { nodes: [], edges: [] };
@@ -264,7 +259,6 @@
data: {
label: s.name,
type: s.type,
- env: [],
repository:
s.source != null
? {
@@ -454,7 +448,6 @@
type: "postgresql",
data: {
label: p.name,
- volumeId: "", // TODO(gio): volume
envVars: [],
ports: [
{
@@ -518,7 +511,6 @@
type: "mongodb",
data: {
label: m.name,
- volumeId: "", // TODO(gio): volume
envVars: [],
ports: [
{
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index c36dbcf..e309926 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -1,6 +1,14 @@
import { z } from "zod";
import { Node } from "@xyflow/react";
-import { Domain, ServiceType, VolumeType } from "./types.js";
+import {
+ ConfigSchema,
+ Domain,
+ DomainSchema,
+ ServiceType,
+ ServiceTypeSchema,
+ VolumeType,
+ VolumeTypeSchema,
+} from "./types.js";
export const serviceAnalyzisSchema = z.object({
name: z.string(),
@@ -37,6 +45,41 @@
),
});
+export const BoundEnvVarSchema = z.union([
+ z.object({
+ id: z.string(),
+ source: z.null(),
+ name: z.string(),
+ value: z.string(),
+ isEditting: z.boolean().optional(),
+ }),
+ z.object({
+ id: z.string(),
+ source: z.string().nullable(),
+ portId: z.string(),
+ name: z.string(),
+ alias: z.string(),
+ isEditting: z.boolean(),
+ }),
+ z.object({
+ id: z.string(),
+ source: z.string().nullable(),
+ name: z.string(),
+ alias: z.string(),
+ isEditting: z.boolean(),
+ }),
+ z.object({
+ id: z.string(),
+ source: z.string().nullable(),
+ name: z.string(),
+ isEditting: z.boolean(),
+ }),
+ z.object({
+ id: z.string(),
+ source: z.string().nullable(),
+ }),
+]);
+
export type BoundEnvVar =
| {
id: string;
@@ -131,11 +174,13 @@
type: "gateway-tcp";
};
-export type Port = {
- id: string;
- name: string;
- value: number;
-};
+export const PortSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ value: z.number(),
+});
+
+export type Port = z.infer<typeof PortSchema>;
export type ServiceData = NodeData & {
type: ServiceType;
@@ -155,10 +200,9 @@
branch: string;
rootDir: string;
};
- env: string[];
- volume: string[];
- preBuildCommands: string;
- isChoosingPortToConnect: boolean;
+ volume?: string[];
+ preBuildCommands?: string;
+ isChoosingPortToConnect?: boolean;
dev?:
| {
enabled: false;
@@ -191,17 +235,13 @@
type: "volume";
};
-export type PostgreSQLData = NodeData & {
- volumeId: string;
-};
+export type PostgreSQLData = NodeData & {};
export type PostgreSQLNode = Node<PostgreSQLData> & {
type: "postgresql";
};
-export type MongoDBData = NodeData & {
- volumeId: string;
-};
+export type MongoDBData = NodeData & {};
export type MongoDBNode = Node<MongoDBData> & {
type: "mongodb";
@@ -236,13 +276,13 @@
export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
-export const networkSchema = z.object({
+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 type Network = z.infer<typeof NetworkSchema>;
export const accessSchema = z.discriminatedUnion("type", [
z.object({
@@ -340,3 +380,225 @@
export type Env = z.infer<typeof envSchema>;
export type Access = z.infer<typeof accessSchema>;
export type AgentAccess = Required<Extract<Access, { type: "https" }>>;
+
+const NodeBaseSchema = z.object({
+ id: z.string(),
+ position: z.object({
+ x: z.number(),
+ y: z.number(),
+ }),
+});
+
+export const NodeBaseDataSchema = z.object({
+ label: z.string(),
+ envVars: z.array(BoundEnvVarSchema),
+ ports: z.array(PortSchema),
+});
+
+export const NetworkNodeSchema = z
+ .object({
+ type: z.literal("network"),
+ data: z
+ .object({
+ domain: z.string(),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const GithubNodeSchema = z
+ .object({
+ type: z.literal("github"),
+ data: z
+ .object({
+ repository: z
+ .object({
+ id: z.number(),
+ sshURL: z.string(),
+ fullName: z.string(),
+ })
+ .optional(),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const GatewayHttpsNodeSchema = z
+ .object({
+ type: z.literal("gateway-https"),
+ data: z
+ .object({
+ readonly: z.boolean().optional(), // TODO: remove this
+ network: z.string().optional(),
+ subdomain: z.string().optional(),
+ https: z
+ .object({
+ serviceId: z.string(),
+ portId: z.string(),
+ })
+ .optional(),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const GatewayTCPNodeSchema = z
+ .object({
+ type: z.literal("gateway-tcp"),
+ data: z
+ .object({
+ readonly: z.boolean().optional(),
+ network: z.string().optional(),
+ subdomain: z.string().optional(),
+ exposed: z.array(
+ z.object({
+ serviceId: z.string(),
+ portId: z.string(),
+ }),
+ ),
+ selected: z
+ .object({
+ serviceId: z.string().optional(),
+ portId: z.string().optional(),
+ })
+ .optional(),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const ServiceNodeSchema = z
+ .object({
+ type: z.literal("app"),
+ data: z
+ .object({
+ type: ServiceTypeSchema,
+ repository: z
+ .union([
+ z.object({
+ id: z.number(),
+ repoNodeId: z.string(),
+ branch: z.string(),
+ rootDir: z.string(),
+ }),
+ z.object({
+ id: z.number(),
+ repoNodeId: z.string(),
+ branch: z.string(),
+ }),
+ z.object({
+ id: z.number(),
+ repoNodeId: z.string(),
+ }),
+ ])
+ .optional(),
+ volume: z.array(z.string()).optional(),
+ preBuildCommands: z.string().optional(),
+ isChoosingPortToConnect: z.boolean().optional(),
+ dev: z
+ .discriminatedUnion("enabled", [
+ z.object({
+ enabled: z.literal(false),
+ expose: DomainSchema.optional(),
+ }),
+ z.object({
+ enabled: z.literal(true),
+ expose: DomainSchema.optional(),
+ codeServerNodeId: z.string(),
+ sshNodeId: z.string(),
+ }),
+ ])
+ .optional(),
+ model: z
+ .object({
+ name: z.enum(["gemini", "claude"]),
+ apiKey: z.string().optional(),
+ })
+ .optional(),
+ info: serviceAnalyzisSchema.optional(),
+
+ ports: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ value: z.number(),
+ }),
+ ),
+ activeField: z.string().optional(),
+ state: z.string().nullable().optional(),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const VolumeNodeSchema = z
+ .object({
+ type: z.literal("volume"),
+ data: z
+ .object({
+ size: z.string(),
+ type: VolumeTypeSchema,
+ attachedTo: z.array(z.string()),
+ })
+ .extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const PostgreSQLNodeSchema = z
+ .object({
+ type: z.literal("postgresql"),
+ data: z.object({}).extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const MongoDBNodeSchema = z
+ .object({
+ type: z.literal("mongodb"),
+ data: z.object({}).extend(NodeBaseDataSchema.shape),
+ })
+ .extend(NodeBaseSchema.shape);
+
+export const NodeSchema = z.discriminatedUnion("type", [
+ NetworkNodeSchema,
+ GithubNodeSchema,
+ GatewayHttpsNodeSchema,
+ GatewayTCPNodeSchema,
+ ServiceNodeSchema,
+ VolumeNodeSchema,
+ PostgreSQLNodeSchema,
+ MongoDBNodeSchema,
+]);
+
+export const EdgeSchema = z.object({
+ id: z.string(),
+ source: z.string(),
+ sourceHandle: z.string().optional(),
+ target: z.string(),
+ targetHandle: z.string().optional(),
+});
+
+export const GraphSchema = z.object({
+ nodes: z.array(NodeSchema),
+ edges: z.array(EdgeSchema),
+ viewport: z
+ .object({
+ x: z.number(),
+ y: z.number(),
+ zoom: z.number(),
+ })
+ .optional(),
+});
+
+export const GraphOrConfigSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("graph"),
+ graph: GraphSchema,
+ }),
+ z.object({
+ type: z.literal("config"),
+ config: ConfigSchema,
+ }),
+]);
+
+export type Edge = z.infer<typeof EdgeSchema>;
+export type Graph = z.infer<typeof GraphSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index 569d347..4a6121e 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -50,9 +50,12 @@
accessSchema,
Access,
AgentAccess,
+ Graph,
+ Edge,
+ GraphOrConfigSchema,
} from "./graph.js";
-export { generateDodoConfig, configToGraph, Graph } from "./config.js";
+export { generateDodoConfig, configToGraph } from "./config.js";
export {
GithubRepository,
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index 15bf22d..bbf851b 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -20,7 +20,7 @@
auth: AuthSchema,
});
-const DomainSchema = z.object({
+export const DomainSchema = z.object({
nodeId: z.string().optional(),
network: z.string(),
subdomain: z.string(),
@@ -52,7 +52,7 @@
"sketch:latest",
] as const;
-const ServiceTypeSchema = z.enum(ServiceTypes);
+export const ServiceTypeSchema = z.enum(ServiceTypes);
const ModelSchema = z.discriminatedUnion("name", [
z.object({
@@ -114,7 +114,7 @@
model: ModelSchema,
});
-const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
+export const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
const VolumeSchema = z.object({
nodeId: z.string().optional(),
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index 5ea2920..f283ec8 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -139,7 +139,10 @@
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify(instance.toObject()),
+ body: JSON.stringify({
+ type: "graph",
+ graph: instance.toObject(),
+ }),
});
if (resp.ok) {
info("Save succeeded");
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 0132721..5e9315b 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -539,13 +539,6 @@
attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
});
}
- if (c.targetHandle === "volume") {
- if (tn.type === "postgresql" || tn.type === "mongodb") {
- updateNodeData(c.target, {
- volumeId: c.source,
- });
- }
- }
if (c.targetHandle === "https") {
if ((sn.data.ports || []).length === 1) {
updateNodeData<"gateway-https">(c.target, {