CI/CD: add deploy webhook endpoint for Gitea auto-deploy
This commit is contained in:
@@ -7,8 +7,6 @@ const rateLimit = require("@fastify/rate-limit");
|
|||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
const Redis = require("ioredis");
|
const Redis = require("ioredis");
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
|
||||||
|
|
||||||
// Database pool
|
// Database pool
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: process.env.DATABASE_URL || "postgresql://taskteam:TaskTeam2026!@10.10.10.10:5432/taskteam"
|
connectionString: process.env.DATABASE_URL || "postgresql://taskteam:TaskTeam2026!@10.10.10.10:5432/taskteam"
|
||||||
@@ -18,50 +16,15 @@ const pool = new Pool({
|
|||||||
const redis = new Redis(process.env.REDIS_URL || "redis://:Redis2026!@10.10.10.10:6379");
|
const redis = new Redis(process.env.REDIS_URL || "redis://:Redis2026!@10.10.10.10:6379");
|
||||||
|
|
||||||
redis.on("connect", () => {
|
redis.on("connect", () => {
|
||||||
app.log.info("Redis connected");
|
console.log("Redis connected");
|
||||||
});
|
});
|
||||||
redis.on("error", (err) => {
|
redis.on("error", (err) => {
|
||||||
app.log.error("Redis error: " + err.message);
|
console.error("Redis error: " + err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plugins
|
|
||||||
app.register(cors, { origin: true });
|
|
||||||
app.register(rateLimit, {
|
|
||||||
max: 100,
|
|
||||||
timeWindow: "1 minute",
|
|
||||||
keyGenerator: (req) => req.ip,
|
|
||||||
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
|
|
||||||
});
|
|
||||||
app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
|
||||||
|
|
||||||
// Decorate with db and redis
|
|
||||||
app.decorate("db", pool);
|
|
||||||
app.decorate("redis", redis);
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get("/health", async () => ({
|
|
||||||
status: "ok",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
pid: process.pid,
|
|
||||||
redis: redis.status
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Register routes
|
|
||||||
app.register(require("./routes/tasks"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/groups"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/auth"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/connectors"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/connectors/odoo"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/connectors/moodle"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/connectors/pohoda"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/chat"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/notifications"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/goals"), { prefix: "/api/v1" });
|
|
||||||
app.register(require("./routes/system"), { prefix: "/api/v1" });
|
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const shutdown = async (signal) => {
|
const shutdown = async (signal) => {
|
||||||
app.log.info(`Received ${signal}, shutting down gracefully...`);
|
console.log(`Received ${signal}, shutting down gracefully...`);
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -71,11 +34,49 @@ process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|||||||
|
|
||||||
// Start
|
// Start
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// Decorate with db and redis
|
||||||
|
app.decorate("db", pool);
|
||||||
|
app.decorate("redis", redis);
|
||||||
|
|
||||||
|
// Plugins (must await for Fastify 5 compatibility)
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
|
||||||
|
});
|
||||||
|
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
||||||
|
|
||||||
|
// Health check (excluded from rate limit)
|
||||||
|
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pid: process.pid,
|
||||||
|
redis: redis.status
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
await app.register(require("./routes/tasks"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/groups"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/auth"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/connectors"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/connectors/odoo"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/connectors/moodle"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/connectors/pohoda"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/chat"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/notifications"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/goals"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/deploy"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/system"), { prefix: "/api/v1" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
||||||
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
// Task Team — Deploy webhook — 2026-03-29
|
// Task Team — Deploy webhook — 2026-03-29
|
||||||
const { execSync } = require("child_process");
|
const { exec, execSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
|
const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
|
||||||
const EXEC_OPTS = { encoding: "utf8", env: { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } };
|
const EXEC_ENV = { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", HOME: "/root" };
|
||||||
|
const DEPLOY_LOG = "/opt/task-team/deploy.log";
|
||||||
|
|
||||||
|
function runCmd(cmd, timeout) {
|
||||||
|
return execSync(cmd, { encoding: "utf8", env: EXEC_ENV, timeout: timeout || 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
async function deployRoutes(app) {
|
async function deployRoutes(app) {
|
||||||
app.post("/deploy/webhook", async (req, reply) => {
|
app.post("/deploy/webhook", async (req, reply) => {
|
||||||
@@ -19,40 +25,76 @@ async function deployRoutes(app) {
|
|||||||
|
|
||||||
app.log.info("Deploy triggered by Gitea webhook");
|
app.log.info("Deploy triggered by Gitea webhook");
|
||||||
|
|
||||||
try {
|
// Run deploy asynchronously via shell script so we can return 200 immediately
|
||||||
// Pull latest code
|
const deployScript = `#!/bin/bash
|
||||||
const pullResult = execSync("cd /opt/task-team && git pull origin master 2>&1", { ...EXEC_OPTS, timeout: 30000 });
|
set -e
|
||||||
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
export HOME="/root"
|
||||||
|
LOG="${DEPLOY_LOG}"
|
||||||
|
echo "=== Deploy started at $(date -Is) ===" >> $LOG
|
||||||
|
|
||||||
// Install deps if package.json changed
|
# Pull latest code
|
||||||
execSync("cd /opt/task-team/api && npm install --production 2>&1", { ...EXEC_OPTS, timeout: 60000 });
|
cd /opt/task-team
|
||||||
|
git pull origin master >> $LOG 2>&1
|
||||||
|
|
||||||
// Reload API (zero-downtime)
|
# Install deps
|
||||||
execSync("pm2 reload taskteam-api 2>&1", { ...EXEC_OPTS, timeout: 15000 });
|
cd /opt/task-team/api
|
||||||
|
npm install --production >> $LOG 2>&1
|
||||||
|
|
||||||
// Build frontend
|
# Reload API (with --force to avoid "already in progress")
|
||||||
execSync("cd /opt/task-team/apps/tasks && NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build 2>&1", { ...EXEC_OPTS, timeout: 120000 });
|
pm2 reload taskteam-api --force >> $LOG 2>&1
|
||||||
|
|
||||||
// Reload web
|
# Build frontend (if it exists)
|
||||||
execSync("pm2 reload taskteam-web 2>&1", { ...EXEC_OPTS, timeout: 15000 });
|
if [ -d /opt/task-team/apps/tasks ] && [ -f /opt/task-team/apps/tasks/package.json ]; then
|
||||||
|
cd /opt/task-team/apps/tasks
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build >> $LOG 2>&1
|
||||||
|
pm2 reload taskteam-web --force >> $LOG 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
return { status: "deployed", pull: pullResult.trim().split("\n").slice(-3).join("\n") };
|
echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
||||||
} catch (e) {
|
`;
|
||||||
app.log.error(e, "Deploy failed");
|
// Write and execute deploy script in background
|
||||||
return reply.code(500).send({ status: "failed", error: e.message });
|
fs.writeFileSync("/opt/task-team/deploy.sh", deployScript, { mode: 0o755 });
|
||||||
}
|
exec("nohup /opt/task-team/deploy.sh &", { env: EXEC_ENV, detached: true });
|
||||||
|
|
||||||
|
return { status: "deploying", message: "Deploy started in background. Check /deploy/status for progress." };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual deploy status
|
// Deploy status
|
||||||
app.get("/deploy/status", async () => {
|
app.get("/deploy/status", async () => {
|
||||||
try {
|
try {
|
||||||
const commit = execSync("cd /opt/task-team && git log --oneline -1", { ...EXEC_OPTS }).trim();
|
const commit = runCmd("cd /opt/task-team && git log --oneline -1").trim();
|
||||||
const pm2 = execSync("pm2 jlist 2>/dev/null", { ...EXEC_OPTS });
|
const pm2out = runCmd("pm2 jlist 2>/dev/null");
|
||||||
const procs = JSON.parse(pm2).map(p => ({ name: p.name, status: p.pm2_env.status, uptime: p.pm2_env.pm_uptime }));
|
const procs = JSON.parse(pm2out).map(p => ({
|
||||||
return { status: "ok", commit, processes: procs };
|
name: p.name,
|
||||||
|
status: p.pm2_env.status,
|
||||||
|
uptime: p.pm2_env.pm_uptime,
|
||||||
|
restarts: p.pm2_env.restart_time
|
||||||
|
}));
|
||||||
|
// Last deploy log
|
||||||
|
let lastDeploy = "";
|
||||||
|
try {
|
||||||
|
const log = fs.readFileSync(DEPLOY_LOG, "utf8");
|
||||||
|
const lines = log.trim().split("\n");
|
||||||
|
lastDeploy = lines.slice(-10).join("\n");
|
||||||
|
} catch (_) {}
|
||||||
|
return { status: "ok", commit, processes: procs, last_deploy: lastDeploy };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { status: "error", message: e.message };
|
return { status: "error", message: e.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Manual trigger (GET for convenience)
|
||||||
|
app.post("/deploy/trigger", async (req, reply) => {
|
||||||
|
const secret = req.headers["x-deploy-secret"] || (req.body && req.body.secret);
|
||||||
|
if (secret !== DEPLOY_SECRET) {
|
||||||
|
return reply.code(401).send({ error: "invalid secret" });
|
||||||
|
}
|
||||||
|
// Simulate a push to master
|
||||||
|
req.body = { ref: "refs/heads/master", secret: DEPLOY_SECRET };
|
||||||
|
req.headers["x-gitea-secret"] = DEPLOY_SECRET;
|
||||||
|
return app.inject({ method: "POST", url: "/api/v1/deploy/webhook", payload: req.body, headers: { "x-gitea-secret": DEPLOY_SECRET } }).then(r => JSON.parse(r.body));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = deployRoutes;
|
module.exports = deployRoutes;
|
||||||
|
|||||||
26
deploy.sh
Executable file
26
deploy.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
export HOME="/root"
|
||||||
|
LOG="/opt/task-team/deploy.log"
|
||||||
|
echo "=== Deploy started at $(date -Is) ===" >> $LOG
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
cd /opt/task-team
|
||||||
|
git pull origin master >> $LOG 2>&1
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
cd /opt/task-team/api
|
||||||
|
npm install --production >> $LOG 2>&1
|
||||||
|
|
||||||
|
# Reload API (with --force to avoid "already in progress")
|
||||||
|
pm2 reload taskteam-api --force >> $LOG 2>&1
|
||||||
|
|
||||||
|
# Build frontend (if it exists)
|
||||||
|
if [ -d /opt/task-team/apps/tasks ] && [ -f /opt/task-team/apps/tasks/package.json ]; then
|
||||||
|
cd /opt/task-team/apps/tasks
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build >> $LOG 2>&1
|
||||||
|
pm2 reload taskteam-web --force >> $LOG 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
||||||
Reference in New Issue
Block a user