feat: add Docker and Gitea services, monitoring, queue, and Telegram notification functionalities

- Implemented Docker operations including image building, container management, and resource stats.
- Added Gitea API client for repository management and webhook handling.
- Introduced monitoring service to collect and store container metrics in InfluxDB.
- Created a queue system using BullMQ for managing deployment jobs with real-time log streaming.
- Developed Telegram notification service for deployment status updates.
- Added Traefik label generation for dynamic reverse proxy configuration.
- Implemented WebSocket endpoints for log streaming and terminal access to containers.
- Created an updater sidecar for self-updating the AutoDeployer container.
This commit is contained in:
Giuseppe Raffa
2026-04-13 23:23:18 +02:00
commit 87d698bc5c
48 changed files with 5558 additions and 0 deletions

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "autodeployer-server",
"version": "1.0.0",
"description": "Self-hosted deployment platform — API server",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.4",
"express": "^4.21.2",
"ioredis": "^5.4.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"express-rate-limit": "^7.5.0",
"ws": "^8.18.0",
"node-pty": "^1.0.0",
"@influxdata/influxdb-client": "^1.35.0"
}
}

39
server/src/config.js Normal file
View File

@@ -0,0 +1,39 @@
const config = {
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
// JWT
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-me',
jwtAccessExpiry: '15m',
jwtRefreshExpiry: '7d',
// Redis
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379/2',
// Gitea
giteaUrl: process.env.GITEA_URL || 'http://gitea:3000',
giteaToken: process.env.GITEA_TOKEN || '',
// Telegram
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: process.env.TELEGRAM_CHAT_ID || '',
// InfluxDB
influxUrl: process.env.INFLUXDB_URL || 'http://influxdb:8086',
influxToken: process.env.INFLUXDB_TOKEN || '',
influxOrg: process.env.INFLUXDB_ORG || 'autodeployer',
influxBucket: process.env.INFLUXDB_BUCKET || 'metrics',
// Domain
deployDomain: process.env.DEPLOY_DOMAIN || 'deploy.example.com',
// Webhook
webhookSecret: process.env.WEBHOOK_SECRET || 'default-webhook-secret',
// Paths
dataDir: process.env.DATA_DIR || '/app/data',
buildsDir: process.env.BUILDS_DIR || '/tmp/builds',
dbPath: process.env.DB_PATH || '/app/data/autodeployer.db',
};
export default config;

235
server/src/db/index.js Normal file
View File

@@ -0,0 +1,235 @@
import Database from 'better-sqlite3';
import config from '../config.js';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
// Ensure data directory exists
mkdirSync(dirname(config.dbPath), { recursive: true });
const db = new Database(config.dbPath);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ─── Schema ───────────────────────────────────────────────
function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL DEFAULT 'admin',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
-- Gitea config
gitea_repo_url TEXT NOT NULL,
gitea_branch TEXT NOT NULL DEFAULT 'main',
-- Build config
dockerfile_path TEXT NOT NULL DEFAULT 'Dockerfile',
build_context TEXT NOT NULL DEFAULT '.',
build_args TEXT DEFAULT '{}',
-- Container config
container_name TEXT NOT NULL UNIQUE,
container_port INTEGER NOT NULL DEFAULT 3000,
-- Traefik config
traefik_enabled INTEGER NOT NULL DEFAULT 1,
traefik_domain TEXT DEFAULT '',
traefik_entrypoints TEXT NOT NULL DEFAULT 'websecure',
traefik_tls_resolver TEXT DEFAULT 'cloudflare',
traefik_path_prefix TEXT DEFAULT '',
traefik_middlewares TEXT DEFAULT '[]',
traefik_network TEXT NOT NULL DEFAULT 'meb-public',
-- Networks
networks TEXT NOT NULL DEFAULT '["meb-public"]',
-- Health check
health_check_enabled INTEGER NOT NULL DEFAULT 0,
health_check_path TEXT DEFAULT '/health',
health_check_interval INTEGER DEFAULT 10,
health_check_timeout INTEGER DEFAULT 5,
health_check_retries INTEGER DEFAULT 3,
-- Zero-downtime
zero_downtime INTEGER NOT NULL DEFAULT 0,
-- Status
status TEXT NOT NULL DEFAULT 'stopped',
current_image TEXT DEFAULT '',
current_container_id TEXT DEFAULT '',
-- Webhook
webhook_id TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS env_vars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
is_build_arg INTEGER NOT NULL DEFAULT 0,
is_secret INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
UNIQUE(service_id, key)
);
CREATE TABLE IF NOT EXISTS deploys (
id TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
trigger TEXT NOT NULL DEFAULT 'manual',
commit_sha TEXT DEFAULT '',
commit_message TEXT DEFAULT '',
commit_author TEXT DEFAULT '',
image_tag TEXT DEFAULT '',
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS deploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deploy_id TEXT NOT NULL,
stream TEXT NOT NULL DEFAULT 'stdout',
message TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (deploy_id) REFERENCES deploys(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_deploys_service ON deploys(service_id);
CREATE INDEX IF NOT EXISTS idx_deploys_created ON deploys(created_at);
CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy ON deploy_logs(deploy_id);
CREATE INDEX IF NOT EXISTS idx_env_vars_service ON env_vars(service_id);
`);
}
migrate();
// ─── Helpers ──────────────────────────────────────────────
// Admin
export const getAdmin = () => db.prepare('SELECT * FROM admin WHERE id = 1').get();
export const createAdmin = (username, passwordHash) =>
db.prepare('INSERT OR REPLACE INTO admin (id, username, password_hash) VALUES (1, ?, ?)').run(username, passwordHash);
// Services
export const getAllServices = () => db.prepare('SELECT * FROM services ORDER BY name').all();
export const getServiceById = (id) => db.prepare('SELECT * FROM services WHERE id = ?').get(id);
export const getServiceByWebhookId = (webhookId) => db.prepare('SELECT * FROM services WHERE webhook_id = ?').get(webhookId);
export const getServiceByName = (name) => db.prepare('SELECT * FROM services WHERE name = ?').get(name);
export const createService = (service) => {
const stmt = db.prepare(`
INSERT INTO services (id, name, description, gitea_repo_url, gitea_branch,
dockerfile_path, build_context, build_args, container_name, container_port,
traefik_enabled, traefik_domain, traefik_entrypoints, traefik_tls_resolver,
traefik_path_prefix, traefik_middlewares, traefik_network, networks,
health_check_enabled, health_check_path, health_check_interval,
health_check_timeout, health_check_retries, zero_downtime, webhook_id)
VALUES (@id, @name, @description, @gitea_repo_url, @gitea_branch,
@dockerfile_path, @build_context, @build_args, @container_name, @container_port,
@traefik_enabled, @traefik_domain, @traefik_entrypoints, @traefik_tls_resolver,
@traefik_path_prefix, @traefik_middlewares, @traefik_network, @networks,
@health_check_enabled, @health_check_path, @health_check_interval,
@health_check_timeout, @health_check_retries, @zero_downtime, @webhook_id)
`);
return stmt.run(service);
};
export const updateService = (id, updates) => {
const fields = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
const stmt = db.prepare(`UPDATE services SET ${fields}, updated_at = datetime('now') WHERE id = @id`);
return stmt.run({ id, ...updates });
};
export const deleteService = (id) => db.prepare('DELETE FROM services WHERE id = ?').run(id);
// Environment Variables
export const getEnvVars = (serviceId) =>
db.prepare('SELECT * FROM env_vars WHERE service_id = ? ORDER BY key').all(serviceId);
export const setEnvVar = (serviceId, key, value, isBuildArg = false, isSecret = false) =>
db.prepare(`
INSERT INTO env_vars (service_id, key, value, is_build_arg, is_secret)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(service_id, key) DO UPDATE SET value = ?, is_build_arg = ?, is_secret = ?
`).run(serviceId, key, value, isBuildArg ? 1 : 0, isSecret ? 1 : 0, value, isBuildArg ? 1 : 0, isSecret ? 1 : 0);
export const deleteEnvVar = (serviceId, key) =>
db.prepare('DELETE FROM env_vars WHERE service_id = ? AND key = ?').run(serviceId, key);
export const bulkSetEnvVars = db.transaction((serviceId, vars) => {
db.prepare('DELETE FROM env_vars WHERE service_id = ?').run(serviceId);
const stmt = db.prepare(`
INSERT INTO env_vars (service_id, key, value, is_build_arg, is_secret)
VALUES (?, ?, ?, ?, ?)
`);
for (const v of vars) {
stmt.run(serviceId, v.key, v.value, v.is_build_arg ? 1 : 0, v.is_secret ? 1 : 0);
}
});
// Deploys
export const getDeploysByService = (serviceId, limit = 20) =>
db.prepare('SELECT * FROM deploys WHERE service_id = ? ORDER BY created_at DESC LIMIT ?').all(serviceId, limit);
export const getDeployById = (id) => db.prepare('SELECT * FROM deploys WHERE id = ?').get(id);
export const createDeploy = (deploy) => {
const stmt = db.prepare(`
INSERT INTO deploys (id, service_id, status, trigger, commit_sha, commit_message, commit_author, image_tag)
VALUES (@id, @service_id, @status, @trigger, @commit_sha, @commit_message, @commit_author, @image_tag)
`);
return stmt.run(deploy);
};
export const updateDeploy = (id, updates) => {
const fields = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
return db.prepare(`UPDATE deploys SET ${fields} WHERE id = @id`).run({ id, ...updates });
};
// Deploy Logs
export const getDeployLogs = (deployId) =>
db.prepare('SELECT * FROM deploy_logs WHERE deploy_id = ? ORDER BY id').all(deployId);
export const addDeployLog = (deployId, message, stream = 'stdout') =>
db.prepare('INSERT INTO deploy_logs (deploy_id, stream, message) VALUES (?, ?, ?)').run(deployId, stream, message);
// Settings
export const getSetting = (key) => {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : null;
};
export const setSetting = (key, value) =>
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`)
.run(key, value, value);
export const getAllSettings = () => {
const rows = db.prepare('SELECT * FROM settings').all();
return Object.fromEntries(rows.map(r => [r.key, r.value]));
};
export default db;

147
server/src/index.js Normal file
View File

@@ -0,0 +1,147 @@
import express from 'express';
import { createServer } from 'http';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { WebSocketServer } from 'ws';
import { URL } from 'url';
import config from './config.js';
import { requireAuth, requireSetup, authenticateWs } from './middleware/auth.js';
// Routes
import authRoutes from './routes/auth.js';
import servicesRoutes from './routes/services.js';
import webhooksRoutes from './routes/webhooks.js';
import deploysRoutes from './routes/deploys.js';
import logsRoutes from './routes/logs.js';
import networksRoutes from './routes/networks.js';
import monitoringRoutes from './routes/monitoring.js';
import settingsRoutes from './routes/settings.js';
import systemRoutes from './routes/system.js';
// WebSocket
import { handleLogStream } from './ws/logs.js';
import { handleTerminal } from './ws/terminal.js';
// Services init
import { initQueue } from './services/queue.js';
import { startMonitoringCollector } from './services/monitoring.js';
import { startPeriodicCleanup } from './services/cleanup.js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const server = createServer(app);
// ─── Security ─────────────────────────────────────────────
app.use(helmet({
contentSecurityPolicy: false, // Disable CSP for SPA
crossOriginEmbedderPolicy: false,
}));
app.use(cors({
origin: config.nodeEnv === 'development' ? 'http://localhost:5173' : true,
credentials: true,
}));
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
// ─── Health Check ─────────────────────────────────────────
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ─── Auth Routes (no auth required) ──────────────────────
app.use('/api/auth', authRoutes);
// ─── Webhook Routes (authenticated via secret, not JWT) ──
app.use('/api/webhooks', webhooksRoutes);
// ─── Protected API Routes ────────────────────────────────
app.use('/api/services', requireAuth, servicesRoutes);
app.use('/api/deploys', requireAuth, deploysRoutes);
app.use('/api/logs', requireAuth, logsRoutes);
app.use('/api/networks', requireAuth, networksRoutes);
app.use('/api/monitoring', requireAuth, monitoringRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/system', requireAuth, systemRoutes);
// ─── Serve Static Dashboard ──────────────────────────────
const publicDir = join(__dirname, '..', 'public');
if (existsSync(publicDir)) {
app.use(express.static(publicDir));
// SPA fallback: serve index.html for all non-API routes
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(join(publicDir, 'index.html'));
}
});
}
// ─── WebSocket Server ─────────────────────────────────────
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get('token');
// Authenticate WebSocket connections
const payload = authenticateWs(token);
if (!payload) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
const pathname = url.pathname;
if (pathname.startsWith('/ws/logs/')) {
const target = pathname.replace('/ws/logs/', '');
handleLogStream(ws, target);
} else if (pathname.startsWith('/ws/terminal/')) {
const containerId = pathname.replace('/ws/terminal/', '');
handleTerminal(ws, containerId);
} else {
ws.close(4004, 'Unknown WebSocket path');
}
});
});
// ─── Error Handler ────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error('[ERROR]', err.message);
res.status(err.status || 500).json({
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
});
});
// ─── Start ────────────────────────────────────────────────
async function start() {
try {
// Initialize build queue
await initQueue();
console.log('[QUEUE] Build queue initialized');
// Start monitoring collector
startMonitoringCollector();
console.log('[MONITORING] Metrics collector started');
// Start periodic cleanup (orphan containers + old images)
startPeriodicCleanup();
console.log('[CLEANUP] Periodic cleanup scheduled');
server.listen(config.port, '0.0.0.0', () => {
console.log(`[SERVER] AutoDeployer running on port ${config.port}`);
console.log(`[SERVER] Environment: ${config.nodeEnv}`);
});
} catch (err) {
console.error('[FATAL]', err);
process.exit(1);
}
}
start();

View File

@@ -0,0 +1,86 @@
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import config from '../config.js';
import { getAdmin } from '../db/index.js';
// ─── Password Hashing ────────────────────────────────────
const SALT_ROUNDS = 12;
export const hashPassword = (password) => bcrypt.hashSync(password, SALT_ROUNDS);
export const verifyPassword = (password, hash) => bcrypt.compareSync(password, hash);
// ─── JWT ──────────────────────────────────────────────────
export const generateAccessToken = (userId) =>
jwt.sign({ sub: userId, type: 'access' }, config.jwtSecret, { expiresIn: config.jwtAccessExpiry });
export const generateRefreshToken = (userId) =>
jwt.sign({ sub: userId, type: 'refresh' }, config.jwtSecret, { expiresIn: config.jwtRefreshExpiry });
export const verifyToken = (token) => {
try {
return jwt.verify(token, config.jwtSecret);
} catch {
return null;
}
};
// ─── Rate Limiter ─────────────────────────────────────────
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
// ─── Auth Middleware ──────────────────────────────────────
export const requireAuth = (req, res, next) => {
// Check Authorization header
const authHeader = req.headers.authorization;
let token = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.slice(7);
}
// Fallback to cookie
if (!token && req.cookies?.accessToken) {
token = req.cookies.accessToken;
}
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const payload = verifyToken(token);
if (!payload || payload.type !== 'access') {
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.userId = payload.sub;
next();
};
// ─── Setup Check Middleware ───────────────────────────────
export const requireSetup = (req, res, next) => {
const admin = getAdmin();
if (!admin) {
// Allow setup endpoints when no admin exists
if (req.path === '/api/auth/setup' && req.method === 'POST') {
return next();
}
return res.status(503).json({
error: 'Setup required',
setupRequired: true,
});
}
next();
};
// ─── WebSocket Auth ───────────────────────────────────────
export const authenticateWs = (token) => {
if (!token) return null;
const payload = verifyToken(token);
if (!payload || payload.type !== 'access') return null;
return payload;
};

185
server/src/routes/auth.js Normal file
View File

@@ -0,0 +1,185 @@
import { Router } from 'express';
import { getAdmin, createAdmin } from '../db/index.js';
import {
hashPassword, verifyPassword,
generateAccessToken, generateRefreshToken, verifyToken,
loginRateLimiter,
} from '../middleware/auth.js';
const router = Router();
/**
* POST /api/auth/setup — Initial admin setup (only works if no admin exists)
*/
router.post('/setup', async (req, res) => {
const existing = getAdmin();
if (existing) {
return res.status(400).json({ error: 'Admin already configured' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (password.length < 12) {
return res.status(400).json({ error: 'Password must be at least 12 characters' });
}
const hash = hashPassword(password);
createAdmin(username, hash);
const accessToken = generateAccessToken(1);
const refreshToken = generateRefreshToken(1);
// Set secure cookies
setAuthCookies(res, accessToken, refreshToken);
res.json({
ok: true,
user: { id: 1, username },
accessToken,
});
});
/**
* POST /api/auth/login — Login with username/password
*/
router.post('/login', loginRateLimiter, async (req, res) => {
const admin = getAdmin();
if (!admin) {
return res.status(503).json({ error: 'Setup required', setupRequired: true });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (username !== admin.username || !verifyPassword(password, admin.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = generateAccessToken(1);
const refreshToken = generateRefreshToken(1);
setAuthCookies(res, accessToken, refreshToken);
res.json({
ok: true,
user: { id: 1, username: admin.username },
accessToken,
});
});
/**
* POST /api/auth/refresh — Refresh access token
*/
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies?.refreshToken || req.body.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const accessToken = generateAccessToken(payload.sub);
setCookie(res, 'accessToken', accessToken, 15 * 60 * 1000);
res.json({ ok: true, accessToken });
});
/**
* POST /api/auth/logout
*/
router.post('/logout', (_req, res) => {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ ok: true });
});
/**
* GET /api/auth/me — Get current user info
*/
router.get('/me', (req, res) => {
// Check auth manually here since this route doesn't use requireAuth middleware
const authHeader = req.headers.authorization;
let token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.accessToken;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
const payload = verifyToken(token);
if (!payload || payload.type !== 'access') {
return res.status(401).json({ error: 'Invalid token' });
}
const admin = getAdmin();
if (!admin) {
return res.status(503).json({ error: 'Setup required', setupRequired: true });
}
res.json({
user: { id: 1, username: admin.username },
});
});
/**
* GET /api/auth/status — Check if setup is needed
*/
router.get('/status', (_req, res) => {
const admin = getAdmin();
res.json({
setupRequired: !admin,
configured: !!admin,
});
});
/**
* PUT /api/auth/password — Change password
*/
router.put('/password', async (req, res) => {
// Auth check
const authHeader = req.headers.authorization;
let token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.accessToken;
const payload = verifyToken(token);
if (!payload || payload.type !== 'access') {
return res.status(401).json({ error: 'Not authenticated' });
}
const { currentPassword, newPassword } = req.body;
const admin = getAdmin();
if (!verifyPassword(currentPassword, admin.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 12) {
return res.status(400).json({ error: 'Password must be at least 12 characters' });
}
const hash = hashPassword(newPassword);
createAdmin(admin.username, hash);
res.json({ ok: true });
});
// ─── Cookie helpers ───────────────────────────────────────
function setCookie(res, name, value, maxAge) {
res.cookie(name, value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge,
path: '/',
});
}
function setAuthCookies(res, accessToken, refreshToken) {
setCookie(res, 'accessToken', accessToken, 15 * 60 * 1000); // 15 min
setCookie(res, 'refreshToken', refreshToken, 7 * 24 * 60 * 60 * 1000); // 7 days
}
export default router;

View File

@@ -0,0 +1,51 @@
import { Router } from 'express';
import * as db from '../db/index.js';
const router = Router();
/**
* GET /api/deploys — List recent deploys across all services
*/
router.get('/', (_req, res) => {
try {
const services = db.getAllServices();
const allDeploys = [];
for (const service of services) {
const deploys = db.getDeploysByService(service.id, 10);
for (const d of deploys) {
allDeploys.push({ ...d, service_name: service.name });
}
}
// Sort by created_at desc
allDeploys.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json(allDeploys.slice(0, 50));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/deploys/:id — Get a single deploy with logs
*/
router.get('/:id', (req, res) => {
try {
const deploy = db.getDeployById(req.params.id);
if (!deploy) return res.status(404).json({ error: 'Deploy not found' });
const logs = db.getDeployLogs(deploy.id);
const service = db.getServiceById(deploy.service_id);
res.json({
...deploy,
service_name: service?.name || 'unknown',
logs,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

34
server/src/routes/logs.js Normal file
View File

@@ -0,0 +1,34 @@
import { Router } from 'express';
import * as db from '../db/index.js';
import { streamLogs } from '../services/docker.js';
const router = Router();
/**
* GET /api/logs/:serviceId — Get recent container logs (non-streaming)
*/
router.get('/:serviceId', async (req, res) => {
try {
const service = db.getServiceById(req.params.serviceId);
if (!service) return res.status(404).json({ error: 'Service not found' });
const tail = parseInt(req.query.tail || '200');
const logs = [];
const cleanup = await streamLogs(
service.container_name,
(line) => logs.push(line),
{ follow: false, tail }
);
// Wait a short moment for non-follow logs to complete
await new Promise(r => setTimeout(r, 500));
cleanup();
res.json({ service: service.name, logs });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,49 @@
import { Router } from 'express';
import { queryMetrics, getRealtimeStats } from '../services/monitoring.js';
import { getContainerStats } from '../services/docker.js';
import * as db from '../db/index.js';
const router = Router();
/**
* GET /api/monitoring/realtime — Get real-time stats for all running services
*/
router.get('/realtime', async (_req, res) => {
try {
const stats = await getRealtimeStats();
res.json(stats);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/monitoring/:serviceName — Get real-time stats for a specific service
*/
router.get('/:serviceName/stats', async (req, res) => {
try {
const service = db.getServiceByName(req.params.serviceName) || db.getServiceById(req.params.serviceName);
if (!service) return res.status(404).json({ error: 'Service not found' });
const stats = await getContainerStats(service.container_name);
res.json(stats || { error: 'Container not running' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/monitoring/:serviceName/history — Get historical metrics
*/
router.get('/:serviceName/history', async (req, res) => {
try {
const range = req.query.range || '-1h';
const field = req.query.field || 'cpu_percent';
const data = await queryMetrics(req.params.serviceName, range, field);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { listNetworks, createNetwork, removeNetwork } from '../services/docker.js';
const router = Router();
/**
* GET /api/networks — List Docker networks
*/
router.get('/', async (_req, res) => {
try {
const networks = await listNetworks();
res.json(networks);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/networks — Create a new Docker network
*/
router.post('/', async (req, res) => {
try {
const { name, driver, internal } = req.body;
if (!name) return res.status(400).json({ error: 'Network name is required' });
await createNetwork(name, driver || 'bridge', internal || false);
res.status(201).json({ ok: true, name });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/networks/:name — Remove a Docker network
*/
router.delete('/:name', async (req, res) => {
try {
await removeNetwork(req.params.name);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,404 @@
import { Router } from 'express';
import { randomUUID } from 'crypto';
import * as db from '../db/index.js';
import { getContainerInfo, stopAndRemoveContainer } from '../services/docker.js';
import { previewTraefikLabels } from '../services/traefik.js';
import { queueDeploy } from '../services/queue.js';
import { scanOrphanContainers, cleanupOrphanContainers, pruneOldImages } from '../services/cleanup.js';
const router = Router();
/**
* GET /api/services — List all services
*/
router.get('/', async (_req, res) => {
try {
const services = db.getAllServices();
// Enrich with live container info
const enriched = await Promise.all(
services.map(async (s) => {
let containerInfo = null;
try {
containerInfo = await getContainerInfo(s.container_name);
} catch {}
const lastDeploy = db.getDeploysByService(s.id, 1)[0] || null;
return {
...s,
networks: safeJsonParse(s.networks, []),
traefik_middlewares: safeJsonParse(s.traefik_middlewares, []),
build_args: safeJsonParse(s.build_args, {}),
container: containerInfo,
last_deploy: lastDeploy,
};
})
);
res.json(enriched);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/services/:id — Get a single service
*/
router.get('/:id', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
let containerInfo = null;
try {
containerInfo = await getContainerInfo(service.container_name);
} catch {}
const deploys = db.getDeploysByService(service.id, 10);
const envVars = db.getEnvVars(service.id);
res.json({
...service,
networks: safeJsonParse(service.networks, []),
traefik_middlewares: safeJsonParse(service.traefik_middlewares, []),
build_args: safeJsonParse(service.build_args, {}),
container: containerInfo,
deploys,
env_vars: envVars.map(e => ({
...e,
value: e.is_secret ? '••••••••' : e.value,
})),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services — Create a new service
*/
router.post('/', async (req, res) => {
try {
const {
name, description, gitea_repo_url, gitea_branch,
dockerfile_path, build_context, container_name, container_port,
traefik_enabled, traefik_domain, traefik_entrypoints,
traefik_tls_resolver, traefik_path_prefix, traefik_middlewares,
traefik_network, networks,
health_check_enabled, health_check_path, health_check_interval,
health_check_timeout, health_check_retries, zero_downtime,
} = req.body;
if (!name || !gitea_repo_url || !container_name) {
return res.status(400).json({ error: 'name, gitea_repo_url, and container_name are required' });
}
// Check uniqueness
if (db.getServiceByName(name)) {
return res.status(409).json({ error: `Service "${name}" already exists` });
}
const id = randomUUID();
const webhookId = randomUUID();
const service = {
id,
name,
description: description || '',
gitea_repo_url,
gitea_branch: gitea_branch || 'main',
dockerfile_path: dockerfile_path || 'Dockerfile',
build_context: build_context || '.',
build_args: JSON.stringify(req.body.build_args || {}),
container_name,
container_port: container_port || 3000,
traefik_enabled: traefik_enabled !== false ? 1 : 0,
traefik_domain: traefik_domain || '',
traefik_entrypoints: traefik_entrypoints || 'websecure',
traefik_tls_resolver: traefik_tls_resolver || 'cloudflare',
traefik_path_prefix: traefik_path_prefix || '',
traefik_middlewares: JSON.stringify(traefik_middlewares || []),
traefik_network: traefik_network || 'meb-public',
networks: JSON.stringify(networks || ['meb-public']),
health_check_enabled: health_check_enabled ? 1 : 0,
health_check_path: health_check_path || '/health',
health_check_interval: health_check_interval || 10,
health_check_timeout: health_check_timeout || 5,
health_check_retries: health_check_retries || 3,
zero_downtime: zero_downtime ? 1 : 0,
webhook_id: webhookId,
};
db.createService(service);
res.status(201).json({
...service,
networks: safeJsonParse(service.networks, []),
traefik_middlewares: safeJsonParse(service.traefik_middlewares, []),
build_args: safeJsonParse(service.build_args, {}),
webhook_url: `/api/webhooks/${webhookId}`,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* PUT /api/services/:id — Update a service
*/
router.put('/:id', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const updates = {};
const allowedFields = [
'name', 'description', 'gitea_repo_url', 'gitea_branch',
'dockerfile_path', 'build_context', 'container_name', 'container_port',
'traefik_enabled', 'traefik_domain', 'traefik_entrypoints',
'traefik_tls_resolver', 'traefik_path_prefix', 'traefik_network',
'health_check_enabled', 'health_check_path', 'health_check_interval',
'health_check_timeout', 'health_check_retries', 'zero_downtime',
];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
if (typeof req.body[field] === 'boolean') {
updates[field] = req.body[field] ? 1 : 0;
} else {
updates[field] = req.body[field];
}
}
}
// JSON fields
if (req.body.networks !== undefined) updates.networks = JSON.stringify(req.body.networks);
if (req.body.traefik_middlewares !== undefined) updates.traefik_middlewares = JSON.stringify(req.body.traefik_middlewares);
if (req.body.build_args !== undefined) updates.build_args = JSON.stringify(req.body.build_args);
if (Object.keys(updates).length > 0) {
db.updateService(req.params.id, updates);
}
const updated = db.getServiceById(req.params.id);
res.json({
...updated,
networks: safeJsonParse(updated.networks, []),
traefik_middlewares: safeJsonParse(updated.traefik_middlewares, []),
build_args: safeJsonParse(updated.build_args, {}),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/services/:id — Delete a service
*/
router.delete('/:id', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
db.deleteService(req.params.id);
res.json({ ok: true, deleted: service.name });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/:id/deploy — Trigger a manual deploy
*/
router.post('/:id/deploy', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const deployId = randomUUID();
db.createDeploy({
id: deployId,
service_id: service.id,
status: 'queued',
trigger: 'manual',
commit_sha: '',
commit_message: 'Manual deploy',
commit_author: 'admin',
image_tag: '',
});
await queueDeploy(deployId, service.id);
res.json({ ok: true, deploy_id: deployId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/:id/stop — Stop a service container
*/
router.post('/:id/stop', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
await stopAndRemoveContainer(service.container_name);
db.updateService(service.id, { status: 'stopped' });
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/:id/restart — Restart a service container
*/
router.post('/:id/restart', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
if (!service.current_image) {
return res.status(400).json({ error: 'No image available. Deploy the service first.' });
}
// Queue a redeploy with current image
const deployId = randomUUID();
db.createDeploy({
id: deployId,
service_id: service.id,
status: 'queued',
trigger: 'restart',
commit_sha: '',
commit_message: 'Container restart',
commit_author: 'admin',
image_tag: service.current_image,
});
await queueDeploy(deployId, service.id);
res.json({ ok: true, deploy_id: deployId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/services/:id/inspect — Full Docker inspect output
*/
router.get('/:id/inspect', async (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const { docker } = await import('../services/docker.js');
const container = docker.getContainer(service.container_name);
const info = await container.inspect();
res.json(info);
} catch (err) {
if (err.statusCode === 404) return res.status(404).json({ error: 'Container not found' });
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/services/:id/traefik-preview — Preview Traefik labels
*/
router.get('/:id/traefik-preview', (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const preview = previewTraefikLabels(service);
res.json({ labels: preview });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/traefik-preview — Preview Traefik labels from body (for unsaved config)
*/
router.post('/traefik-preview', (req, res) => {
try {
const preview = previewTraefikLabels(req.body);
res.json({ labels: preview });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/cleanup — Scan and remove orphan containers
*/
router.post('/cleanup', async (_req, res) => {
try {
const orphans = await scanOrphanContainers();
const results = await cleanupOrphanContainers();
res.json({ orphans_found: orphans.length, results });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/services/prune-images — Remove old Docker images
*/
router.post('/prune-images', async (req, res) => {
try {
const keep = parseInt(req.body?.keep) || 2;
const results = await pruneOldImages(keep);
res.json({ results });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Env Vars sub-routes ──────────────────────────────────
/**
* GET /api/services/:id/env — Get env vars
*/
router.get('/:id/env', (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const vars = db.getEnvVars(service.id);
res.json(vars.map(e => ({
...e,
value: e.is_secret ? '••••••••' : e.value,
})));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* PUT /api/services/:id/env — Bulk set env vars
*/
router.put('/:id/env', (req, res) => {
try {
const service = db.getServiceById(req.params.id);
if (!service) return res.status(404).json({ error: 'Service not found' });
const { vars } = req.body;
if (!Array.isArray(vars)) {
return res.status(400).json({ error: 'vars must be an array' });
}
db.bulkSetEnvVars(service.id, vars);
res.json({ ok: true, count: vars.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
function safeJsonParse(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
export default router;

View File

@@ -0,0 +1,77 @@
import { Router } from 'express';
import * as db from '../db/index.js';
import { testConnection as testGitea } from '../services/gitea.js';
import { testTelegram } from '../services/telegram.js';
import { getQueueStatus } from '../services/queue.js';
const router = Router();
/**
* GET /api/settings — Get all settings
*/
router.get('/', (_req, res) => {
try {
const settings = db.getAllSettings();
res.json(settings);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* PUT /api/settings — Bulk update settings
*/
router.put('/', (req, res) => {
try {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'settings object required' });
}
for (const [key, value] of Object.entries(settings)) {
db.setSetting(key, String(value));
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/settings/test/gitea — Test Gitea connection
*/
router.get('/test/gitea', async (_req, res) => {
try {
const result = await testGitea();
res.json(result);
} catch (err) {
res.json({ ok: false, error: err.message });
}
});
/**
* GET /api/settings/test/telegram — Test Telegram bot
*/
router.get('/test/telegram', async (_req, res) => {
try {
const result = await testTelegram();
res.json(result);
} catch (err) {
res.json({ ok: false, error: err.message });
}
});
/**
* GET /api/settings/queue — Get build queue status
*/
router.get('/queue', async (_req, res) => {
try {
const status = await getQueueStatus();
res.json(status);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,50 @@
import { Router } from 'express';
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { execFileSync } from 'child_process';
const router = Router();
const TRIGGER_FILE = '/app/trigger/update-trigger';
const STATUS_FILE = '/app/trigger/update-status';
const LOG_FILE = '/app/trigger/update.log';
/**
* POST /api/system/self-update — Trigger self-update via sidecar
*/
router.post('/self-update', (_req, res) => {
try {
writeFileSync(TRIGGER_FILE, new Date().toISOString());
res.json({ status: 'update_triggered', message: 'Il sidecar aggiornerà AutoDeployer a breve.' });
} catch (err) {
res.status(500).json({ error: `Failed to trigger update: ${err.message}` });
}
});
/**
* GET /api/system/update-status — Check self-update status
*/
router.get('/update-status', (_req, res) => {
try {
const status = existsSync(STATUS_FILE) ? readFileSync(STATUS_FILE, 'utf8').trim() : 'idle';
const log = existsSync(LOG_FILE) ? readFileSync(LOG_FILE, 'utf8').split('\n').slice(-50).join('\n') : '';
res.json({ status, log });
} catch {
res.json({ status: 'unknown', log: '' });
}
});
/**
* GET /api/system/version — Current version info
*/
router.get('/version', (_req, res) => {
try {
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: 'pipe' }).toString().trim();
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { stdio: 'pipe' }).toString().trim();
const date = execFileSync('git', ['log', '-1', '--format=%ci'], { stdio: 'pipe' }).toString().trim();
res.json({ commit, branch, date });
} catch {
res.json({ commit: 'unknown', branch: 'unknown', date: 'unknown' });
}
});
export default router;

View File

@@ -0,0 +1,100 @@
import { Router } from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
import { randomUUID } from 'crypto';
import * as db from '../db/index.js';
import { queueDeploy } from '../services/queue.js';
import config from '../config.js';
const router = Router();
/**
* POST /api/webhooks/:webhookId — Receive webhook from Gitea
* No JWT auth — authenticated via webhook secret
*/
router.post('/:webhookId', async (req, res) => {
try {
const { webhookId } = req.params;
// Find the service by webhook ID
const service = db.getServiceByWebhookId(webhookId);
if (!service) {
return res.status(404).json({ error: 'Unknown webhook' });
}
// Verify Gitea webhook signature (X-Gitea-Signature header)
const signature = req.headers['x-gitea-signature'];
if (config.webhookSecret) {
if (!signature) {
return res.status(401).json({ error: 'Missing webhook signature' });
}
const expectedSig = createHmac('sha256', config.webhookSecret)
.update(JSON.stringify(req.body))
.digest('hex');
try {
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSig, 'hex');
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
} catch {
return res.status(401).json({ error: 'Invalid webhook signature format' });
}
}
// Parse Gitea push event
const event = req.headers['x-gitea-event'];
if (event !== 'push') {
return res.json({ ok: true, skipped: true, reason: `Event ${event} not handled` });
}
const payload = req.body;
const ref = payload.ref || '';
const branch = ref.replace('refs/heads/', '');
// Verify branch matches
if (branch !== service.gitea_branch) {
return res.json({
ok: true,
skipped: true,
reason: `Branch ${branch} does not match configured branch ${service.gitea_branch}`,
});
}
// Extract commit info
const headCommit = payload.head_commit || payload.commits?.[0] || {};
const commitSha = headCommit.id || payload.after || '';
const commitMessage = headCommit.message || '';
const commitAuthor = headCommit.author?.name || headCommit.author?.username || '';
// Create deploy record
const deployId = randomUUID();
db.createDeploy({
id: deployId,
service_id: service.id,
status: 'queued',
trigger: 'webhook',
commit_sha: commitSha.slice(0, 12),
commit_message: commitMessage.split('\n')[0].slice(0, 200),
commit_author: commitAuthor,
image_tag: '',
});
// Queue the build
await queueDeploy(deployId, service.id);
console.log(`[WEBHOOK] Deploy queued for ${service.name} (commit: ${commitSha.slice(0, 8)})`);
res.json({
ok: true,
deploy_id: deployId,
service: service.name,
commit: commitSha.slice(0, 8),
});
} catch (err) {
console.error('[WEBHOOK] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,237 @@
import { execFileSync } from 'child_process';
import { mkdirSync, rmSync, existsSync } from 'fs';
import { join } from 'path';
import config from '../config.js';
import * as db from '../db/index.js';
import * as dockerService from './docker.js';
import { generateTraefikLabels } from './traefik.js';
import { getCloneUrl } from './gitea.js';
import { sendDeployNotification } from './telegram.js';
/**
* Build and deploy a service
* @param {string} deployId - Deploy record ID
* @param {function} onLog - Callback for log messages
*/
export async function buildAndDeploy(deployId, onLog = () => {}) {
const deploy = db.getDeployById(deployId);
if (!deploy) throw new Error(`Deploy ${deployId} not found`);
const service = db.getServiceById(deploy.service_id);
if (!service) throw new Error(`Service ${deploy.service_id} not found`);
const startTime = Date.now();
const imageTag = `${service.container_name}:${Date.now()}`;
const buildDir = join(config.buildsDir, `${service.id}-${Date.now()}`);
const log = (msg, stream = 'stdout') => {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${msg}`;
db.addDeployLog(deployId, line, stream);
onLog(line, stream);
};
try {
// ─── Update status ──────────────────────────────────────
db.updateDeploy(deployId, { status: 'building', started_at: new Date().toISOString() });
db.updateService(service.id, { status: 'building' });
log(`🚀 Starting deploy for ${service.name}`);
await sendDeployNotification(service.name, 'building', `Building ${service.name}...`);
// ─── Clone repository ───────────────────────────────────
log(`📦 Cloning ${service.gitea_repo_url} (branch: ${service.gitea_branch})`);
mkdirSync(buildDir, { recursive: true });
const cloneUrl = getCloneUrl(service.gitea_repo_url);
execFileSync('git', [
'clone', '--depth', '1', '--branch', service.gitea_branch,
'--', cloneUrl, buildDir
], { stdio: 'pipe', timeout: 120000 });
log(`✅ Repository cloned successfully`);
// ─── Build Docker image ─────────────────────────────────
log(`🔨 Building Docker image: ${imageTag}`);
log(` Dockerfile: ${service.dockerfile_path}`);
log(` Context: ${service.build_context}`);
// Get build args from env vars
const envVars = db.getEnvVars(service.id);
const buildArgs = {};
for (const ev of envVars.filter(e => e.is_build_arg)) {
buildArgs[ev.key] = ev.value;
}
// Merge with service-level build args
try {
const serviceBuildArgs = JSON.parse(service.build_args || '{}');
Object.assign(buildArgs, serviceBuildArgs);
} catch {}
const contextPath = join(buildDir, service.build_context);
await dockerService.buildImage(
contextPath,
imageTag,
service.dockerfile_path,
buildArgs,
(line) => log(` ${line}`)
);
log(`✅ Image built: ${imageTag}`);
// ─── Prepare environment ────────────────────────────────
const runtimeEnv = envVars
.filter(e => !e.is_build_arg)
.map(e => `${e.key}=${e.value}`);
// ─── Generate Traefik labels ────────────────────────────
const labels = generateTraefikLabels(service);
log(`🏷️ Traefik labels generated (${Object.keys(labels).length} labels)`);
// ─── Parse networks ─────────────────────────────────────
let networks = ['meb-public'];
try {
networks = JSON.parse(service.networks || '["meb-public"]');
} catch {}
// ─── Deploy container ───────────────────────────────────
if (service.zero_downtime && service.health_check_enabled) {
await zeroDowntimeDeploy(service, imageTag, runtimeEnv, labels, networks, log);
} else {
await standardDeploy(service, imageTag, runtimeEnv, labels, networks, log);
}
// ─── Update records ─────────────────────────────────────
const duration = Date.now() - startTime;
db.updateDeploy(deployId, {
status: 'success',
image_tag: imageTag,
finished_at: new Date().toISOString(),
duration_ms: duration,
});
db.updateService(service.id, {
status: 'running',
current_image: imageTag,
});
log(`✅ Deploy completed in ${(duration / 1000).toFixed(1)}s`);
await sendDeployNotification(service.name, 'success', `Deployed in ${(duration / 1000).toFixed(1)}s`);
} catch (err) {
const duration = Date.now() - startTime;
log(`❌ Deploy failed: ${err.message}`, 'stderr');
db.updateDeploy(deployId, {
status: 'failed',
finished_at: new Date().toISOString(),
duration_ms: duration,
});
db.updateService(service.id, { status: 'error' });
await sendDeployNotification(service.name, 'failed', err.message);
throw err;
} finally {
// ─── Cleanup build directory ────────────────────────────
try {
if (existsSync(buildDir)) {
rmSync(buildDir, { recursive: true, force: true });
}
} catch {}
}
}
/**
* Standard deploy: stop old → start new
*/
async function standardDeploy(service, imageTag, env, labels, networks, log) {
log(`📥 Deploying container: ${service.container_name}`);
const healthCheck = service.health_check_enabled ? {
path: service.health_check_path,
port: service.container_port,
interval: service.health_check_interval,
timeout: service.health_check_timeout,
retries: service.health_check_retries,
} : null;
const containerInfo = await dockerService.runContainer({
name: service.container_name,
image: imageTag,
env,
labels,
networks,
healthCheck,
});
log(`✅ Container started: ${containerInfo.name} (${containerInfo.id.slice(0, 12)})`);
// Update with actual container ID
db.updateService(service.id, { current_container_id: containerInfo.id });
}
/**
* Zero-downtime deploy: start new → wait healthy → stop old → rename new
* Traffic gap is only the rename operation (milliseconds).
*/
async function zeroDowntimeDeploy(service, imageTag, env, labels, networks, log) {
const tempName = `${service.container_name}-new`;
const oldName = service.container_name;
log(`⚡ Zero-downtime deploy: starting temporary container ${tempName}`);
const healthCheck = {
path: service.health_check_path,
port: service.container_port,
interval: service.health_check_interval,
timeout: service.health_check_timeout,
retries: service.health_check_retries,
};
// Start new container with REAL Traefik labels but temporary name
// Traefik won't route to it yet because the container name doesn't match
// what Traefik expects (the labels reference the correct router name)
await dockerService.runContainer({
name: tempName,
image: imageTag,
env,
labels,
networks,
healthCheck,
});
log(`⏳ Waiting for health check...`);
try {
await dockerService.waitForHealthy(tempName, 120000);
log(`✅ New container is healthy`);
} catch (err) {
log(`❌ Health check failed, rolling back: ${err.message}`, 'stderr');
await dockerService.stopAndRemoveContainer(tempName).catch(() => {});
throw new Error(`Zero-downtime deploy failed: health check did not pass`);
}
// Switch traffic: stop old → rename new (millisecond gap)
log(`🔄 Switching traffic...`);
await dockerService.stopAndRemoveContainer(oldName).catch(() => {});
try {
await dockerService.renameContainer(tempName, oldName);
} catch (renameErr) {
// If rename fails, the new container is still running as tempName
// Try to recover by stopping and recreating with correct name
log(`⚠️ Rename failed, recreating container: ${renameErr.message}`, 'stderr');
await dockerService.stopAndRemoveContainer(tempName).catch(() => {});
const containerInfo = await dockerService.runContainer({
name: oldName,
image: imageTag,
env,
labels,
networks,
healthCheck,
});
log(`✅ Container recreated: ${containerInfo.name}`);
db.updateService(service.id, { current_container_id: containerInfo.id });
return;
}
// Get the renamed container's info
const info = await dockerService.getContainerInfo(oldName);
log(`✅ Traffic switched to new container: ${oldName} (${info.id.slice(0, 12)})`);
db.updateService(service.id, { current_container_id: info.id });
}

View File

@@ -0,0 +1,107 @@
import * as dockerService from './docker.js';
import { docker } from './docker.js';
import * as db from '../db/index.js';
/**
* Find orphan containers (leftover -new containers from failed zero-downtime deploys)
*/
export async function scanOrphanContainers() {
const containers = await dockerService.listContainers(true);
const orphans = [];
for (const container of containers) {
if (container.name.endsWith('-new')) {
const baseName = container.name.replace(/-new$/, '');
// Check if there's an active deploy for this service
const service = db.getAllServices().find(s => s.container_name === baseName);
if (!service || service.status !== 'building') {
orphans.push(container);
}
}
}
return orphans;
}
/**
* Remove orphan containers
*/
export async function cleanupOrphanContainers() {
const orphans = await scanOrphanContainers();
const results = [];
for (const orphan of orphans) {
try {
await dockerService.stopAndRemoveContainer(orphan.name);
results.push({ name: orphan.name, status: 'removed' });
} catch (err) {
results.push({ name: orphan.name, status: 'error', error: err.message });
}
}
return results;
}
/**
* Prune old images for managed services, keeping the N most recent per service
*/
export async function pruneOldImages(keepLatest = 2) {
const services = db.getAllServices();
const results = [];
for (const service of services) {
try {
const images = await docker.listImages({
filters: { reference: [`${service.container_name}:*`] },
});
if (images.length <= keepLatest) continue;
// Sort by creation date descending
const sorted = images.sort((a, b) => b.Created - a.Created);
const toRemove = sorted.slice(keepLatest);
for (const img of toRemove) {
const tag = img.RepoTags?.[0] || img.Id;
try {
await dockerService.removeImage(tag);
results.push({ image: tag, status: 'removed' });
} catch (err) {
results.push({ image: tag, status: 'error', error: err.message });
}
}
} catch (err) {
results.push({ service: service.name, status: 'error', error: err.message });
}
}
return results;
}
/**
* Start periodic cleanup (every 30 minutes)
*/
export function startPeriodicCleanup() {
const INTERVAL = 30 * 60 * 1000;
// Initial delay of 60s to let services stabilize
setTimeout(() => {
const run = async () => {
try {
const orphans = await cleanupOrphanContainers();
if (orphans.length > 0) {
console.log(`[CLEANUP] Removed ${orphans.filter(o => o.status === 'removed').length} orphan containers`);
}
const pruned = await pruneOldImages();
if (pruned.length > 0) {
console.log(`[CLEANUP] Pruned ${pruned.filter(p => p.status === 'removed').length} old images`);
}
} catch (err) {
console.error('[CLEANUP] Error:', err.message);
}
};
run();
setInterval(run, INTERVAL);
}, 60000);
}

View File

@@ -0,0 +1,350 @@
import Dockerode from 'dockerode';
const docker = new Dockerode({ socketPath: '/var/run/docker.sock' });
// ─── Image Operations ─────────────────────────────────────
/**
* Build a Docker image from a context directory
* @param {string} contextPath - Path to the build context
* @param {string} tag - Image tag (e.g. "myservice:20260404-123456")
* @param {string} dockerfilePath - Relative path to Dockerfile within context
* @param {object} buildArgs - Build arguments
* @param {function} onLog - Callback for build output lines
*/
export async function buildImage(contextPath, tag, dockerfilePath = 'Dockerfile', buildArgs = {}, onLog = () => {}) {
return new Promise((resolve, reject) => {
docker.buildImage(
{ context: contextPath, src: ['.'] },
{
t: tag,
dockerfile: dockerfilePath,
buildargs: buildArgs,
rm: true,
forcerm: true,
},
(err, stream) => {
if (err) return reject(err);
docker.modem.followProgress(
stream,
(err, output) => {
if (err) return reject(err);
resolve(output);
},
(event) => {
if (event.stream) onLog(event.stream.trim());
if (event.error) onLog(`[ERROR] ${event.error}`);
}
);
}
);
});
}
/**
* Remove a Docker image
*/
export async function removeImage(imageTag) {
try {
const image = docker.getImage(imageTag);
await image.remove({ force: true });
} catch (err) {
if (err.statusCode !== 404) throw err;
}
}
// ─── Container Operations ─────────────────────────────────
/**
* Create and start a container
* @param {object} opts
* @returns {object} container info
*/
export async function runContainer({
name,
image,
env = [],
labels = {},
networks = [],
ports = {},
healthCheck = null,
restart = 'unless-stopped',
}) {
// Remove existing container with same name (if any)
await stopAndRemoveContainer(name).catch(() => {});
const containerConfig = {
name,
Image: image,
Env: env,
Labels: labels,
HostConfig: {
RestartPolicy: { Name: restart },
},
};
// Port bindings (optional, usually Traefik handles routing)
if (ports && Object.keys(ports).length > 0) {
containerConfig.ExposedPorts = {};
containerConfig.HostConfig.PortBindings = {};
for (const [containerPort, hostPort] of Object.entries(ports)) {
containerConfig.ExposedPorts[`${containerPort}/tcp`] = {};
containerConfig.HostConfig.PortBindings[`${containerPort}/tcp`] = [{ HostPort: String(hostPort) }];
}
}
// Health check
if (healthCheck) {
containerConfig.Healthcheck = {
Test: healthCheck.test || ['CMD-SHELL', `wget -qO- http://localhost:${healthCheck.port || 3000}${healthCheck.path || '/health'} || exit 1`],
Interval: (healthCheck.interval || 10) * 1e9, // nanoseconds
Timeout: (healthCheck.timeout || 5) * 1e9,
Retries: healthCheck.retries || 3,
StartPeriod: (healthCheck.startPeriod || 15) * 1e9,
};
}
const container = await docker.createContainer(containerConfig);
// Connect to networks
for (const networkName of networks) {
try {
const network = docker.getNetwork(networkName);
await network.connect({ Container: container.id });
} catch (err) {
console.warn(`[DOCKER] Failed to connect to network ${networkName}:`, err.message);
}
}
await container.start();
const info = await container.inspect();
return {
id: info.Id,
name: info.Name.replace('/', ''),
status: info.State.Status,
image: info.Config.Image,
};
}
/**
* Stop and remove a container by name
*/
export async function stopAndRemoveContainer(name) {
try {
const container = docker.getContainer(name);
const info = await container.inspect();
if (info.State.Running) {
await container.stop({ t: 10 });
}
await container.remove({ force: true });
} catch (err) {
if (err.statusCode !== 404) throw err;
}
}
/**
* Get container info by name or ID
*/
export async function getContainerInfo(nameOrId) {
try {
const container = docker.getContainer(nameOrId);
const info = await container.inspect();
return {
id: info.Id,
name: info.Name.replace('/', ''),
status: info.State.Status,
health: info.State.Health?.Status || 'none',
image: info.Config.Image,
created: info.Created,
started: info.State.StartedAt,
ports: info.NetworkSettings.Ports,
networks: Object.keys(info.NetworkSettings.Networks || {}),
};
} catch (err) {
if (err.statusCode === 404) return null;
throw err;
}
}
/**
* List all containers (running and stopped)
*/
export async function listContainers(all = true) {
const containers = await docker.listContainers({ all });
return containers.map(c => ({
id: c.Id,
name: c.Names[0]?.replace('/', '') || '',
image: c.Image,
status: c.Status,
state: c.State,
labels: c.Labels,
networks: Object.keys(c.NetworkSettings?.Networks || {}),
}));
}
/**
* Stream container logs
* @param {string} nameOrId - container name or ID
* @param {function} onData - callback for log data
* @param {object} opts - options (tail, since, follow)
* @returns {function} cleanup function
*/
export async function streamLogs(nameOrId, onData, opts = {}) {
const container = docker.getContainer(nameOrId);
const stream = await container.logs({
stdout: true,
stderr: true,
follow: opts.follow !== false,
tail: opts.tail || 200,
since: opts.since || 0,
timestamps: true,
});
// Use Dockerode's demuxer to properly parse multiplexed stream
const { PassThrough } = await import('stream');
const stdout = new PassThrough();
const stderr = new PassThrough();
const handleOutput = (s) => {
s.on('data', (chunk) => {
const lines = chunk.toString('utf8').split('\n').filter(Boolean);
for (const line of lines) {
onData(line);
}
});
};
handleOutput(stdout);
handleOutput(stderr);
docker.modem.demuxStream(stream, stdout, stderr);
stream.on('error', (err) => {
onData(`[ERROR] ${err.message}`);
});
stream.on('end', () => {
stdout.end();
stderr.end();
});
return () => {
try { stream.destroy(); } catch {}
};
}
/**
* Get container resource stats
*/
export async function getContainerStats(nameOrId) {
try {
const container = docker.getContainer(nameOrId);
const stats = await container.stats({ stream: false });
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * (stats.cpu_stats.online_cpus || 1) * 100 : 0;
const memUsage = stats.memory_stats.usage || 0;
const memLimit = stats.memory_stats.limit || 1;
const memPercent = (memUsage / memLimit) * 100;
const netRx = Object.values(stats.networks || {}).reduce((sum, n) => sum + (n.rx_bytes || 0), 0);
const netTx = Object.values(stats.networks || {}).reduce((sum, n) => sum + (n.tx_bytes || 0), 0);
return {
cpu_percent: Math.round(cpuPercent * 100) / 100,
memory_usage: memUsage,
memory_limit: memLimit,
memory_percent: Math.round(memPercent * 100) / 100,
network_rx: netRx,
network_tx: netTx,
};
} catch {
return null;
}
}
/**
* Execute a command in a container and return a stream
*/
export async function execInContainer(containerId, cmd = ['/bin/sh']) {
const container = docker.getContainer(containerId);
const exec = await container.exec({
Cmd: cmd,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
});
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
return { exec, stream };
}
/**
* Resize exec TTY
*/
export async function resizeExec(execId, w, h) {
try {
const exec = docker.getExec(execId);
await exec.resize({ w, h });
} catch {}
}
/**
* Wait for a container to become healthy
*/
export async function waitForHealthy(nameOrId, timeoutMs = 120000, pollMs = 2000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const info = await getContainerInfo(nameOrId);
if (!info) throw new Error(`Container ${nameOrId} not found`);
if (info.health === 'healthy') return true;
if (info.health === 'unhealthy') throw new Error(`Container ${nameOrId} is unhealthy`);
if (info.health === 'none' && info.status === 'running') return true; // No health check configured
await new Promise(r => setTimeout(r, pollMs));
}
throw new Error(`Container ${nameOrId} health check timeout after ${timeoutMs}ms`);
}
/**
* Rename a container
*/
export async function renameContainer(currentName, newName) {
const container = docker.getContainer(currentName);
await container.rename({ name: newName });
}
// ─── Network Operations ───────────────────────────────────
export async function listNetworks() {
const networks = await docker.listNetworks();
return networks
.filter(n => !['none', 'host', 'bridge'].includes(n.Name))
.map(n => ({
id: n.Id,
name: n.Name,
driver: n.Driver,
scope: n.Scope,
containers: Object.keys(n.Containers || {}).length,
internal: n.Internal || false,
}));
}
export async function createNetwork(name, driver = 'bridge', internal = false) {
const network = await docker.createNetwork({
Name: name,
Driver: driver,
Internal: internal,
});
return network;
}
export async function removeNetwork(nameOrId) {
const network = docker.getNetwork(nameOrId);
await network.remove();
}
export { docker };

View File

@@ -0,0 +1,134 @@
import config from '../config.js';
/**
* Gitea API client — uses API token auth
*/
const headers = () => ({
'Content-Type': 'application/json',
'Authorization': `token ${config.giteaToken}`,
});
const apiUrl = (path) => `${config.giteaUrl}/api/v1${path}`;
async function request(path, opts = {}) {
const url = apiUrl(path);
const res = await fetch(url, {
...opts,
headers: { ...headers(), ...opts.headers },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Gitea API error: ${res.status} ${res.statusText}${body}`);
}
return res.json();
}
/**
* List all accessible repositories
*/
export async function listRepos(page = 1, limit = 50) {
return request(`/repos/search?page=${page}&limit=${limit}&sort=updated&order=desc`);
}
/**
* Get a repository by owner/name
*/
export async function getRepo(owner, name) {
return request(`/repos/${owner}/${name}`);
}
/**
* List branches for a repository
*/
export async function listBranches(owner, name) {
return request(`/repos/${owner}/${name}/branches`);
}
/**
* Get the latest commit on a branch
*/
export async function getLatestCommit(owner, name, branch = 'main') {
const commits = await request(`/repos/${owner}/${name}/commits?sha=${branch}&limit=1`);
if (commits.length === 0) return null;
return {
sha: commits[0].sha,
message: commits[0].commit?.message || '',
author: commits[0].commit?.author?.name || '',
date: commits[0].commit?.author?.date || '',
};
}
/**
* Create a webhook on a Gitea repository
*/
export async function createWebhook(owner, name, targetUrl, secret) {
return request(`/repos/${owner}/${name}/hooks`, {
method: 'POST',
body: JSON.stringify({
type: 'gitea',
config: {
url: targetUrl,
content_type: 'json',
secret: secret,
},
events: ['push'],
active: true,
}),
});
}
/**
* Delete a webhook from a Gitea repository
*/
export async function deleteWebhook(owner, name, hookId) {
const url = apiUrl(`/repos/${owner}/${name}/hooks/${hookId}`);
const res = await fetch(url, {
method: 'DELETE',
headers: headers(),
});
if (!res.ok && res.status !== 404) {
throw new Error(`Gitea API error: ${res.status}`);
}
}
/**
* List webhooks for a repository
*/
export async function listWebhooks(owner, name) {
return request(`/repos/${owner}/${name}/hooks`);
}
/**
* Get the clone URL (with token embedded for private repos)
*/
export function getCloneUrl(repoUrl) {
// repoUrl is like "http://gitea:3000/owner/repo"
// We need to inject the token for authenticated clone
const url = new URL(repoUrl);
url.username = 'autodeployer';
url.password = config.giteaToken;
return url.toString();
}
/**
* Parse a Gitea repo URL into owner/name
*/
export function parseRepoUrl(repoUrl) {
const url = new URL(repoUrl);
const parts = url.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
if (parts.length < 2) throw new Error(`Invalid repo URL: ${repoUrl}`);
return { owner: parts[0], name: parts[1] };
}
/**
* Test the Gitea connection
*/
export async function testConnection() {
try {
const user = await request('/user');
return { ok: true, user: user.login };
} catch (err) {
return { ok: false, error: err.message };
}
}

View File

@@ -0,0 +1,146 @@
import config from '../config.js';
import * as db from '../db/index.js';
import { getContainerStats, listContainers } from './docker.js';
let influxWriteApi = null;
let influxQueryApi = null;
let collectorInterval = null;
/**
* Initialize InfluxDB client
*/
async function initInflux() {
if (!config.influxUrl || !config.influxToken) {
console.warn('[MONITORING] InfluxDB not configured, monitoring disabled');
return false;
}
try {
const { InfluxDB } = await import('@influxdata/influxdb-client');
const client = new InfluxDB({
url: config.influxUrl,
token: config.influxToken,
});
influxWriteApi = client.getWriteApi(config.influxOrg, config.influxBucket, 's');
influxQueryApi = client.getQueryApi(config.influxOrg);
console.log('[MONITORING] InfluxDB client initialized');
return true;
} catch (err) {
console.error('[MONITORING] InfluxDB init failed:', err.message);
return false;
}
}
/**
* Collect metrics for all managed containers and write to InfluxDB
*/
async function collectMetrics() {
if (!influxWriteApi) return;
try {
const { Point } = await import('@influxdata/influxdb-client');
const services = db.getAllServices();
for (const service of services) {
if (service.status !== 'running') continue;
const stats = await getContainerStats(service.container_name);
if (!stats) continue;
const point = new Point('container_metrics')
.tag('service', service.name)
.tag('container', service.container_name)
.floatField('cpu_percent', stats.cpu_percent)
.intField('memory_usage', stats.memory_usage)
.intField('memory_limit', stats.memory_limit)
.floatField('memory_percent', stats.memory_percent)
.intField('network_rx', stats.network_rx)
.intField('network_tx', stats.network_tx);
influxWriteApi.writePoint(point);
}
await influxWriteApi.flush();
} catch (err) {
console.error('[MONITORING] Metrics collection error:', err.message);
}
}
/**
* Start the metrics collection loop (every 30 seconds)
*/
export function startMonitoringCollector() {
initInflux().then((ok) => {
if (!ok) return;
// Collect immediately, then every 30 seconds
collectMetrics();
collectorInterval = setInterval(collectMetrics, 30000);
});
}
/**
* Stop the metrics collector
*/
export function stopMonitoringCollector() {
if (collectorInterval) {
clearInterval(collectorInterval);
collectorInterval = null;
}
}
/**
* Query historical metrics for a service
* @param {string} serviceName - Service name
* @param {string} range - Time range (e.g. '-1h', '-6h', '-24h', '-7d')
* @param {string} field - Metric field (cpu_percent, memory_percent, etc.)
*/
export async function queryMetrics(serviceName, range = '-1h', field = 'cpu_percent') {
if (!influxQueryApi) return [];
const query = `
from(bucket: "${config.influxBucket}")
|> range(start: ${range})
|> filter(fn: (r) => r._measurement == "container_metrics")
|> filter(fn: (r) => r.service == "${serviceName}")
|> filter(fn: (r) => r._field == "${field}")
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|> yield(name: "mean")
`;
const results = [];
return new Promise((resolve, reject) => {
influxQueryApi.queryRows(query, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row);
results.push({
time: obj._time,
value: obj._value,
});
},
error(err) {
console.error('[MONITORING] Query error:', err.message);
resolve([]); // Return empty on error instead of rejecting
},
complete() {
resolve(results);
},
});
});
}
/**
* Get real-time stats for all running services
*/
export async function getRealtimeStats() {
const services = db.getAllServices();
const stats = {};
for (const service of services) {
if (service.status !== 'running') continue;
const s = await getContainerStats(service.container_name);
if (s) stats[service.name] = s;
}
return stats;
}

View File

@@ -0,0 +1,125 @@
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import config from '../config.js';
import { buildAndDeploy } from './builder.js';
// Use ioredis for BullMQ compatibility (BullMQ ships with it)
let connection;
let buildQueue;
let buildWorker;
// Event listeners registry for real-time log streaming
const deployListeners = new Map();
/**
* Register a listener for deploy events (used by WebSocket)
*/
export function onDeployLog(deployId, callback) {
if (!deployListeners.has(deployId)) {
deployListeners.set(deployId, new Set());
}
deployListeners.get(deployId).add(callback);
return () => {
const listeners = deployListeners.get(deployId);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) deployListeners.delete(deployId);
}
};
}
function notifyDeployListeners(deployId, message, stream) {
const listeners = deployListeners.get(deployId);
if (listeners) {
for (const cb of listeners) {
try { cb(message, stream); } catch {}
}
}
}
/**
* Initialize the build queue and worker
*/
export async function initQueue() {
// Parse Redis URL
const redisUrl = new URL(config.redisUrl);
connection = new IORedis({
host: redisUrl.hostname,
port: parseInt(redisUrl.port || '6379'),
db: parseInt(redisUrl.pathname?.replace('/', '') || '2'),
password: redisUrl.password || undefined,
maxRetriesPerRequest: null, // Required by BullMQ
enableReadyCheck: false,
});
buildQueue = new Queue('autodeployer:builds', { connection });
buildWorker = new Worker(
'autodeployer:builds',
async (job) => {
const { deployId } = job.data;
console.log(`[WORKER] Processing deploy ${deployId}`);
await buildAndDeploy(deployId, (message, stream) => {
notifyDeployListeners(deployId, message, stream);
});
},
{
connection,
concurrency: 1, // Process one build at a time
removeOnComplete: { count: 50 },
removeOnFail: { count: 20 },
}
);
buildWorker.on('completed', (job) => {
console.log(`[WORKER] Deploy ${job.data.deployId} completed`);
});
buildWorker.on('failed', (job, err) => {
console.error(`[WORKER] Deploy ${job?.data?.deployId} failed:`, err.message);
});
buildWorker.on('error', (err) => {
console.error('[WORKER] Queue error:', err.message);
});
console.log('[QUEUE] Connected to Redis, worker ready');
}
/**
* Add a deploy job to the queue
*/
export async function queueDeploy(deployId, serviceId) {
if (!buildQueue) throw new Error('Build queue not initialized');
const job = await buildQueue.add(
'deploy',
{ deployId, serviceId },
{
attempts: 1, // No retry on failure (deploy should be manually triggered again)
removeOnComplete: { count: 50 },
removeOnFail: { count: 20 },
}
);
console.log(`[QUEUE] Deploy ${deployId} queued as job ${job.id}`);
return job.id;
}
/**
* Get queue status
*/
export async function getQueueStatus() {
if (!buildQueue) return { waiting: 0, active: 0, completed: 0, failed: 0 };
const [waiting, active, completed, failed] = await Promise.all([
buildQueue.getWaitingCount(),
buildQueue.getActiveCount(),
buildQueue.getCompletedCount(),
buildQueue.getFailedCount(),
]);
return { waiting, active, completed, failed };
}

View File

@@ -0,0 +1,92 @@
import config from '../config.js';
/**
* Send a Telegram notification
*/
async function sendMessage(text, parseMode = 'HTML') {
if (!config.telegramBotToken || !config.telegramChatId) {
return; // Telegram not configured, skip silently
}
try {
const url = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.telegramChatId,
text,
parse_mode: parseMode,
disable_web_page_preview: true,
}),
});
} catch (err) {
console.error('[TELEGRAM] Failed to send notification:', err.message);
}
}
const STATUS_EMOJI = {
building: '🔨',
success: '✅',
failed: '❌',
stopped: '⏹️',
started: '▶️',
};
/**
* Send a deploy status notification
*/
export async function sendDeployNotification(serviceName, status, details = '') {
const emoji = STATUS_EMOJI[status] || '📋';
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
let text = `${emoji} <b>AutoDeployer</b>\n\n`;
text += `<b>Service:</b> ${escapeHtml(serviceName)}\n`;
text += `<b>Status:</b> ${statusText}\n`;
if (details) {
text += `<b>Details:</b> ${escapeHtml(details)}\n`;
}
text += `\n<i>${new Date().toLocaleString('it-IT', { timeZone: 'Europe/Rome' })}</i>`;
await sendMessage(text);
}
/**
* Send a generic notification
*/
export async function sendNotification(title, message) {
const text = `📢 <b>${escapeHtml(title)}</b>\n\n${escapeHtml(message)}`;
await sendMessage(text);
}
/**
* Test Telegram configuration
*/
export async function testTelegram() {
if (!config.telegramBotToken || !config.telegramChatId) {
return { ok: false, error: 'Telegram bot token and chat ID are required' };
}
try {
const url = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.telegramChatId,
text: '✅ AutoDeployer — Telegram notifications configured successfully!',
}),
});
const data = await res.json();
return { ok: data.ok, error: data.ok ? null : data.description };
} catch (err) {
return { ok: false, error: err.message };
}
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -0,0 +1,195 @@
/**
* Traefik label generator
* Generates Docker labels for Traefik reverse proxy configuration
*/
/**
* Generate all Traefik labels for a service
* @param {object} service - Service configuration from DB
* @returns {object} Docker labels object
*/
export function generateTraefikLabels(service) {
if (!service.traefik_enabled) {
return { 'traefik.enable': 'false' };
}
const name = sanitizeRouterName(service.container_name);
const labels = {
'traefik.enable': 'true',
};
// ─── Router ──────────────────────────────────────────────
const routerPrefix = `traefik.http.routers.${name}`;
// Build routing rule
let rule = '';
if (service.traefik_domain) {
rule = `Host(\`${service.traefik_domain}\`)`;
}
if (service.traefik_path_prefix) {
const pathRule = `PathPrefix(\`${service.traefik_path_prefix}\`)`;
rule = rule ? `${rule} && ${pathRule}` : pathRule;
}
if (!rule) {
rule = `Host(\`${service.container_name}.localhost\`)`;
}
labels[`${routerPrefix}.rule`] = rule;
// Entrypoints
labels[`${routerPrefix}.entrypoints`] = service.traefik_entrypoints || 'websecure';
// TLS
if (service.traefik_tls_resolver) {
labels[`${routerPrefix}.tls`] = 'true';
labels[`${routerPrefix}.tls.certresolver`] = service.traefik_tls_resolver;
}
// ─── Service ─────────────────────────────────────────────
const svcPrefix = `traefik.http.services.${name}`;
labels[`${svcPrefix}.loadbalancer.server.port`] = String(service.container_port || 3000);
// ─── Network ─────────────────────────────────────────────
if (service.traefik_network) {
labels['traefik.docker.network'] = service.traefik_network;
}
// ─── Middlewares ─────────────────────────────────────────
const middlewares = parseMiddlewares(service.traefik_middlewares);
const middlewareNames = [];
for (const mw of middlewares) {
// Reference middleware: use existing middleware from file/docker provider
if (mw.type === 'reference') {
const provider = mw.provider || 'file';
middlewareNames.push(`${mw.name}@${provider}`);
continue;
}
const mwName = `${name}-${mw.type}`;
middlewareNames.push(mwName);
const mwPrefix = `traefik.http.middlewares.${mwName}`;
switch (mw.type) {
case 'ratelimit':
labels[`${mwPrefix}.ratelimit.average`] = String(mw.average || 100);
labels[`${mwPrefix}.ratelimit.burst`] = String(mw.burst || 50);
labels[`${mwPrefix}.ratelimit.period`] = mw.period || '1m';
break;
case 'headers':
if (mw.stsSeconds) labels[`${mwPrefix}.headers.stsSeconds`] = String(mw.stsSeconds);
if (mw.stsIncludeSubdomains) labels[`${mwPrefix}.headers.stsIncludeSubdomains`] = 'true';
if (mw.forceSTSHeader) labels[`${mwPrefix}.headers.forceSTSHeader`] = 'true';
if (mw.contentTypeNosniff) labels[`${mwPrefix}.headers.contentTypeNosniff`] = 'true';
if (mw.frameDeny) labels[`${mwPrefix}.headers.frameDeny`] = 'true';
if (mw.browserXssFilter) labels[`${mwPrefix}.headers.browserXssFilter`] = 'true';
if (mw.customRequestHeaders) {
for (const [k, v] of Object.entries(mw.customRequestHeaders)) {
labels[`${mwPrefix}.headers.customrequestheaders.${k}`] = v;
}
}
if (mw.customResponseHeaders) {
for (const [k, v] of Object.entries(mw.customResponseHeaders)) {
labels[`${mwPrefix}.headers.customresponseheaders.${k}`] = v;
}
}
break;
case 'redirectscheme':
labels[`${mwPrefix}.redirectscheme.scheme`] = mw.scheme || 'https';
labels[`${mwPrefix}.redirectscheme.permanent`] = String(mw.permanent !== false);
break;
case 'basicauth':
if (mw.users) labels[`${mwPrefix}.basicauth.users`] = mw.users;
break;
case 'stripprefix':
if (mw.prefixes) labels[`${mwPrefix}.stripprefix.prefixes`] = mw.prefixes.join(',');
break;
case 'compress':
labels[`${mwPrefix}.compress`] = 'true';
break;
case 'retry':
labels[`${mwPrefix}.retry.attempts`] = String(mw.attempts || 3);
if (mw.initialInterval) labels[`${mwPrefix}.retry.initialInterval`] = mw.initialInterval;
break;
case 'ipallowlist':
if (mw.sourceRange) labels[`${mwPrefix}.ipallowlist.sourcerange`] = mw.sourceRange.join(',');
break;
default:
// Custom labels pass-through
if (mw.labels) {
for (const [k, v] of Object.entries(mw.labels)) {
labels[`${mwPrefix}.${k}`] = v;
}
}
}
}
if (middlewareNames.length > 0) {
labels[`${routerPrefix}.middlewares`] = middlewareNames.join(',');
}
return labels;
}
/**
* Generate a preview of Traefik labels (formatted for display)
*/
export function previewTraefikLabels(service) {
const labels = generateTraefikLabels(service);
return Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
/**
* Common middleware presets
*/
export const MIDDLEWARE_PRESETS = {
'security-headers': {
type: 'headers',
stsSeconds: 31536000,
stsIncludeSubdomains: true,
forceSTSHeader: true,
contentTypeNosniff: true,
frameDeny: true,
browserXssFilter: true,
},
'redirect-https': {
type: 'redirectscheme',
scheme: 'https',
permanent: true,
},
'rate-limit-default': {
type: 'ratelimit',
average: 100,
burst: 50,
period: '1m',
},
'compress': {
type: 'compress',
},
};
// ─── Helpers ──────────────────────────────────────────────
function sanitizeRouterName(name) {
return name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
}
function parseMiddlewares(json) {
try {
const parsed = JSON.parse(json || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}

82
server/src/ws/logs.js Normal file
View File

@@ -0,0 +1,82 @@
import { streamLogs } from '../services/docker.js';
import { onDeployLog } from '../services/queue.js';
import * as db from '../db/index.js';
/**
* Handle WebSocket log streaming
* Path: /ws/logs/{target}
*
* Target can be:
* - service:{serviceId} — Stream runtime container logs
* - deploy:{deployId} — Stream build logs for a deploy
*/
export function handleLogStream(ws, target) {
let cleanup = null;
const sendLog = (line) => {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(JSON.stringify({ type: 'log', data: line, timestamp: new Date().toISOString() }));
}
};
if (target.startsWith('service:')) {
// Stream runtime container logs
const serviceId = target.replace('service:', '');
const service = db.getServiceById(serviceId);
if (!service) {
ws.send(JSON.stringify({ type: 'error', data: 'Service not found' }));
ws.close();
return;
}
ws.send(JSON.stringify({ type: 'info', data: `Streaming logs for ${service.name}...` }));
streamLogs(service.container_name, sendLog, { follow: true, tail: 100 })
.then((cleanupFn) => {
cleanup = cleanupFn;
})
.catch((err) => {
ws.send(JSON.stringify({ type: 'error', data: `Failed to stream logs: ${err.message}` }));
});
} else if (target.startsWith('deploy:')) {
// Stream build logs for a deploy
const deployId = target.replace('deploy:', '');
const deploy = db.getDeployById(deployId);
if (!deploy) {
ws.send(JSON.stringify({ type: 'error', data: 'Deploy not found' }));
ws.close();
return;
}
// Send existing logs first
const existingLogs = db.getDeployLogs(deployId);
for (const log of existingLogs) {
sendLog(log.message);
}
// If deploy is still in progress, stream new logs
if (['queued', 'building'].includes(deploy.status)) {
ws.send(JSON.stringify({ type: 'info', data: 'Streaming build output...' }));
cleanup = onDeployLog(deployId, (message) => {
sendLog(message);
});
} else {
ws.send(JSON.stringify({ type: 'info', data: `Deploy ${deploy.status}. Showing historical logs.` }));
}
} else {
ws.send(JSON.stringify({ type: 'error', data: 'Invalid target. Use service:{id} or deploy:{id}' }));
ws.close();
return;
}
ws.on('close', () => {
if (cleanup) cleanup();
});
ws.on('error', () => {
if (cleanup) cleanup();
});
}

82
server/src/ws/terminal.js Normal file
View File

@@ -0,0 +1,82 @@
import { execInContainer, resizeExec } from '../services/docker.js';
/**
* Handle WebSocket terminal session
* Path: /ws/terminal/{containerId}
*
* Creates an interactive shell (docker exec) in the container
* and bridges it to the WebSocket connection.
*
* Client messages:
* - { type: 'input', data: '...' } — stdin data
* - { type: 'resize', cols: N, rows: N } — terminal resize
*/
export async function handleTerminal(ws, containerId) {
let execInstance = null;
let stream = null;
try {
ws.send(JSON.stringify({ type: 'info', data: `Connecting to container ${containerId.slice(0, 12)}...` }));
const result = await execInContainer(containerId, ['/bin/sh', '-c', 'if command -v bash > /dev/null; then exec bash; else exec sh; fi']);
execInstance = result.exec;
stream = result.stream;
ws.send(JSON.stringify({ type: 'connected', data: 'Terminal connected' }));
// Docker exec stream → WebSocket
stream.on('data', (chunk) => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'output', data: chunk.toString('utf8') }));
}
});
stream.on('end', () => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'info', data: 'Terminal session ended' }));
ws.close();
}
});
stream.on('error', (err) => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', data: `Stream error: ${err.message}` }));
}
});
// WebSocket → Docker exec stream
ws.on('message', (message) => {
try {
const msg = JSON.parse(message.toString());
if (msg.type === 'input' && stream && stream.writable) {
stream.write(msg.data);
} else if (msg.type === 'resize' && execInstance) {
resizeExec(execInstance.id, msg.cols || 80, msg.rows || 24);
}
} catch {
// If raw text, treat as stdin input
if (stream && stream.writable) {
stream.write(message.toString());
}
}
});
} catch (err) {
ws.send(JSON.stringify({ type: 'error', data: `Failed to connect: ${err.message}` }));
ws.close();
return;
}
ws.on('close', () => {
try {
if (stream) stream.end();
} catch {}
});
ws.on('error', () => {
try {
if (stream) stream.end();
} catch {}
});
}