Canvas: Generate graph state out of dodo-app config
Restructure code, create shared config lib.
Change-Id: I2cf06d35c486d4557484daf8618a2c215316fa7e
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
index 803192c..faf64e2 100644
--- a/apps/canvas/back/src/app_manager.ts
+++ b/apps/canvas/back/src/app_manager.ts
@@ -69,7 +69,6 @@
if (response.status !== 200) {
throw new Error(`Failed to deploy application: ${response.statusText}`);
}
- console.log(response.data);
const result = DeployResponseSchema.safeParse(response.data);
if (!result.success) {
throw new Error(`Invalid deploy response format: ${result.error.message}`);
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index a2e8b31..0ba25b0 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -3,15 +3,16 @@
import fs from "node:fs";
import { env } from "node:process";
import axios from "axios";
-import { GithubClient } from "./github";
-import { AppManager } from "./app_manager";
+import { GithubClient } from "./github.js";
+import { AppManager } from "./app_manager.js";
import { z } from "zod";
-import { ProjectMonitor, WorkerSchema } from "./project_monitor";
+import { ProjectMonitor, WorkerSchema } from "./project_monitor.js";
import tmp from "tmp";
-import { NodeJSAnalyzer } from "./lib/nodejs";
+import { NodeJSAnalyzer } from "./lib/nodejs.js";
import shell from "shelljs";
-import { RealFileSystem } from "./lib/fs";
+import { RealFileSystem } from "./lib/fs.js";
import path from "node:path";
+import { Env, generateDodoConfig, ConfigSchema, AppNode, ConfigWithInput, configToGraph, Network } from "config";
async function generateKey(root: string): Promise<[string, string]> {
const privKeyPath = path.join(root, "key");
@@ -120,31 +121,44 @@
}
resp.status(200);
resp.header("content-type", "application/json");
+ let currentState: Record<string, unknown> | null = null;
if (state === "deploy") {
if (r.state == null) {
- resp.send({
+ currentState = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
- });
+ };
} else {
- resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
+ currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
}
} else {
if (r.draft == null) {
if (r.state == null) {
- resp.send({
+ currentState = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
- });
+ };
} else {
- resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
+ currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
}
} else {
- resp.send(JSON.parse(Buffer.from(r.draft).toString("utf8")));
+ currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
}
}
+ const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
+ if (currentState) {
+ const config = generateDodoConfig(
+ req.params["projectId"].toString(),
+ currentState.nodes as AppNode[],
+ env,
+ );
+ resp.send({
+ state: currentState,
+ config,
+ });
+ }
} catch (e) {
console.log(e);
resp.status(500);
@@ -257,7 +271,6 @@
deployKey: string,
publicAddr?: string,
): Promise<void> {
- console.log(publicAddr);
for (const repoUrl of diff.toDelete ?? []) {
try {
await github.removeDeployKey(repoUrl, deployKey);
@@ -289,11 +302,10 @@
const handleDeploy: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
- const state = JSON.stringify(req.body.state);
const p = await db.project.findUnique({
where: {
id: projectId,
- userId: resp.locals.userId,
+ // userId: resp.locals.userId, TODO(gio): validate
},
select: {
instanceId: true,
@@ -307,6 +319,21 @@
resp.status(404);
return;
}
+ const config = ConfigSchema.safeParse(req.body.config);
+ if (!config.success) {
+ resp.status(400);
+ resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
+ return;
+ }
+ const state = req.body.state
+ ? JSON.stringify(req.body.state)
+ : JSON.stringify(
+ configToGraph(
+ config.data,
+ getNetworks(resp.locals.username),
+ p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
+ ),
+ );
await db.project.update({
where: {
id: projectId,
@@ -325,14 +352,20 @@
});
}
let diff: RepoDiff | null = null;
- const config = req.body.config;
- config.input.key = {
- public: deployKeyPublic,
- private: deployKey,
+ const cfg: ConfigWithInput = {
+ ...config.data,
+ input: {
+ appId: projectId.toString(),
+ managerAddr: env.INTERNAL_API_ADDR!,
+ key: {
+ public: deployKeyPublic!,
+ private: deployKey!,
+ },
+ },
};
try {
if (p.instanceId == null) {
- const deployResponse = await appManager.deploy(config);
+ const deployResponse = await appManager.deploy(cfg);
await db.project.update({
where: {
id: projectId,
@@ -346,7 +379,7 @@
});
diff = { toAdd: extractGithubRepos(state) };
} else {
- const deployResponse = await appManager.update(p.instanceId, config);
+ const deployResponse = await appManager.update(p.instanceId, cfg);
diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
await db.project.update({
where: {
@@ -413,6 +446,41 @@
}
};
+const handleConfigGet: express.Handler = async (req, resp) => {
+ try {
+ const projectId = Number(req.params["projectId"]);
+ const project = await db.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ select: {
+ state: true,
+ },
+ });
+
+ if (!project || !project.state) {
+ resp.status(404).send({ error: "No deployed configuration found." });
+ return;
+ }
+
+ const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
+ const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
+ const config = generateDodoConfig(projectId.toString(), state.nodes, env);
+
+ if (!config) {
+ resp.status(500).send({ error: "Failed to generate configuration." });
+ return;
+ }
+ resp.status(200).json(config);
+ } catch (e) {
+ console.log(e);
+ resp.status(500).send({ error: "Internal server error" });
+ } finally {
+ console.log("config get done");
+ resp.end();
+ }
+};
+
const handleRemoveDeployment: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
@@ -529,80 +597,84 @@
}
};
+const getNetworks = (username?: string | undefined): Network[] => {
+ return [
+ {
+ name: "Trial",
+ domain: "trial.dodoapp.xyz",
+ hasAuth: false,
+ },
+ // TODO(gio): Remove
+ ].concat(
+ username === "gio" || 1 == 1
+ ? [
+ {
+ name: "Public",
+ domain: "v1.dodo.cloud",
+ hasAuth: true,
+ },
+ {
+ name: "Private",
+ domain: "p.v1.dodo.cloud",
+ hasAuth: true,
+ },
+ ]
+ : [],
+ );
+};
+
+const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
+ const project = await db.project.findUnique({
+ where: {
+ id: projectId,
+ userId,
+ },
+ select: {
+ deployKeyPublic: true,
+ githubToken: true,
+ access: true,
+ instanceId: true,
+ },
+ });
+ if (!project) {
+ throw new Error("Project not found");
+ }
+ const monitor = projectMonitors.get(projectId);
+ const serviceNames = monitor ? monitor.getAllServiceNames() : [];
+ const services = serviceNames.map((name: string) => ({
+ name,
+ workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
+ ([id, status]) => ({
+ ...status,
+ id,
+ }),
+ ),
+ }));
+ return {
+ managerAddr: env.INTERNAL_API_ADDR,
+ deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
+ instanceId: project.instanceId == null ? undefined : project.instanceId,
+ access: JSON.parse(project.access ?? "[]"),
+ integrations: {
+ github: !!project.githubToken,
+ },
+ networks: getNetworks(username),
+ services,
+ user: {
+ id: userId,
+ username: username,
+ },
+ };
+};
+
const handleEnv: express.Handler = async (req, resp) => {
const projectId = Number(req.params["projectId"]);
try {
- const project = await db.project.findUnique({
- where: {
- id: projectId,
- userId: resp.locals.userId,
- },
- select: {
- deployKeyPublic: true,
- githubToken: true,
- access: true,
- instanceId: true,
- },
- });
- if (!project) {
- resp.status(404);
- resp.write(JSON.stringify({ error: "Project not found" }));
- return;
- }
- const monitor = projectMonitors.get(projectId);
- const serviceNames = monitor ? monitor.getAllServiceNames() : [];
- const services = serviceNames.map((name) => ({
- name,
- workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
- ([id, status]) => ({
- ...status,
- id,
- }),
- ),
- }));
-
+ const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
resp.status(200);
- resp.write(
- JSON.stringify({
- managerAddr: env.INTERNAL_API_ADDR,
- deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
- instanceId: project.instanceId == null ? undefined : project.instanceId,
- access: JSON.parse(project.access ?? "[]"),
- integrations: {
- github: !!project.githubToken,
- },
- networks: [
- {
- name: "Trial",
- domain: "trial.dodoapp.xyz",
- hasAuth: false,
- },
- // TODO(gio): Remove
- ].concat(
- resp.locals.username !== "gio"
- ? []
- : [
- {
- name: "Public",
- domain: "v1.dodo.cloud",
- hasAuth: true,
- },
- {
- name: "Private",
- domain: "p.v1.dodo.cloud",
- hasAuth: true,
- },
- ],
- ),
- services,
- user: {
- id: resp.locals.userId,
- username: resp.locals.username,
- },
- }),
- );
+ resp.write(JSON.stringify(env));
} catch (error) {
- console.error("Error checking integrations:", error);
+ console.error("Error getting env:", error);
resp.status(500);
resp.write(JSON.stringify({ error: "Internal server error" }));
} finally {
@@ -692,12 +764,17 @@
return true;
}
const results = await Promise.all(
- projectWorkers.map(async (workerAddress) => {
- const resp = await axios.post(`${workerAddress}/update`);
- return resp.status === 200;
+ projectWorkers.map(async (workerAddress: string) => {
+ try {
+ const { data } = await axios.get(`http://${workerAddress}/reload`);
+ return data.every((s: { status: string }) => s.status === "ok");
+ } catch (error) {
+ console.error(`Failed to reload worker ${workerAddress}:`, error);
+ return false;
+ }
}),
);
- return results.reduce((acc, curr) => acc && curr, true);
+ return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
}
const handleReload: express.Handler = async (req, resp) => {
@@ -886,9 +963,30 @@
}
};
+const handleValidateConfig: express.Handler = async (req, resp) => {
+ try {
+ const validationResult = ConfigSchema.safeParse(req.body);
+ if (!validationResult.success) {
+ resp.status(400);
+ resp.header("Content-Type", "application/json");
+ resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
+ } else {
+ resp.status(200);
+ resp.header("Content-Type", "application/json");
+ resp.write(JSON.stringify({ success: true }));
+ }
+ } catch (e) {
+ console.log(e);
+ resp.status(500);
+ } finally {
+ resp.end();
+ }
+};
+
async function start() {
await db.$connect();
const app = express();
+ app.set("json spaces", 2);
app.use(express.json()); // Global JSON parsing
// Public webhook route - no auth needed
@@ -903,6 +1001,7 @@
projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
projectRouter.post("/:projectId/deploy", handleDeploy);
projectRouter.get("/:projectId/status", handleStatus);
+ projectRouter.get("/:projectId/config", handleConfigGet);
projectRouter.delete("/:projectId", handleProjectDelete);
projectRouter.get("/:projectId/repos/github", handleGithubRepos);
projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
@@ -921,6 +1020,9 @@
const internalApi = express();
internalApi.use(express.json());
internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
+ internalApi.get("/api/project/:projectId/config", handleConfigGet);
+ internalApi.post("/api/project/:projectId/deploy", handleDeploy);
+ internalApi.post("/api/validate-config", handleValidateConfig);
app.listen(env.DODO_PORT_WEB, () => {
console.log("Web server started on port", env.DODO_PORT_WEB);
diff --git a/apps/canvas/back/src/lib/analyze.ts b/apps/canvas/back/src/lib/analyze.ts
index e1fa699..28eecbf 100644
--- a/apps/canvas/back/src/lib/analyze.ts
+++ b/apps/canvas/back/src/lib/analyze.ts
@@ -1,4 +1,4 @@
-import { FileSystem } from "./fs";
+import { FileSystem } from "./fs.js";
export interface ServiceAnalyzer {
detect: (fs: FileSystem, root: string) => boolean;
diff --git a/apps/canvas/back/src/lib/nodejs.test.ts b/apps/canvas/back/src/lib/nodejs.test.ts
index 7d406b1..5de8423 100644
--- a/apps/canvas/back/src/lib/nodejs.test.ts
+++ b/apps/canvas/back/src/lib/nodejs.test.ts
@@ -1,8 +1,8 @@
-import { NodeJSAnalyzer } from "./nodejs";
-import { FileSystem, RealFileSystem } from "./fs";
+import { NodeJSAnalyzer } from "./nodejs.js";
+import { FileSystem, RealFileSystem } from "./fs.js";
import { Volume, IFs, createFsFromVolume } from "memfs";
import { test, expect } from "@jest/globals";
-import { expandValue } from "./env";
+import { expandValue } from "./env.js";
import shell from "shelljs";
class InMemoryFileSystem implements FileSystem {
diff --git a/apps/canvas/back/src/lib/nodejs.ts b/apps/canvas/back/src/lib/nodejs.ts
index 07e6c1f..3369b1d 100644
--- a/apps/canvas/back/src/lib/nodejs.ts
+++ b/apps/canvas/back/src/lib/nodejs.ts
@@ -1,10 +1,10 @@
import path from "path";
-import { FileSystem } from "./fs";
-import { ServiceAnalyzer, ConfigVar, ConfigVarCategory, ConfigVarSemanticType } from "./analyze";
+import { FileSystem } from "./fs.js";
+import { ServiceAnalyzer, ConfigVar, ConfigVarCategory, ConfigVarSemanticType } from "./analyze.js";
import { parse as parseDotenv } from "dotenv";
import { parsePrismaSchema } from "@loancrate/prisma-schema-parser";
-import { augmentConfigVar } from "./semantics";
-import { expandValue } from "./env";
+import { augmentConfigVar } from "./semantics.js";
+import { expandValue } from "./env.js";
import { z } from "zod";
const packageJsonFileName = "package.json";
diff --git a/apps/canvas/back/src/lib/semantics.ts b/apps/canvas/back/src/lib/semantics.ts
index 2cbd1ab..f365f83 100644
--- a/apps/canvas/back/src/lib/semantics.ts
+++ b/apps/canvas/back/src/lib/semantics.ts
@@ -1,4 +1,4 @@
-import { ConfigVar, ConfigVarSemanticType } from "./analyze";
+import { ConfigVar, ConfigVarSemanticType } from "./analyze.js";
export function augmentConfigVar(cv: ConfigVar) {
if (cv.semanticType != null) {