Canvas: Auto register github webhook upon deploy
Change-Id: I0321a032014d58016926189869b0fc24ad7ee2b1
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index d251998..76e250a 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -200,11 +200,22 @@
return { toAdd, toDelete };
}
-async function manageGithubKeys(github: GithubClient, deployKey: string, diff: RepoDiff): Promise<void> {
+async function manageGithubRepos(
+ github: GithubClient,
+ diff: RepoDiff,
+ deployKey: string,
+ publicAddr?: string,
+): Promise<void> {
+ console.log(publicAddr);
for (const repoUrl of diff.toDelete ?? []) {
try {
await github.removeDeployKey(repoUrl, deployKey);
console.log(`Removed deploy key from repository ${repoUrl}`);
+ if (publicAddr) {
+ const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
+ await github.removePushWebhook(repoUrl, webhookCallbackUrl);
+ console.log(`Removed push webhook from repository ${repoUrl}`);
+ }
} catch (error) {
console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
}
@@ -213,6 +224,11 @@
try {
await github.addDeployKey(repoUrl, deployKey);
console.log(`Added deploy key to repository ${repoUrl}`);
+ if (publicAddr) {
+ const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
+ await github.addPushWebhook(repoUrl, webhookCallbackUrl);
+ console.log(`Added push webhook to repository ${repoUrl}`);
+ }
} catch (error) {
console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
}
@@ -283,7 +299,7 @@
}
if (diff && p.githubToken && deployKey) {
const github = new GithubClient(p.githubToken);
- await manageGithubKeys(github, deployKey, diff);
+ await manageGithubRepos(github, diff, deployKey, env.PUBLIC_ADDR);
}
resp.status(200);
} catch (error) {
@@ -372,7 +388,7 @@
const github = new GithubClient(p.githubToken);
const repos = extractGithubRepos(p.state);
const diff = { toDelete: repos, toAdd: [] };
- await manageGithubKeys(github, p.deployKey, diff);
+ await manageGithubRepos(github, diff, p.deployKey, env.PUBLIC_ADDR);
} catch (error) {
console.error("Error removing GitHub deploy keys:", error);
}
@@ -589,45 +605,44 @@
}
};
+async function reloadProject(projectId: number): Promise<boolean> {
+ const projectWorkers = workers.get(projectId) || [];
+ const workerCount = projectWorkers.length;
+ if (workerCount === 0) {
+ return true;
+ }
+ const results = await Promise.all(
+ projectWorkers.map(async (workerAddress) => {
+ const resp = await axios.post(`${workerAddress}/update`);
+ return resp.status === 200;
+ }),
+ );
+ return results.reduce((acc, curr) => acc && curr, true);
+}
+
const handleReload: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
- const projectWorkers = workers.get(projectId) || [];
- const project = await db.project.findUnique({
+ const projectAuth = await db.project.findFirst({
where: {
id: projectId,
userId: resp.locals.userId,
},
+ select: { id: true },
});
- if (project == null) {
+ if (!projectAuth) {
resp.status(404);
- 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;
+ const success = await reloadProject(projectId);
+ if (success) {
+ resp.status(200);
+ } else {
+ resp.status(500);
}
- await Promise.all(
- projectWorkers.map(async (workerAddress) => {
- try {
- const updateEndpoint = `${workerAddress}/update`;
- await axios.post(updateEndpoint);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } catch (error: any) {
- console.log(`Failed to update worker ${workerAddress}: ${error.message || "Unknown error"}`);
- }
- }),
- );
- resp.status(200);
- resp.write(JSON.stringify({ success: true }));
} catch (e) {
- console.log(e);
+ console.error(e);
resp.status(500);
- resp.write(JSON.stringify({ error: "Failed to reload workers" }));
- } finally {
- resp.end();
}
};
@@ -645,36 +660,98 @@
next();
};
+const handleGithubPushWebhook: express.Handler = async (req, resp) => {
+ try {
+ // TODO(gio): Implement GitHub signature verification for security
+ const webhookSchema = z.object({
+ repository: z.object({
+ ssh_url: z.string(),
+ }),
+ });
+
+ const result = webhookSchema.safeParse(req.body);
+ if (!result.success) {
+ console.warn("GitHub webhook: Invalid payload:", result.error.issues);
+ resp.status(400).json({ error: "Invalid webhook payload" });
+ return;
+ }
+ const { ssh_url: addr } = result.data.repository;
+ const allProjects = await db.project.findMany({
+ select: {
+ id: true,
+ state: true,
+ },
+ where: {
+ instanceId: {
+ not: null,
+ },
+ },
+ });
+ // TODO(gio): This should run in background
+ new Promise<boolean>((resolve, reject) => {
+ setTimeout(() => {
+ const projectsToReloadIds: number[] = [];
+ for (const project of allProjects) {
+ if (project.state && project.state.length > 0) {
+ const projectRepos = extractGithubRepos(project.state);
+ if (projectRepos.includes(addr)) {
+ projectsToReloadIds.push(project.id);
+ }
+ }
+ }
+ Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
+ .then((results) => {
+ resolve(results.reduce((acc, curr) => acc && curr, true));
+ })
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .catch((reason: any) => reject(reason));
+ }, 10);
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ console.error(error);
+ resp.status(500);
+ }
+};
+
async function start() {
await db.$connect();
const app = express();
- app.use(express.json());
- app.use(auth);
- app.post("/api/project/:projectId/saved", handleSave);
- app.get("/api/project/:projectId/saved/deploy", handleSavedGet("deploy"));
- app.get("/api/project/:projectId/saved/draft", handleSavedGet("draft"));
- app.post("/api/project/:projectId/deploy", handleDeploy);
- app.get("/api/project/:projectId/status", handleStatus);
- app.delete("/api/project/:projectId", handleDelete);
- app.get("/api/project", handleProjectAll);
- app.post("/api/project", handleProjectCreate);
- app.get("/api/project/:projectId/repos/github", handleGithubRepos);
- app.post("/api/project/:projectId/github-token", handleUpdateGithubToken);
- app.get("/api/project/:projectId/env", handleEnv);
- app.post("/api/project/:projectId/reload", handleReload);
- app.get("/api/project/:projectId/logs/:service", handleServiceLogs);
- app.post("/api/project/:projectId/remove-deployment", handleRemoveDeployment);
+ app.use(express.json()); // Global JSON parsing
+
+ // Public webhook route - no auth needed
+ app.post("/api/webhook/github/push", handleGithubPushWebhook);
+
+ // Authenticated project routes
+ const projectRouter = express.Router();
+ projectRouter.use(auth); // Apply auth middleware to this router
+ projectRouter.post("/:projectId/saved", handleSave);
+ projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
+ projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
+ projectRouter.post("/:projectId/deploy", handleDeploy);
+ projectRouter.get("/:projectId/status", handleStatus);
+ projectRouter.delete("/:projectId", handleDelete);
+ projectRouter.get("/:projectId/repos/github", handleGithubRepos);
+ projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
+ projectRouter.get("/:projectId/env", handleEnv);
+ projectRouter.post("/:projectId/reload", handleReload);
+ projectRouter.get("/:projectId/logs/:service", handleServiceLogs);
+ projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
+ projectRouter.get("/", handleProjectAll);
+ projectRouter.post("/", handleProjectCreate);
+ app.use("/api/project", projectRouter); // Mount the authenticated router
+
app.use("/", express.static("../front/dist"));
- const api = express();
- api.use(express.json());
- api.post("/api/project/:projectId/workers", handleRegisterWorker);
+ const internalApi = express();
+ internalApi.use(express.json());
+ internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
app.listen(env.DODO_PORT_WEB, () => {
console.log("Web server started on port", env.DODO_PORT_WEB);
});
- api.listen(env.DODO_PORT_API, () => {
+ internalApi.listen(env.DODO_PORT_API, () => {
console.log("Internal API server started on port", env.DODO_PORT_API);
});
}