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
|
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:
|
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: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
|
INSTANCE_ID: ${HOSTNAME}
|
||||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||||
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
||||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||||
@@ -13,5 +48,26 @@ services:
|
|||||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
||||||
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs/api:/app/logs
|
- emly-api-logs:/app/logs
|
||||||
restart: on-failure
|
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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function runMigrations(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await pool.execute(migration);
|
await pool.execute(migration);
|
||||||
} catch {
|
} 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 { adminRoutes } from "./routes/admin";
|
||||||
import { initLogger, Log } from "./logger";
|
import { initLogger, Log } from "./logger";
|
||||||
|
|
||||||
|
const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
||||||
|
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
initLogger();
|
initLogger();
|
||||||
|
|
||||||
@@ -13,7 +15,12 @@ initLogger();
|
|||||||
validateConfig();
|
validateConfig();
|
||||||
|
|
||||||
// Run database migrations
|
// Run database migrations
|
||||||
|
try {
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
} catch (error) {
|
||||||
|
Log("ERROR", "Failed to run migrations:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.onRequest(({ request }) => {
|
.onRequest(({ request }) => {
|
||||||
@@ -22,7 +29,7 @@ const app = new Elysia()
|
|||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
request.headers.get("x-real-ip") ||
|
request.headers.get("x-real-ip") ||
|
||||||
"unknown";
|
"unknown";
|
||||||
Log("HTTP", `${request.method} ${url.pathname} from ${ip}`);
|
Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`);
|
||||||
})
|
})
|
||||||
.onAfterResponse(({ request, set }) => {
|
.onAfterResponse(({ request, set }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -33,11 +40,13 @@ const app = new Elysia()
|
|||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { success: false, message: "Internal server error" };
|
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(bugReportRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.listen({
|
.listen({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
//@ts-ignore
|
||||||
maxBody: 50 * 1024 * 1024, // 50MB
|
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}`);
|
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;
|
let systemInfo: Record<string, unknown> | null = null;
|
||||||
if (system_info) {
|
if (system_info) {
|
||||||
if (typeof system_info === "string") {
|
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