Init: First Commit
第一条提交
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.0.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const DB_PATH = join(__dirname, "../../data/cicd.db");
|
||||
|
||||
// ensure data dir exists
|
||||
import { mkdirSync } from "fs";
|
||||
mkdirSync(join(__dirname, "../../data"), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||
db.exec(schema);
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
webhook_secret TEXT NOT NULL,
|
||||
trigger_branch TEXT NOT NULL DEFAULT 'main',
|
||||
shell_script TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
trigger_ref TEXT,
|
||||
log TEXT DEFAULT '',
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import Fastify from "fastify";
|
||||
import staticPlugin from "@fastify/static";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
import projectRoutes from "./routes/projects.js";
|
||||
import buildRoutes from "./routes/builds.js";
|
||||
import webhookRoutes from "./routes/webhook.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
// Store raw body for webhook signature verification
|
||||
fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => {
|
||||
req.rawBody = body;
|
||||
try {
|
||||
done(null, JSON.parse(body.toString()));
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
fastify.register(projectRoutes);
|
||||
fastify.register(buildRoutes);
|
||||
fastify.register(webhookRoutes);
|
||||
|
||||
// Serve frontend static files if built
|
||||
const publicDir = join(__dirname, "../public");
|
||||
if (existsSync(publicDir)) {
|
||||
fastify.register(staticPlugin, { root: publicDir, prefix: "/" });
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
if (!req.url.startsWith("/api")) {
|
||||
return reply.sendFile("index.html");
|
||||
}
|
||||
reply.code(404).send({ error: "Not found" });
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
|
||||
if (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import db from "../db/index.js";
|
||||
|
||||
export default async function buildRoutes(fastify) {
|
||||
// List builds for a project
|
||||
fastify.get("/api/projects/:projectId/builds", async (request, reply) => {
|
||||
const { projectId } = request.params;
|
||||
const project = db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId);
|
||||
if (!project) return reply.code(404).send({ error: "Project not found" });
|
||||
const builds = db.prepare("SELECT id, status, trigger_ref, started_at, finished_at FROM builds WHERE project_id = ? ORDER BY id DESC LIMIT 50").all(projectId);
|
||||
return builds;
|
||||
});
|
||||
|
||||
// Get single build (without log)
|
||||
fastify.get("/api/builds/:id", async (request, reply) => {
|
||||
const build = db.prepare("SELECT id, project_id, status, trigger_ref, started_at, finished_at FROM builds WHERE id = ?").get(request.params.id);
|
||||
if (!build) return reply.code(404).send({ error: "Not found" });
|
||||
return build;
|
||||
});
|
||||
|
||||
// SSE: stream build log
|
||||
fastify.get("/api/builds/:id/log", async (request, reply) => {
|
||||
const buildId = request.params.id;
|
||||
const build = db.prepare("SELECT * FROM builds WHERE id = ?").get(buildId);
|
||||
if (!build) return reply.code(404).send({ error: "Not found" });
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
|
||||
const send = (data) => reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
// If already finished, send full log and close
|
||||
if (build.status === "success" || build.status === "failed") {
|
||||
send({ log: build.log, status: build.status, done: true });
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
}
|
||||
|
||||
// Poll DB for new log content while running
|
||||
let sentLength = 0;
|
||||
const interval = setInterval(() => {
|
||||
const current = db.prepare("SELECT log, status FROM builds WHERE id = ?").get(buildId);
|
||||
if (!current) {
|
||||
clearInterval(interval);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const newChunk = current.log.slice(sentLength);
|
||||
if (newChunk) {
|
||||
send({ chunk: newChunk });
|
||||
sentLength = current.log.length;
|
||||
}
|
||||
|
||||
if (current.status === "success" || current.status === "failed") {
|
||||
send({ status: current.status, done: true });
|
||||
clearInterval(interval);
|
||||
reply.raw.end();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
request.raw.on("close", () => clearInterval(interval));
|
||||
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import db from "../db/index.js";
|
||||
|
||||
export default async function projectRoutes(fastify) {
|
||||
// List all projects
|
||||
fastify.get("/api/projects", async () => {
|
||||
return db.prepare("SELECT * FROM projects ORDER BY created_at DESC").all();
|
||||
});
|
||||
|
||||
// Get single project
|
||||
fastify.get("/api/projects/:id", async (request, reply) => {
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
return project;
|
||||
});
|
||||
|
||||
// Create project
|
||||
fastify.post("/api/projects", async (request, reply) => {
|
||||
const { name, trigger_branch = "main", shell_script = "" } = request.body ?? {};
|
||||
if (!name) return reply.code(400).send({ error: "name is required" });
|
||||
const webhook_secret = randomBytes(20).toString("hex");
|
||||
const project = db
|
||||
.prepare(
|
||||
`INSERT INTO projects (name, webhook_secret, trigger_branch, shell_script)
|
||||
VALUES (?, ?, ?, ?) RETURNING *`,
|
||||
)
|
||||
.get(name, webhook_secret, trigger_branch, shell_script);
|
||||
return reply.code(201).send(project);
|
||||
});
|
||||
|
||||
// Update project
|
||||
fastify.put("/api/projects/:id", async (request, reply) => {
|
||||
const { name, trigger_branch, shell_script } = request.body ?? {};
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
const updated = db
|
||||
.prepare(
|
||||
`UPDATE projects SET
|
||||
name = ?, trigger_branch = ?, shell_script = ?
|
||||
WHERE id = ? RETURNING *`,
|
||||
)
|
||||
.get(name ?? project.name, trigger_branch ?? project.trigger_branch, shell_script ?? project.shell_script, request.params.id);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Regenerate webhook secret
|
||||
fastify.post("/api/projects/:id/regenerate-secret", async (request, reply) => {
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
const webhook_secret = randomBytes(20).toString("hex");
|
||||
const updated = db.prepare(`UPDATE projects SET webhook_secret = ? WHERE id = ? RETURNING *`).get(webhook_secret, request.params.id);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete project
|
||||
fastify.delete("/api/projects/:id", async (request, reply) => {
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
db.prepare("DELETE FROM projects WHERE id = ?").run(request.params.id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Manually trigger a build
|
||||
fastify.post("/api/projects/:id/trigger", async (request, reply) => {
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(request.params.id);
|
||||
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||
const { enqueue } = await import("../services/queue.js");
|
||||
const { execute } = await import("../services/executor.js");
|
||||
const build = db.prepare(`INSERT INTO builds (project_id, trigger_ref) VALUES (?, ?) RETURNING *`).get(request.params.id, "manual");
|
||||
enqueue(request.params.id, () => execute(build.id, project.shell_script));
|
||||
return reply.code(202).send({ buildId: build.id });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import db from "../db/index.js";
|
||||
import { enqueue } from "../services/queue.js";
|
||||
import { execute } from "../services/executor.js";
|
||||
|
||||
export default async function webhookRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/api/webhook/:projectId",
|
||||
{
|
||||
config: { rawBody: true },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { projectId } = request.params;
|
||||
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||
|
||||
if (!project) return reply.code(404).send({ error: "Project not found" });
|
||||
|
||||
// Verify Gitea HMAC-SHA256 signature
|
||||
const sig = request.headers["x-gitea-signature"];
|
||||
if (sig) {
|
||||
const expected = createHmac("sha256", project.webhook_secret).update(request.rawBody).digest("hex");
|
||||
try {
|
||||
const sigBuf = Buffer.from(sig, "hex");
|
||||
const expBuf = Buffer.from(expected, "hex");
|
||||
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
|
||||
return reply.code(401).send({ error: "Invalid signature" });
|
||||
}
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "Invalid signature" });
|
||||
}
|
||||
}
|
||||
|
||||
const payload = request.body;
|
||||
const ref = payload?.ref ?? "";
|
||||
const branch = ref.replace("refs/heads/", "");
|
||||
|
||||
if (project.trigger_branch && branch !== project.trigger_branch) {
|
||||
return reply.send({ skipped: true, reason: `branch ${branch} not matched` });
|
||||
}
|
||||
|
||||
const build = db.prepare(`INSERT INTO builds (project_id, trigger_ref) VALUES (?, ?) RETURNING *`).get(projectId, ref);
|
||||
|
||||
enqueue(projectId, () => execute(build.id, project.shell_script));
|
||||
|
||||
return reply.code(202).send({ buildId: build.id });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { spawn } from "child_process";
|
||||
import db from "../db/index.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export async function execute(buildId, script) {
|
||||
db.prepare(`UPDATE builds SET status = 'running', started_at = unixepoch() WHERE id = ?`).run(buildId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("bash", ["-lc", script], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const appendLog = (chunk) => {
|
||||
db.prepare(`UPDATE builds SET log = log || ? WHERE id = ?`).run(chunk, buildId);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (data) => appendLog(data.toString()));
|
||||
child.stderr.on("data", (data) => appendLog(data.toString()));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
appendLog("\n[TIMEOUT] Build exceeded 10 minutes and was killed.\n");
|
||||
}, DEFAULT_TIMEOUT_MS);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const status = code === 0 ? "success" : "failed";
|
||||
db.prepare(`UPDATE builds SET status = ?, finished_at = unixepoch() WHERE id = ?`).run(status, buildId);
|
||||
resolve(status);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Per-project serial queue using promise chaining
|
||||
const queues = new Map();
|
||||
|
||||
export function enqueue(projectId, task) {
|
||||
const prev = queues.get(projectId) ?? Promise.resolve();
|
||||
const next = prev.then(() => task()).catch(() => {});
|
||||
queues.set(projectId, next);
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user