Introduces scalable Docker Compose deployment
Establishes a multi-service Docker Compose setup for the application, including a Traefik reverse proxy, a MySQL database, and a scalable API service. Adds a comprehensive MySQL database schema for bug reports, file storage, rate limiting, user management, and sessions. Improves application startup reliability by implementing a "wait-for-MySQL" script, ensuring the API service initializes only after the database is available. This script is integrated into the Dockerfile and package.json. Enhances the API service with instance-specific logging, a more informative health endpoint, and an increased maximum request body size (50MB). Database migration failures are now gracefully handled.
This commit is contained in:
@@ -9,4 +9,5 @@ COPY src/ ./src/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
# Use a small startup script that waits for MySQL to be ready
|
||||
CMD ["bun", "run", "src/wait-for-mysql.ts"]
|
||||
|
||||
62
compose.yml
62
compose.yml
@@ -1,7 +1,42 @@
|
||||
services:
|
||||
api:
|
||||
reverse-proxy:
|
||||
image: traefik:latest
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
emly-mysql-db:
|
||||
image: mysql:lts
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./init:/docker-entrypoint-initdb.d
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
emly-api:
|
||||
build: .
|
||||
environment:
|
||||
INSTANCE_ID: ${HOSTNAME}
|
||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||
@@ -13,5 +48,26 @@ services:
|
||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
||||
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
||||
volumes:
|
||||
- ./logs/api:/app/logs
|
||||
restart: on-failure
|
||||
- emly-api-logs:/app/logs
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 3
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.emly-api.entrypoints=web"
|
||||
- "traefik.http.routers.emly-api.rule=PathPrefix(`/`)"
|
||||
- "traefik.http.services.emly-api.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
- emly-mysql-db
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
emly-api-logs:
|
||||
62
init/init.sql
Normal file
62
init/init.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS `bug_reports` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`hwid` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`hostname` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`os_user` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`submitter_ip` VARCHAR(45) NOT NULL DEFAULT '',
|
||||
`system_info` JSON NULL,
|
||||
`status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_hwid` (`hwid`),
|
||||
INDEX `idx_hostname` (`hostname`),
|
||||
INDEX `idx_os_user` (`os_user`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `bug_report_files` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`report_id` INT UNSIGNED NOT NULL,
|
||||
`file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
|
||||
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data` LONGBLOB NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_report_id` (`report_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
|
||||
`hwid` VARCHAR(255) PRIMARY KEY,
|
||||
`window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`count` INT UNSIGNED NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user` (
|
||||
`id` VARCHAR(255) PRIMARY KEY,
|
||||
`username` VARCHAR(255) NOT NULL UNIQUE,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`displayname` VARCHAR(255) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `session` (
|
||||
`id` VARCHAR(255) PRIMARY KEY,
|
||||
`user_id` VARCHAR(255) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid;
|
||||
ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname;
|
||||
ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname);
|
||||
ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user);
|
||||
ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
"start": "bun run src/index.ts",
|
||||
"start:wait": "bun run src/wait-for-mysql.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function runMigrations(): Promise<void> {
|
||||
try {
|
||||
await pool.execute(migration);
|
||||
} catch {
|
||||
// Column/index already exists — safe to ignore
|
||||
// Column/index already exists - safe to ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/index.ts
13
src/index.ts
@@ -6,6 +6,8 @@ import { bugReportRoutes } from "./routes/bugReports";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { initLogger, Log } from "./logger";
|
||||
|
||||
const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
||||
|
||||
// Initialize logger
|
||||
initLogger();
|
||||
|
||||
@@ -13,7 +15,12 @@ initLogger();
|
||||
validateConfig();
|
||||
|
||||
// Run database migrations
|
||||
try {
|
||||
await runMigrations();
|
||||
} catch (error) {
|
||||
Log("ERROR", "Failed to run migrations:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
.onRequest(({ request }) => {
|
||||
@@ -22,7 +29,7 @@ const app = new Elysia()
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
Log("HTTP", `${request.method} ${url.pathname} from ${ip}`);
|
||||
Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`);
|
||||
})
|
||||
.onAfterResponse(({ request, set }) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -33,11 +40,13 @@ const app = new Elysia()
|
||||
set.status = 500;
|
||||
return { success: false, message: "Internal server error" };
|
||||
})
|
||||
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
|
||||
.get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() }))
|
||||
.get("/", () => ({ status: "ok", message: "API is running" }))
|
||||
.use(bugReportRoutes)
|
||||
.use(adminRoutes)
|
||||
.listen({
|
||||
port: config.port,
|
||||
//@ts-ignore
|
||||
maxBody: 50 * 1024 * 1024, // 50MB
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||
|
||||
Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
|
||||
|
||||
// Parse system_info — may arrive as a JSON string or already-parsed object
|
||||
// Parse system_info - may arrive as a JSON string or already-parsed object
|
||||
let systemInfo: Record<string, unknown> | null = null;
|
||||
if (system_info) {
|
||||
if (typeof system_info === "string") {
|
||||
|
||||
39
src/wait-for-mysql.ts
Normal file
39
src/wait-for-mysql.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import net from 'net'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
const host = process.env.MYSQL_HOST || 'mysql'
|
||||
const port = parseInt(process.env.MYSQL_PORT || '3306', 10)
|
||||
const retryDelay = 2000
|
||||
|
||||
function waitForPort(host: string, port: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const tryConnect = () => {
|
||||
const socket = new net.Socket()
|
||||
socket.setTimeout(2000)
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
resolve()
|
||||
})
|
||||
socket.once('error', () => {
|
||||
socket.destroy()
|
||||
setTimeout(tryConnect, retryDelay)
|
||||
})
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy()
|
||||
setTimeout(tryConnect, retryDelay)
|
||||
})
|
||||
socket.connect(port, host)
|
||||
}
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log(`Waiting for MySQL at ${host}:${port}...`)
|
||||
await waitForPort(host, port)
|
||||
console.log('MySQL is available - starting API.')
|
||||
const child = spawn('bun', ['run', 'src/index.ts'], { stdio: 'inherit' })
|
||||
;['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) =>
|
||||
process.on(sig as NodeJS.Signals, () => child.kill(sig as NodeJS.Signals))
|
||||
)
|
||||
})()
|
||||
Reference in New Issue
Block a user