diff --git a/api/src/index.js b/api/src/index.js index 9d08a11..b6cdc69 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -7,8 +7,6 @@ const rateLimit = require("@fastify/rate-limit"); const { Pool } = require("pg"); const Redis = require("ioredis"); -const app = Fastify({ logger: true }); - // Database pool const pool = new Pool({ 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"); redis.on("connect", () => { - app.log.info("Redis connected"); + console.log("Redis connected"); }); 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 const shutdown = async (signal) => { - app.log.info(`Received ${signal}, shutting down gracefully...`); + console.log(`Received ${signal}, shutting down gracefully...`); await redis.quit(); await pool.end(); process.exit(0); @@ -71,11 +34,49 @@ process.on("SIGTERM", () => shutdown("SIGTERM")); // Start 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 { 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 + ")"); } catch (err) { - app.log.error(err); + console.error(err); process.exit(1); } }; diff --git a/api/src/routes/deploy.js b/api/src/routes/deploy.js index 41af8d9..4bb484c 100644 --- a/api/src/routes/deploy.js +++ b/api/src/routes/deploy.js @@ -1,8 +1,14 @@ // 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 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) { app.post("/deploy/webhook", async (req, reply) => { @@ -19,40 +25,76 @@ async function deployRoutes(app) { app.log.info("Deploy triggered by Gitea webhook"); - try { - // Pull latest code - const pullResult = execSync("cd /opt/task-team && git pull origin master 2>&1", { ...EXEC_OPTS, timeout: 30000 }); + // Run deploy asynchronously via shell script so we can return 200 immediately + const deployScript = `#!/bin/bash +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 - execSync("cd /opt/task-team/api && npm install --production 2>&1", { ...EXEC_OPTS, timeout: 60000 }); +# Pull latest code +cd /opt/task-team +git pull origin master >> $LOG 2>&1 - // Reload API (zero-downtime) - execSync("pm2 reload taskteam-api 2>&1", { ...EXEC_OPTS, timeout: 15000 }); +# Install deps +cd /opt/task-team/api +npm install --production >> $LOG 2>&1 - // Build frontend - execSync("cd /opt/task-team/apps/tasks && NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build 2>&1", { ...EXEC_OPTS, timeout: 120000 }); +# Reload API (with --force to avoid "already in progress") +pm2 reload taskteam-api --force >> $LOG 2>&1 - // Reload web - execSync("pm2 reload taskteam-web 2>&1", { ...EXEC_OPTS, timeout: 15000 }); +# 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 - return { status: "deployed", pull: pullResult.trim().split("\n").slice(-3).join("\n") }; - } catch (e) { - app.log.error(e, "Deploy failed"); - return reply.code(500).send({ status: "failed", error: e.message }); - } +echo "=== Deploy completed at $(date -Is) ===" >> $LOG +`; + // Write and execute deploy script in background + 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 () => { try { - const commit = execSync("cd /opt/task-team && git log --oneline -1", { ...EXEC_OPTS }).trim(); - const pm2 = execSync("pm2 jlist 2>/dev/null", { ...EXEC_OPTS }); - const procs = JSON.parse(pm2).map(p => ({ name: p.name, status: p.pm2_env.status, uptime: p.pm2_env.pm_uptime })); - return { status: "ok", commit, processes: procs }; + const commit = runCmd("cd /opt/task-team && git log --oneline -1").trim(); + const pm2out = runCmd("pm2 jlist 2>/dev/null"); + const procs = JSON.parse(pm2out).map(p => ({ + 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) { 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; diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d3e0f2c --- /dev/null +++ b/deploy.sh @@ -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