Canvas: Handle repo diff
Refactor github and appmanager clients.
Remove dev mode ports/ingress definitions.
Change-Id: I0ca15cec897d5a8cfa1c89b8ec9c09c408686c64
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
new file mode 100644
index 0000000..2f62ddd
--- /dev/null
+++ b/apps/canvas/back/src/app_manager.ts
@@ -0,0 +1,61 @@
+import axios from "axios";
+import { z } from "zod";
+
+export const DeployResponseSchema = z.object({
+ id: z.string(),
+ deployKey: z.string(),
+});
+
+export type DeployResponse = z.infer<typeof DeployResponseSchema>;
+
+export class AppManager {
+ private baseUrl: string;
+
+ constructor(baseUrl: string = "http://appmanager.hgrz-appmanager.svc.cluster.local") {
+ this.baseUrl = baseUrl;
+ }
+
+ async deploy(config: unknown): Promise<DeployResponse> {
+ const response = await axios.request({
+ url: `${this.baseUrl}/api/dodo-app`,
+ method: "post",
+ data: { config },
+ });
+ if (response.status !== 200) {
+ throw new Error(`Failed to deploy application: ${response.statusText}`);
+ }
+ const result = DeployResponseSchema.safeParse(response.data);
+ if (!result.success) {
+ throw new Error(`Invalid deploy response format: ${result.error.message}`);
+ }
+ return result.data;
+ }
+
+ async update(instanceId: string, config: unknown): Promise<boolean> {
+ const response = await axios.request({
+ url: `${this.baseUrl}/api/dodo-app/${instanceId}`,
+ method: "put",
+ data: { config },
+ });
+ return response.status === 200;
+ }
+
+ async getStatus(instanceId: string): Promise<unknown> {
+ const response = await axios.request({
+ url: `${this.baseUrl}/api/instance/${instanceId}/status`,
+ method: "get",
+ });
+ if (response.status !== 200) {
+ throw new Error(`Failed to get application status: ${response.statusText}`);
+ }
+ return response.data;
+ }
+
+ async removeInstance(instanceId: string): Promise<boolean> {
+ const response = await axios.request({
+ url: `${this.baseUrl}/api/instance/${instanceId}/remove`,
+ method: "post",
+ });
+ return response.status === 200;
+ }
+}
diff --git a/apps/canvas/back/src/github.ts b/apps/canvas/back/src/github.ts
index 850185d..60d2191 100644
--- a/apps/canvas/back/src/github.ts
+++ b/apps/canvas/back/src/github.ts
@@ -9,6 +9,13 @@
ssh_url: z.string(),
});
+const DeployKeysSchema = z.array(
+ z.object({
+ id: z.number(),
+ key: z.string(),
+ }),
+);
+
export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
export class GithubClient {
@@ -35,7 +42,6 @@
async addDeployKey(repoPath: string, key: string) {
const sshUrl = repoPath;
const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
-
await axios.post(
`https://api.github.com/repos/${repoOwnerAndName}/keys`,
{
@@ -48,4 +54,24 @@
},
);
}
+
+ async removeDeployKey(repoPath: string, key: string) {
+ const sshUrl = repoPath;
+ const repoOwnerAndName = sshUrl.replace("git@github.com:", "").replace(".git", "");
+ const response = await axios.get(`https://api.github.com/repos/${repoOwnerAndName}/keys`, {
+ headers: this.getHeaders(),
+ });
+ const result = DeployKeysSchema.safeParse(response.data);
+ if (!result.success) {
+ throw new Error("Failed to parse deploy keys response");
+ }
+ const deployKeys = result.data.filter((k) => k.key === key);
+ await Promise.all(
+ deployKeys.map((deployKey) =>
+ axios.delete(`https://api.github.com/repos/${repoOwnerAndName}/keys/${deployKey.id}`, {
+ headers: this.getHeaders(),
+ }),
+ ),
+ );
+ }
}
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 295bdf3..7c0fffe 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -3,11 +3,12 @@
import { env } from "node:process";
import axios from "axios";
import { GithubClient } from "./github";
+import { AppManager } from "./app_manager";
import { z } from "zod";
const db = new PrismaClient();
+const appManager = new AppManager();
-// Map to store worker addresses by project ID
const workers = new Map<number, string[]>();
const logs = new Map<number, Map<string, string>>();
@@ -152,11 +153,7 @@
if (p.instanceId === null) {
ok = true;
} else {
- const r = await axios.request({
- url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/instance/${p.instanceId}/remove`,
- method: "post",
- });
- ok = r.status === 200;
+ ok = await appManager.removeInstance(p.instanceId);
}
if (ok) {
await db.project.delete({
@@ -174,6 +171,54 @@
}
};
+function extractGithubRepos(serializedState: Buffer | Uint8Array | null): string[] {
+ if (!serializedState) {
+ return [];
+ }
+ try {
+ const stateObj = JSON.parse(serializedState.toString());
+ const githubNodes = stateObj.nodes.filter(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (n: any) => n.type === "github" && n.data?.repository?.id,
+ );
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return githubNodes.map((n: any) => n.data.repository.sshURL);
+ } catch (error) {
+ console.error("Failed to parse state or extract GitHub repos:", error);
+ return [];
+ }
+}
+
+type RepoDiff = {
+ toAdd?: string[];
+ toDelete?: string[];
+};
+
+function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
+ const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
+ const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
+ return { toAdd, toDelete };
+}
+
+async function manageGithubKeys(github: GithubClient, deployKey: string, diff: RepoDiff): Promise<void> {
+ for (const repoUrl of diff.toDelete ?? []) {
+ try {
+ await github.removeDeployKey(repoUrl, deployKey);
+ console.log(`Removed deploy key from repository ${repoUrl}`);
+ } catch (error) {
+ console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
+ }
+ }
+ for (const repoUrl of diff.toAdd ?? []) {
+ try {
+ await github.addDeployKey(repoUrl, deployKey);
+ console.log(`Added deploy key to repository ${repoUrl}`);
+ } catch (error) {
+ console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
+ }
+ }
+}
+
const handleDeploy: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
@@ -187,6 +232,7 @@
instanceId: true,
githubToken: true,
deployKey: true,
+ state: true,
},
});
if (p === null) {
@@ -201,16 +247,11 @@
draft: state,
},
});
- let r: { status: number; data: { id: string; deployKey: string } };
- if (p.instanceId == null) {
- r = await axios.request({
- url: "http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app",
- method: "post",
- data: {
- config: req.body.config,
- },
- });
- if (r.status === 200) {
+ let diff: RepoDiff | null = null;
+ let deployKey: string | null = null;
+ try {
+ if (p.instanceId == null) {
+ const deployResponse = await appManager.deploy(req.body.config);
await db.project.update({
where: {
id: projectId,
@@ -218,50 +259,41 @@
data: {
state,
draft: null,
- instanceId: r.data.id,
- deployKey: r.data.deployKey,
+ instanceId: deployResponse.id,
+ deployKey: deployResponse.deployKey,
},
});
-
- if (p.githubToken && r.data.deployKey) {
- const stateObj = JSON.parse(JSON.parse(state.toString()));
- const githubNodes = stateObj.nodes.filter(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (n: any) => n.type === "github" && n.data?.repository?.id,
- );
-
- const github = new GithubClient(p.githubToken);
- for (const node of githubNodes) {
- try {
- await github.addDeployKey(node.data.repository.sshURL, r.data.deployKey);
- } catch (error) {
- console.error(
- `Failed to add deploy key to repository ${node.data.repository.sshURL}:`,
- error,
- );
- }
- }
+ diff = { toAdd: extractGithubRepos(state) };
+ deployKey = deployResponse.deployKey;
+ } else {
+ const success = await appManager.update(p.instanceId, req.body.config);
+ if (success) {
+ diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
+ deployKey = p.deployKey;
+ await db.project.update({
+ where: {
+ id: projectId,
+ },
+ data: {
+ state,
+ draft: null,
+ },
+ });
+ } else {
+ resp.status(500);
+ resp.write(JSON.stringify({ error: "Failed to update deployment" }));
+ return;
}
}
- } else {
- r = await axios.request({
- url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app/${p.instanceId}`,
- method: "put",
- data: {
- config: req.body.config,
- },
- });
- if (r.status === 200) {
- await db.project.update({
- where: {
- id: projectId,
- },
- data: {
- state,
- draft: null,
- },
- });
+ if (diff && p.githubToken && deployKey) {
+ const github = new GithubClient(p.githubToken);
+ await manageGithubKeys(github, deployKey, diff);
}
+ resp.status(200);
+ } catch (error) {
+ console.error("Deployment error:", error);
+ resp.status(500);
+ resp.write(JSON.stringify({ error: "Deployment failed" }));
}
} catch (e) {
console.log(e);
@@ -291,13 +323,13 @@
resp.status(404);
return;
}
- const r = await axios.request({
- url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/instance/${p.instanceId}/status`,
- method: "get",
- });
- resp.status(r.status);
- if (r.status === 200) {
- resp.write(JSON.stringify(r.data));
+ try {
+ const status = await appManager.getStatus(p.instanceId);
+ resp.status(200);
+ resp.write(JSON.stringify(status));
+ } catch (error) {
+ console.error("Error getting status:", error);
+ resp.status(500);
}
} catch (e) {
console.log(e);
@@ -319,16 +351,13 @@
githubToken: true,
},
});
-
if (!project?.githubToken) {
resp.status(400);
resp.write(JSON.stringify({ error: "GitHub token not configured" }));
return;
}
-
const github = new GithubClient(project.githubToken);
const repositories = await github.getRepositories();
-
resp.status(200);
resp.header("Content-Type", "application/json");
resp.write(JSON.stringify(repositories));
@@ -345,7 +374,6 @@
try {
const projectId = Number(req.params["projectId"]);
const { githubToken } = req.body;
-
await db.project.update({
where: {
id: projectId,
@@ -353,7 +381,6 @@
},
data: { githubToken },
});
-
resp.status(200);
} catch (e) {
console.log(e);
@@ -376,16 +403,13 @@
githubToken: true,
},
});
-
if (!project) {
resp.status(404);
resp.write(JSON.stringify({ error: "Project not found" }));
return;
}
-
const projectLogs = logs.get(projectId) || new Map();
const services = Array.from(projectLogs.keys());
-
resp.status(200);
resp.write(
JSON.stringify({
@@ -406,6 +430,10 @@
},
],
services,
+ user: {
+ id: resp.locals.userId,
+ username: resp.locals.username,
+ },
}),
);
} catch (error) {
@@ -432,21 +460,18 @@
resp.write(JSON.stringify({ error: "Project not found" }));
return;
}
-
const projectLogs = logs.get(projectId);
if (!projectLogs) {
resp.status(404);
resp.write(JSON.stringify({ error: "No logs found for this project" }));
return;
}
-
const serviceLog = projectLogs.get(service);
if (!serviceLog) {
resp.status(404);
resp.write(JSON.stringify({ error: "No logs found for this service" }));
return;
}
-
resp.status(200);
resp.write(JSON.stringify({ logs: serviceLog }));
} catch (e) {
@@ -467,7 +492,6 @@
const handleRegisterWorker: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
-
const result = WorkerSchema.safeParse(req.body);
if (!result.success) {
resp.status(400);
@@ -479,17 +503,11 @@
);
return;
}
-
const { service, address, logs: log } = result.data;
-
- // Get existing workers or initialize empty array
const projectWorkers = workers.get(projectId) || [];
-
- // Add new worker if not already present
if (!projectWorkers.includes(address)) {
projectWorkers.push(address);
}
-
workers.set(projectId, projectWorkers);
if (log) {
const svcLogs: Map<string, string> = logs.get(projectId) || new Map();
@@ -526,13 +544,11 @@
resp.write(JSON.stringify({ error: "Project not found" }));
return;
}
-
if (projectWorkers.length === 0) {
resp.status(404);
resp.write(JSON.stringify({ error: "No workers registered for this project" }));
return;
}
-
await Promise.all(
projectWorkers.map(async (workerAddress) => {
try {
@@ -544,7 +560,6 @@
}
}),
);
-
resp.status(200);
resp.write(JSON.stringify({ success: true }));
} catch (e) {
@@ -558,13 +573,15 @@
const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
const userId = req.get("x-forwarded-userid");
- if (userId === undefined) {
+ const username = req.get("x-forwarded-user");
+ if (userId == null || username == null) {
resp.status(401);
resp.write("Unauthorized");
resp.end();
return;
}
resp.locals.userId = userId;
+ resp.locals.username = username;
next();
};
@@ -592,7 +609,6 @@
api.use(express.json());
api.post("/api/project/:projectId/workers", handleRegisterWorker);
- // Start both servers
app.listen(env.DODO_PORT_WEB, () => {
console.log("Web server started on port", env.DODO_PORT_WEB);
});
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 60d9966..63f6642 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -59,6 +59,7 @@
preBuildCommands?: { bin: string }[];
dev?: {
enabled: boolean;
+ username?: string;
ssh?: Domain;
codeServer?: Domain;
};
@@ -99,8 +100,12 @@
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);
- const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
+ 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])
@@ -134,11 +139,13 @@
branch: n.data.repository.branch,
rootDir: n.data.repository.rootDir,
},
- ports: (n.data.ports || []).map((p) => ({
- name: p.name,
- value: p.value,
- protocol: "TCP", // TODO(gio)
- })),
+ ports: (n.data.ports || [])
+ .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
+ .map((p) => ({
+ name: p.name,
+ value: p.value,
+ protocol: "TCP", // TODO(gio)
+ })),
env: (n.data.envVars || [])
.filter((e) => "name" in e)
.map((e) => ({
@@ -172,17 +179,18 @@
: [],
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: n.data.dev.expose.network,
+ 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: n.data.dev.expose.network,
+ network: networkMap.get(n.data.dev.expose.network)!,
subdomain: n.data.dev.expose.subdomain,
}
: undefined,
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 6e04e4c..7060327 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -361,6 +361,10 @@
github: z.boolean(),
}),
services: z.array(z.string()),
+ user: z.object({
+ id: z.string(),
+ username: z.string(),
+ }),
});
export type Env = z.infer<typeof envSchema>;
@@ -373,6 +377,10 @@
github: false,
},
services: [],
+ user: {
+ id: "",
+ username: "",
+ },
};
export type Project = {