diff --git a/.githooks/pre-commit/build_docs.sh b/.githooks/pre-commit/build_docs.sh new file mode 100755 index 000000000..93d0c707b --- /dev/null +++ b/.githooks/pre-commit/build_docs.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +# Get the list of staged files +staged_files=$(git diff --cached --name-status | grep -E '^(A|M)' | awk '{print $2}') + +# Exit early if no files are staged for addition or modification +if [[ -z "$staged_files" ]]; then + # echo "No files staged for addition or modification. Exiting without linting." + exit 0 +fi + +# check if any of the staged files are in the docs directory +# ignore this if any of the files from docs/.vitepress/dist is staged +if [[ -n $(echo "$staged_files" | grep -E '^docs/') ]] && [[ -z $(echo "$staged_files" | grep -E '^docs/.vitepress/dist/') ]]; then + echo "Docs have changed but not built yet. Building docs..." + docker compose run -T --rm --entrypoint "" docs npm run docs:build + echo "Docs built successfully. Please stage the changes in docs/.vitepress/dist/ and commit again." + exit 1 +fi \ No newline at end of file diff --git a/api/.gitignore b/api/.gitignore index 539e8ac7d..cfdecf586 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -35,7 +35,7 @@ swagger_output.json # generated reports reports -test.js +src/test*.js src/test.js coverage diff --git a/api/Dockerfile b/api/Dockerfile index 5b14fcc59..f258f3227 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM node:19 +FROM node:21 ARG APP_GID ARG APP_UID @@ -23,9 +23,9 @@ RUN mkdir -p /home/appuser/.local/bin ENV PATH="/home/appuser/.local/bin/:${PATH}" # install pnpm and install packages from pnpm-lock.yaml -RUN npm install pm2 -g +# RUN npm install pm2 -g # RUN npm install -g pnpm -ENTRYPOINT npm ci && npx prisma generate client && exec pm2-runtime ecosystem.config.js +ENTRYPOINT npm ci && npx prisma generate client && exec node src/cluster.js # ENTRYPOINT pnpm fetch && npx prisma db push && exec /opt/sca/app/node_modules/.bin/nodemon --signal SIGTERM src/index.js # Entrypoint will be run in the shell mode - https://docs.docker.com/engine/reference/builder/#shell-form-entrypoint-example diff --git a/api/config/default.json b/api/config/default.json index ebc41c8d2..8b3e2e720 100644 --- a/api/config/default.json +++ b/api/config/default.json @@ -5,8 +5,20 @@ "port": 3030, "host": "localhost" }, + "metrics": { + "cluster": { + "port": 9999 + } + }, + "cluster": { + "enabled": true, + "max_workers": 2, + "max_restarts": 3, + "max_restarts_interval": 10000, + "grace": 5000 + }, "logger": { - "level": "debug" + "level": "info" }, "workflow_server": { "base_url": "http://127.0.0.1:5001", diff --git a/api/config/localhost.json b/api/config/localhost.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/api/config/localhost.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/api/ecosystem.config.js b/api/ecosystem.config.js deleted file mode 100644 index 0aa9135bb..000000000 --- a/api/ecosystem.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = [{ - script: 'src/index.js', - name: 'api', - exec_mode: 'cluster', - instances: 2, - exp_backoff_restart_delay: 100, - max_restarts: 3, - watch: false, -}]; diff --git a/api/package-lock.json b/api/package-lock.json index 9987de985..b1799aa7d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -22,6 +22,7 @@ "dompurify": "^3.0.9", "dotenv-safe": "^8.2.0", "express": "^4.18.2", + "express-prom-bundle": "^8.0.0", "express-validator": "^7.0.1", "he": "^1.2.0", "http-errors": "^2.0.0", @@ -30,8 +31,13 @@ "lodash": "^4.17.21", "morgan": "^1.10.0", "multer": "1.4.5-lts.1", + "node-cron": "^3.0.3", "picomatch": "^4.0.2", - "spark-md5": "^3.0.2", + "pino": "^9.6.0", + "pino-caller": "^3.4.0", + "pino-pretty": "^13.0.0", + "prom-client": "^15.1.3", + "rate-limiter-flexible": "^6.2.1", "swagger-autogen": "^2.23.5", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", @@ -381,6 +387,14 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -455,6 +469,49 @@ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", "hasInstallScript": true }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", + "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -467,12 +524,54 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/node": { + "version": "22.13.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", + "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -952,6 +1051,14 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1021,6 +1128,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -1333,6 +1445,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + }, "node_modules/colorspace": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", @@ -1621,6 +1738,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -1880,6 +2005,14 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhance-visitors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/enhance-visitors/-/enhance-visitors-1.0.0.tgz", @@ -2463,6 +2596,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-prom-bundle": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-8.0.0.tgz", + "integrity": "sha512-UHdpaMks6Z/tvxQsNzhsE7nkdXb4/zEh/jwN0tfZSZOEF+aD0dlfl085EU4jveOq09v01c5sIUfjV4kJODZ2eQ==", + "dependencies": { + "@types/express": "^5.0.0", + "on-finished": "^2.3.0", + "url-value-parser": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prom-client": ">=15.0.0" + } + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -2491,6 +2640,11 @@ "node >=0.6.0" ] }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2537,6 +2691,19 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3015,6 +3182,11 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3628,6 +3800,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4204,6 +4384,25 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", @@ -4431,6 +4630,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4641,6 +4848,77 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-caller": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pino-caller/-/pino-caller-3.4.0.tgz", + "integrity": "sha512-2aEjlmhLA7J3lGBXKDSxtlfDY+cBzGh5PnLFP6ZUhvyqCnqKfv28ulpSch6uymGIdo7fzxXHK2hvR5FrdzbhTg==", + "dependencies": { + "source-map-support": "^0.5.13" + }, + "engines": { + "node": ">6.0.0" + }, + "peerDependencies": { + "pino": "*" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.0.0.tgz", + "integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4691,6 +4969,33 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4725,6 +5030,15 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4772,6 +5086,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/random": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz", @@ -4798,6 +5117,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-6.2.1.tgz", + "integrity": "sha512-d9AN+d/wwKW3/yHAL0G3zKpWZQFe55VjRGIFK9VG1w3CSOkcRqRqh0NhCiIXvgKhihNZPjGfISuN3it07NjPbw==" + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -4866,6 +5190,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5126,6 +5458,11 @@ "node": ">=v12.22.7" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, "node_modules/seedrandom": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", @@ -5372,10 +5709,38 @@ "node": ">=8" } }, - "node_modules/spark-md5": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", - "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } }, "node_modules/stack-trace": { "version": "0.0.10", @@ -5507,7 +5872,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -5588,6 +5952,14 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -5599,6 +5971,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5858,6 +6238,11 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -5892,6 +6277,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-value-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz", + "integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/api/package.json b/api/package.json index 4dcc7729d..11964c031 100644 --- a/api/package.json +++ b/api/package.json @@ -7,7 +7,7 @@ "node": ">=18.12.0" }, "scripts": { - "start": "nodemon src/index.js", + "start": "nodemon src/cluster.js", "swagger": "node src/scripts/swagger.js", "lint": "eslint . --ext .js --fix --ignore-path .gitignore" }, @@ -40,6 +40,7 @@ "dompurify": "^3.0.9", "dotenv-safe": "^8.2.0", "express": "^4.18.2", + "express-prom-bundle": "^8.0.0", "express-validator": "^7.0.1", "he": "^1.2.0", "http-errors": "^2.0.0", @@ -48,8 +49,13 @@ "lodash": "^4.17.21", "morgan": "^1.10.0", "multer": "1.4.5-lts.1", + "node-cron": "^3.0.3", "picomatch": "^4.0.2", - "spark-md5": "^3.0.2", + "pino": "^9.6.0", + "pino-caller": "^3.4.0", + "pino-pretty": "^13.0.0", + "prom-client": "^15.1.3", + "rate-limiter-flexible": "^6.2.1", "swagger-autogen": "^2.23.5", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", diff --git a/api/src/app.js b/api/src/app.js index c83daa0fe..9899f8c34 100644 --- a/api/src/app.js +++ b/api/src/app.js @@ -16,6 +16,8 @@ const { axiosErrorHandler, prismaConstraintFailedHandler, } = require('./middleware/error'); +const { metricsMiddleware } = require('./core/metrics'); +const swagger = require('./scripts/swagger'); // Register application const app = express(); @@ -46,13 +48,16 @@ app.use(compression()); if (!['production', 'test'].includes(config.get('mode'))) { // mount swagger ui try { - const swaggerFile = JSON.parse(fs.readFileSync('./swagger_output.json')); + const swaggerFile = JSON.parse(fs.readFileSync(swagger.outputFile, 'utf8')); app.use('/doc', swaggerUi.serve, swaggerUi.setup(swaggerFile)); } catch (e) { - console.error('Unable to load "./swagger_output.json"', e); + // console.error('Unable to load "./swagger_output.json"', e); } } +// prometheus metrics +app.use(metricsMiddleware); + // mount router app.use('/', indexRouter); diff --git a/api/src/cluster.js b/api/src/cluster.js new file mode 100644 index 000000000..c5167c311 --- /dev/null +++ b/api/src/cluster.js @@ -0,0 +1,53 @@ +/* eslint-disable no-console */ +const path = require('path'); +// __basedir is the path of root directory +// has same value when used in any js file in this project +global.__basedir = path.join(__dirname, '..'); +require('dotenv-safe').config(); +const config = require('config'); + +const express = require('express'); +const promBundle = require('express-prom-bundle'); +const promClient = require('prom-client'); +const manage_cluster = require('./core/cluster-manager'); +const { beforeApplicationFork } = require('./core/lifecycle'); + +function master() { + const metricsApp = express(); + metricsApp.use('/metrics', promBundle.clusterMetrics()); + + const port = config.get('metrics.cluster.port'); + // const metricsServer = + metricsApp.listen(port, () => { + console.log(`Aggregated metrics listening on : http://localhost:${port}/metrics`); + }); + + // function shutdown() { + // return new Promise((resolve) => { + // metricsServer.close(() => { + // console.log('Metrics server closed'); + // resolve(); + // }); + // }); + // } + // // Attach shutdown handler to the master process + // return shutdown; +} + +function worker() { + // eslint-disable-next-line no-new + new promClient.AggregatorRegistry(); // Required for metrics + // eslint-disable-next-line global-require + require('./index'); // Business logic +} + +manage_cluster({ + master, + worker, + beforeApplicationFork, + count: config.get('cluster.max_workers'), + max_restarts: config.get('cluster.max_restarts'), + max_restarts_interval: config.get('cluster.max_restarts_interval'), + grace: config.get('cluster.grace'), + signals: ['SIGTERM', 'SIGINT'], +}); diff --git a/api/src/core/cluster-manager.js b/api/src/core/cluster-manager.js new file mode 100644 index 000000000..5eebf75e9 --- /dev/null +++ b/api/src/core/cluster-manager.js @@ -0,0 +1,122 @@ +/* eslint-disable no-console */ +const cluster = require('node:cluster'); +const os = require('node:os'); +const process = require('node:process'); +const { RateLimiterMemory } = require('rate-limiter-flexible'); + +/** + * Manages a cluster of worker processes with configurable options for scaling, + * restart limits, and graceful shutdown. + * + * @param {Object} options - Configuration options for the cluster manager. + * @param {Function} [options.master=null] - Optional callback to execute in the master process. + * @param {Function} options.worker - Callback to execute in each worker process. + * @param {Function} [options.beforeApplicationFork=null] - Optional callback to execute before forking workers. + * @param {number} [options.count=2] - Desired number of worker processes (default is 2). + * @param {number} [options.max_restarts=3] - Maximum number of worker restarts allowed within the interval. + * @param {number} [options.max_restarts_interval=10000] - Time interval (in milliseconds) for the restart limit. + * @param {number} [options.grace=5000] - Grace period (in milliseconds) for workers to shut down gracefully. + * @param {string[]} [options.signals=['SIGINT', 'SIGTERM']] - List of signals to listen for to trigger shutdown. + * + * @returns {void} + * + * @example + * manage_cluster({ + * master: () => console.log('Master process running'), + * worker: () => setInterval(() => console.log('Worker process running'), 1000), + * count: 4, + * max_restarts: 5, + * max_restarts_interval: 15000, + * grace: 3000, + * signals: ['SIGINT', 'SIGTERM', 'SIGHUP'], + * }); + */ +async function manage_cluster({ + master = null, + worker, + beforeApplicationFork = null, + count = 2, + max_restarts = 3, + max_restarts_interval = 10000, + grace = 5000, + signals = ['SIGINT', 'SIGTERM'], +}) { + const numCPUs = Math.min(count, os.availableParallelism()); + + if (cluster.isPrimary) { + console.log(`Primary ${process.pid} is running`); + + // Execute the optional callback before forking workers + if (beforeApplicationFork) { + await beforeApplicationFork(); + } + + let activeWorkers = numCPUs; + let exiting = false; + const restartLimiter = new RateLimiterMemory({ + points: max_restarts, + duration: max_restarts_interval / 1000, // seconds + }); + + for (let i = 0; i < numCPUs; i += 1) { + cluster.fork(); + } + + cluster.on('exit', async (_worker, code, signal) => { + activeWorkers -= 1; + console.log(`Worker ${_worker.process.pid} died (${signal || code})`); + + if (!exiting) { + try { + await restartLimiter.consume('restart'); + console.log('Restarting worker...'); + activeWorkers += 1; + cluster.fork(); + } catch { + console.log('Restart limit reached, not spawning new workers.'); + } + } + + if (activeWorkers === 0) { + console.log('All workers have exited. Shutting down master...'); + if (exiting) { + process.exit(0); + } + process.exit(1); + } + }); + + // let shutdownMaster; + const shutdown = async () => { + // If the shutdown function is already called, do nothing to prevent multiple calls + if (exiting) return; + + console.log('Shutting down cluster...'); + exiting = true; + + Object.values(cluster.workers).forEach((_worker) => _worker.process.kill('SIGTERM')); + // unref- If there is no other activity keeping the event loop running, + // the process may exit before the Timeout object's callback is invoked. + setTimeout(() => process.exit(1), grace).unref(); + + // try { + // await shutdownMaster?.(); + // } catch (error) { + // console.error('Error during master shutdown:', error); + // } finally { + // Object.values(cluster.workers).forEach((_worker) => _worker.process.kill('SIGTERM')); + // // unref- If there is no other activity keeping the event loop running, + // // the process may exit before the Timeout object's callback is invoked. + // setTimeout(() => process.exit(1), grace).unref(); + // } + }; + + signals.forEach((signal) => process.on(signal, shutdown)); + master?.(); + } else { + console.log(`Worker ${process.pid} started`); + worker?.(); + } +} + +module.exports = manage_cluster; diff --git a/api/src/core/cron.js b/api/src/core/cron.js new file mode 100644 index 000000000..83eda599a --- /dev/null +++ b/api/src/core/cron.js @@ -0,0 +1,15 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable global-require */ +const cron = require('node-cron'); +const { createTaskLogger } = require('./logger'); + +function registerCronJobs() { + // Example: Schedule a task to run every minute + // cron.schedule('* * * * *', () => { + // const task = require('../cron/exampleTask.cron'); + // const taskLogger = createTaskLogger('exampleTask'); + // task.run(taskLogger); + // }); +} + +module.exports = registerCronJobs; diff --git a/api/src/core/lifecycle.js b/api/src/core/lifecycle.js new file mode 100644 index 000000000..e731b13a0 --- /dev/null +++ b/api/src/core/lifecycle.js @@ -0,0 +1,52 @@ +const fs = require('node:fs'); +const config = require('config'); +const { logger } = require('./logger'); +const swagger = require('../scripts/swagger'); +const registerCronJobs = require('./cron'); + +// run only in master process before forking workers +async function beforeApplicationFork() { + try { + logger.debug('Before Application Fork: Running master process tasks (one-time setup)'); + + if (!fs.existsSync(swagger.outputFile)) { + await swagger.generate(); + } + + registerCronJobs(); + } catch (error) { + logger.error('Error in beforeApplicationFork:', error); + process.exit(1); // Exit if critical setup fails + } +} + +async function onApplicationBootstrap() { + try { + if (config.get('auth.auto_sign_up.enabled')) { + logger.warn(`⚠️ Auto sign up (default role: "${config.get('auth.auto_sign_up.default_role')}") is enabled. \ +If this is not intended, please disable it in the configuration. \ +This feature can be exploited to create multiple user accounts without rate limiting.‼️\n`); + } + logger.debug('On Application Bootstrap'); + } catch (error) { + logger.error('Error in onApplicationBootstrap:', error); + process.exit(1); // Exit if critical setup fails + } +} + +// run in worker processes when shutting down before closing the server +async function beforeApplicationShutdown() { + logger.debug('Before Application Shutdown'); +} + +// run in worker processes after closing the server +async function onApplicationShutdown() { + logger.debug('On Application Shutdown'); +} + +module.exports = { + beforeApplicationFork, + onApplicationBootstrap, + beforeApplicationShutdown, + onApplicationShutdown, +}; diff --git a/api/src/core/logger.js b/api/src/core/logger.js new file mode 100644 index 000000000..f69e7c228 --- /dev/null +++ b/api/src/core/logger.js @@ -0,0 +1,61 @@ +const path = require('path'); + +const pino = require('pino'); +const pinoCaller = require('pino-caller'); +const config = require('config'); + +function createDevLogger() { + const logger = pino({ + level: config.get('logger.level'), + formatters: { + level: (label) => ({ level: label.toUpperCase() }), + }, + timestamp: pino.stdTimeFunctions.isoTime, + transport: { + target: 'pino-pretty', + options: { colorize: true }, // Add color for better visibility + }, + }); + + const loggerWithCaller = pinoCaller(logger, { relativeTo: global.__basedir }); + return loggerWithCaller; +} + +/** + * Creates a Pino logger instance for a given task + * @param {string} taskName - Name of the task + * @returns {pino.Logger} - Pino logger instance + */ +function createTaskLogger(taskName) { + return pino({ + level: config.get('logger.level'), + formatters: { + level: (label) => ({ level: label.toUpperCase() }), + }, + timestamp: pino.stdTimeFunctions.isoTime, + transport: { + target: 'pino/file', + options: { + destination: path.join(global.__basedir, `logs/${taskName}.log`), // Log to task-specific file + mkdir: true, // Ensure directory exists + }, + }, + }); +} + +// https://betterstack.com/community/guides/logging/how-to-install-setup-and-use-pino-to-log-node-js-applications/#adding-context-to-your-logs +// logger.error( +// { transaction_id: '12343_ff', user_id: 'johndoe' }, +// 'Transaction failed' +// ); + +// try { +// alwaysThrowError(); +// } catch (err) { +// logger.error(err, 'An unexpected error occurred while processing the request'); +// } + +module.exports = { + logger: config.get('mode') === 'production' ? createTaskLogger('app') : createDevLogger(), + createTaskLogger, +}; diff --git a/api/src/core/metrics.js b/api/src/core/metrics.js new file mode 100644 index 000000000..fac21e3f3 --- /dev/null +++ b/api/src/core/metrics.js @@ -0,0 +1,84 @@ +const promBundle = require('express-prom-bundle'); +const client = require('prom-client'); +const config = require('config'); + +const CLIENT_CLOSED_REQUEST_CODE = 499; + +function normalizeStatusCode(res) { + if (res.headersSent) { + const code = res.status_code || res.statusCode; + // collapse all codes starting with 2 to '2xx' + if (code >= 200 && code < 300) { + return '2xx'; + } + // collapse all codes starting with 3 to '3xx' + if (code >= 300 && code < 400) { + return '3xx'; + } + // collapse all codes starting with 4 to '4xx' + // if (code >= 400 && code < 500) { + // return '4xx'; + // } + // collapse all codes starting with 5 to '5xx' + // if (code >= 500 && code < 600) { + // return '5xx'; + // } + return `${code}`; + } + return CLIENT_CLOSED_REQUEST_CODE; +} + +/** + * Reasing for histogram buckets: + * 0 - 0.03 (0-30ms, mostly empty but captures edge cases) + * 0.03 - 0.1 (30-100ms, high-resolution for fast requests) + * 0.1 - 0.3 (100-300ms, common API latencies) + * 0.3 - 1.0 (300ms-1s, still in acceptable range) + * 1.0 - 3.0 (1s-3s, noticeable slowdowns) + * 3.0 - 10.0 (3s-10s, major slow responses) + * 10.0 - 30.0 (10s-30s, very slow but still relevant) + * 30.0 - Inf (Requests that are extremely slow) + */ +const metricsMiddleware = promBundle({ + autoregister: !config.get('cluster.enabled'), + includeMethod: true, + includePath: true, + formatStatusCode: normalizeStatusCode, + // Return the path of the express route (i.e. /projects/:username/datasets/:id + // or /datasets/:id) instead of the full URL (i.e. /projects/ben/datasets/1234)") + normalizePath: (req) => req.baseUrl + (req.route?.path || req.path), + promClient: { + collectDefaultMetrics: { + }, + }, + buckets: [0.03, 0.1, 0.3, 1.0, 3.0, 10.0, 30.0], +}); + +/** + * Counter metric to track the total number of failed authentication attempts. + * + * This metric is labeled with the following: + * - `auth_method`: The authentication method used (e.g., 'password', 'oauth'). + * - `reason`: The reason for the failure (e.g., 'invalid_credentials', 'account_locked'). + * - `client_id`: The identifier for the client making the request (e.g., 'web', 'cli'). + * + * Example usage: + * + * ```javascript + * authFailures.inc({ + * auth_method: 'password', + * reason: 'invalid_credentials', + * client_id: 'web', + * }); + * ``` + */ +const authFailures = new client.Counter({ + name: 'auth_failures_total', + help: 'Total number of failed authentication attempts', + labelNames: ['auth_method', 'reason', 'client_id'], +}); + +module.exports = { + metricsMiddleware, + authFailures, +}; diff --git a/api/src/cron/exampleTask.cron.js b/api/src/cron/exampleTask.cron.js new file mode 100644 index 000000000..a158f623c --- /dev/null +++ b/api/src/cron/exampleTask.cron.js @@ -0,0 +1,12 @@ +module.exports.run = async function run(logger) { + logger.info('Example Task is running'); + // Your task logic here + try { + // Simulate some work + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + throw new Error('Simulated error for demonstration purposes'); + // logger.info('Example Task completed successfully'); + } catch (error) { + logger.error(error, 'Example Task failed'); + } +}; diff --git a/api/src/db.js b/api/src/db.js index 6f7282ed6..b4129ac27 100644 --- a/api/src/db.js +++ b/api/src/db.js @@ -18,7 +18,9 @@ require('dotenv-safe').config(); // // console.log(dbUrl); // process.env.DATABASE_URL = dbUrl; -// Postgres columns can be of BigInt type for which serialization is not defined +// Override BigInt serialization for JSON.stringify +// Postgres columns can be of BigInt type, which JSON.stringify does not handle by default. +// This ensures BigInt values are converted to strings during serialization. // eslint-disable-next-line no-extend-native, func-names BigInt.prototype.toJSON = function () { return this.toString(); diff --git a/api/src/index.js b/api/src/index.js index 0a277ff3b..077d395f8 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,46 +1,58 @@ const path = require('path'); -// __basedir is the path of root directory -// has same value when used in any js file in this project +// __basedir is the path of the root directory +// has the same value when used in any JS file in this project global.__basedir = path.join(__dirname, '..'); +// Load environment variables and validate against .env.example require('dotenv-safe').config(); + require('./db'); const config = require('config'); const app = require('./app'); -const logger = require('./services/logger'); +const { logger } = require('./core/logger'); +const { + onApplicationBootstrap, + beforeApplicationShutdown, + onApplicationShutdown, +} = require('./core/lifecycle'); +const { safeAwait } = require('./utils'); -// log a warning when auto sign up is enabled -if (config.get('auth.auto_sign_up.enabled')) { - logger.warn(`⚠️ Auto sign up (default role: "${config.get('auth.auto_sign_up.default_role')}") is enabled. \ -If this is not intended, please disable it in the configuration. \ -This feature can be exploited to create multiple user accounts without rate limiting.‼️\n`); -} +async function main() { + await onApplicationBootstrap(); -const port = config.get('express.port'); -const host = config.get('express.host'); -const server = app.listen(port, () => { - logger.info(`Listening: http://${host}:${port}`); -}); + const port = config.get('express.port'); + const host = config.get('express.host'); + const server = app.listen(port, () => { + logger.info(`Listening: http://${host}:${port}`); + }); -const shutdown = () => { - logger.warn('server: closing'); - server.close((err) => { - if (err) { - logger.error('server: closed with ERROR', err); - process.exit(1); - } - logger.warn('server: closed'); - process.exit(); + const shutdown = async () => { + await safeAwait(beforeApplicationShutdown()); + logger.debug('server: closing'); + server.close(async (err) => { + if (err) { + logger.error('server: closed with ERROR', err); + process.exit(1); + } + await safeAwait(onApplicationShutdown()); + logger.warn('server: closed'); + process.exit(); + }); + }; + + process.on('SIGINT', () => { + logger.debug('process received SIGINT'); + shutdown(); }); -}; -process.on('SIGINT', () => { - logger.warn('process received SIGINT'); - shutdown(); -}); + process.on('SIGTERM', () => { + logger.debug('process received SIGTERM'); + shutdown(); + }); +} -process.on('SIGTERM', () => { - logger.warn('process received SIGTERM'); - shutdown(); +main().catch((err) => { + logger.error('Error in main:', err); + process.exit(1); }); diff --git a/api/src/middleware/error.js b/api/src/middleware/error.js index bb516bcdc..62c5f6bf9 100644 --- a/api/src/middleware/error.js +++ b/api/src/middleware/error.js @@ -4,7 +4,7 @@ const { Prisma } = require('@prisma/client'); const axios = require('axios'); const _ = require('lodash/fp'); const { log_axios_error } = require('../utils'); -const logger = require('../services/logger'); +const { logger } = require('../core/logger'); // catch 404 and forward to error handler function notFound(req, res, next) { diff --git a/api/src/routes/datasetUploads.js b/api/src/routes/datasetUploads.js index 9be86721c..84b922406 100644 --- a/api/src/routes/datasetUploads.js +++ b/api/src/routes/datasetUploads.js @@ -6,7 +6,7 @@ const path = require('path'); const { PrismaClient } = require('@prisma/client'); const createError = require('http-errors'); -const logger = require('../services/logger'); +const { logger } = require('../core/logger'); const asyncHandler = require('../middleware/asyncHandler'); const { validate } = require('../middleware/validators'); const { accessControl } = require('../middleware/auth'); diff --git a/api/src/routes/datasets.js b/api/src/routes/datasets.js index 115231ae6..cde3fbba4 100644 --- a/api/src/routes/datasets.js +++ b/api/src/routes/datasets.js @@ -17,7 +17,7 @@ const { validate } = require('../middleware/validators'); const datasetService = require('../services/dataset'); const authService = require('../services/auth'); const CONSTANTS = require('../constants'); -const logger = require('../services/logger'); +const { logger } = require('../core/logger'); const isPermittedTo = accessControl('datasets'); diff --git a/api/src/routes/index.js b/api/src/routes/index.js index c2865bda9..a765b05de 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -13,11 +13,11 @@ router.use('/env', require('./env')); // From this point on, all routes require authentication. router.use(authenticate); -router.use('/datasets', require('./datasets') /* #swagger.security = [{"BearerAuth": []}] */); -router.use('/metrics', require('./metrics') /* #swagger.security = [{"BearerAuth": []}] */); -router.use('/users', require('./users') /* #swagger.security = [{"BearerAuth": []}] */); -router.use('/workflows', require('./workflows') /* #swagger.security = [{"BearerAuth": []}] */); -router.use('/projects', require('./projects') /* #swagger.security = [{"BearerAuth": []}] */); +router.use('/datasets', require('./datasets')); +router.use('/resource-metrics', require('./metrics')); +router.use('/users', require('./users')); +router.use('/workflows', require('./workflows')); +router.use('/projects', require('./projects')); router.use('/statistics', require('./statistics')); router.use('/notifications', require('./notifications')); router.use('/fs', require('./fs')); diff --git a/api/src/scripts/swagger.js b/api/src/scripts/swagger.js index 0e2c79810..281fa2830 100644 --- a/api/src/scripts/swagger.js +++ b/api/src/scripts/swagger.js @@ -1,5 +1,6 @@ const swaggerAutogen = require('swagger-autogen')(); const config = require('config'); +const path = require('node:path'); const doc = { info: { @@ -25,7 +26,16 @@ const doc = { ], }; -const outputFile = '../../swagger_output.json'; -const endpointsFiles = ['../routes/index.js']; +const outputFile = path.join(global.__basedir, 'swagger_output.json'); // '../../swagger_output.json'; +const endpointsFiles = [path.join(global.__basedir, 'src', 'routes', 'index.js')]; // ['../routes/index.js']; -swaggerAutogen(outputFile, endpointsFiles, doc); +// Generate swagger.json file +function generate() { + return swaggerAutogen(outputFile, endpointsFiles, doc); +} + +if (require.main === module) { + generate(); +} + +module.exports = { generate, outputFile }; diff --git a/api/src/services/auth.js b/api/src/services/auth.js index d62a689ae..f6e756e86 100644 --- a/api/src/services/auth.js +++ b/api/src/services/auth.js @@ -8,7 +8,7 @@ const config = require('config'); const { OAuth2Client } = require('@badgateway/oauth2-client'); const { PrismaClient } = require('@prisma/client'); -const logger = require('./logger'); +const { logger } = require('../core/logger'); const userService = require('./user'); const prisma = new PrismaClient(); @@ -105,7 +105,7 @@ const find_or_create_test_user = async ({ role }) => { }; function get_upload_token(file_path) { - // [^\w.-]+ matches one or more characters that are not word + // [^\w.-]+ matches one or more characters that are not word // characters (letters, digits, or underscore), dots, or hyphens const hyphen_delimited_file_path = file_path.replace(/[^\w.-]+/g, '-'); const scope = `${config.get('oauth.upload.scope_prefix')}${hyphen_delimited_file_path}`; diff --git a/api/src/services/logger.js b/api/src/services/logger.js deleted file mode 100644 index ba2342e11..000000000 --- a/api/src/services/logger.js +++ /dev/null @@ -1,20 +0,0 @@ -const winston = require('winston'); -const { format } = require('winston'); -const config = require('config'); - -const logger = winston.createLogger({ - format: format.combine( - format.timestamp({ - format: 'YYYY-MM-DD HH:mm:ss', - }), - // format.json(), - // eslint-disable-next-line max-len - format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}${info.splat !== undefined ? `${info.splat}` : ' '}`), - ), - transports: [ - new (winston.transports.Console)({ - level: config.get('logger.level'), - }), - ], -}); -module.exports = logger; diff --git a/api/src/utils/db.js b/api/src/utils/db.js new file mode 100644 index 000000000..e702bfea2 --- /dev/null +++ b/api/src/utils/db.js @@ -0,0 +1,76 @@ +const { Prisma } = require('@prisma/client'); + +class TransactionRetryError extends Error { + constructor(maxRetries, message = 'Transaction failed after maximum retries') { + const _mesage = maxRetries ? `${message}. Maximum retries: ${maxRetries}` : message; + super(_mesage); + this.name = 'TransactionRetryError'; + } +} + +/** + * Executes a transaction with retry logic in case of write conflicts or deadlocks. + * + * @param {object} prisma - The Prisma client instance. + * @param {function|array} statementsOrFn - A function representing an interactive transaction or an array of statements for a batch transaction. + * @param {object} [options] - Optional settings for the transaction. + * @param {number} [options.maxRetries=5] - The maximum number of retry attempts. + * @param {string} [options.isolationLevel=Prisma.TransactionIsolationLevel.RepeatableRead] - The isolation level for the transaction. + * @param {} [options.kwargs] - Additional options to pass to the transaction. + * + * @throws {TypeError} If the input is not a function or an array of statements. + * @throws {Error} If the Prisma client is not defined. + * @throws {Error} If maxRetries is less than 1. + * @throws {TransactionRetryError} If all retry attempts are exhausted. + * @returns {Promise<*>} The result of the transaction. + */ +async function transactionWithRetry( + prisma, + statementsOrFn, + { + maxRetries = 5, + isolationLevel = Prisma.TransactionIsolationLevel.RepeatableRead, + ...kwargs + } = {}, +) { + let retries = 0; + const isFunction = typeof statementsOrFn === 'function'; + const isArray = Array.isArray(statementsOrFn); + + if (!isFunction && !isArray) { + throw new TypeError('Invalid transaction input. Must be a function or an array of statements.'); + } + if (!prisma) { + throw new Error('Prisma client is not defined'); + } + if (maxRetries < 1) { + throw new Error('maxRetries must be greater than 0'); + } + while (retries < maxRetries) { + try { + if (isFunction) { + // Interactive transaction + // eslint-disable-next-line no-await-in-loop + return await prisma.$transaction(statementsOrFn, { isolationLevel, ...kwargs }); + } + // Batch transaction + // eslint-disable-next-line no-await-in-loop + return await prisma.$transaction(statementsOrFn, { isolationLevel, ...kwargs }); + } catch (error) { + if (error?.code === 'P2034') { + // P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction" + console.warn(`Transaction failed with error code P2034. Retrying (${retries + 1}/${maxRetries})...`); + retries += 1; + } else { + throw error; + } + } + } + // if we reach here, it means we have exhausted all retries + throw new TransactionRetryError(maxRetries); +} + +module.exports = { + TransactionRetryError, + transactionWithRetry, +}; diff --git a/api/src/utils/index.js b/api/src/utils/index.js index d534c95eb..f954e3e5e 100644 --- a/api/src/utils/index.js +++ b/api/src/utils/index.js @@ -1,7 +1,6 @@ const fs = require('fs'); const path = require('path'); const _ = require('lodash/fp'); -const { Prisma } = require('@prisma/client'); function renameKey(oldKey, newKey) { return (obj) => { @@ -232,82 +231,32 @@ function readUsersFromJSON(fname) { } } -class TransactionRetryError extends Error { - constructor(maxRetries, message = 'Transaction failed after maximum retries') { - const _mesage = maxRetries ? `${message}. Maximum retries: ${maxRetries}` : message; - super(_mesage); - this.name = 'TransactionRetryError'; - } +function base64urlEncode(buf) { + return buf + .toString('base64') // Regular base64 encoder + .replace(/=/g, '') // Remove any trailing '='s + .replace(/\+/g, '-') // Replace '+' with '-' + .replace(/\//g, '_'); // Replace '/' with '_' } /** - * Executes a transaction with retry logic in case of write conflicts or deadlocks. - * - * @param {object} prisma - The Prisma client instance. - * @param {function|array} statementsOrFn - A function representing an interactive transaction or an array of statements for a batch transaction. - * @param {object} [options] - Optional settings for the transaction. - * @param {number} [options.maxRetries=5] - The maximum number of retry attempts. - * @param {string} [options.isolationLevel=Prisma.TransactionIsolationLevel.RepeatableRead] - The isolation level for the transaction. - * @param {} [options.kwargs] - Additional options to pass to the transaction. + * Safely handles a promise by returning an array with either the resolved data or the error. + * This function helps avoid using try-catch blocks when working with async/await. * - * @throws {TypeError} If the input is not a function or an array of statements. - * @throws {Error} If the Prisma client is not defined. - * @throws {Error} If maxRetries is less than 1. - * @throws {TransactionRetryError} If all retry attempts are exhausted. - * @returns {Promise<*>} The result of the transaction. + * @param {Promise} promise - The promise to be resolved or rejected. + * @returns {Promise<[Error|null, any|null]>} A promise that resolves to an array where the first element + * is the error (if any) or null, and the second element is the resolved data or null. */ -async function transactionWithRetry( - prisma, - statementsOrFn, - { - maxRetries = 5, - isolationLevel = Prisma.TransactionIsolationLevel.RepeatableRead, - ...kwargs - } = {}, -) { - let retries = 0; - const isFunction = typeof statementsOrFn === 'function'; - const isArray = Array.isArray(statementsOrFn); - - if (!isFunction && !isArray) { - throw new TypeError('Invalid transaction input. Must be a function or an array of statements.'); - } - if (!prisma) { - throw new Error('Prisma client is not defined'); - } - if (maxRetries < 1) { - throw new Error('maxRetries must be greater than 0'); - } - while (retries < maxRetries) { - try { - if (isFunction) { - // Interactive transaction - // eslint-disable-next-line no-await-in-loop - return await prisma.$transaction(statementsOrFn, { isolationLevel, ...kwargs }); - } - // Batch transaction - // eslint-disable-next-line no-await-in-loop - return await prisma.$transaction(statementsOrFn, { isolationLevel, ...kwargs }); - } catch (error) { - if (error?.code === 'P2034') { - // P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction" - console.warn(`Transaction failed with error code P2034. Retrying (${retries + 1}/${maxRetries})...`); - retries += 1; - } else { - throw error; - } +async function safeAwait(promise, { logError = false } = {}) { + try { + const data = typeof promise.then === 'function' ? await promise : promise; + return [null, data]; + } catch (error) { + if (logError) { + console.error('Error occurred:', error); } + return [error, null]; } - // if we reach here, it means we have exhausted all retries - throw new TransactionRetryError(maxRetries); -} - -function base64urlEncode(buf) { - return buf - .toString('base64') // Regular base64 encoder - .replace(/=/g, '') // Remove any trailing '='s - .replace(/\+/g, '-') // Replace '+' with '-' - .replace(/\//g, '_'); // Replace '/' with '_' } module.exports = { @@ -321,7 +270,6 @@ module.exports = { numericStringsToNumbers, decodeJWT, readUsersFromJSON, - transactionWithRetry, - TransactionRetryError, base64urlEncode, + safeAwait, }; diff --git a/docker-compose.yml b/docker-compose.yml index 7025788a6..be141a0b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ name: bioloop services: ui: - image: node:19 + image: node:21 volumes: - ./ui/:/opt/sca/app - ui_modules:/opt/sca/app/node_modules @@ -17,7 +17,7 @@ services: npm install && exec /opt/sca/app/node_modules/.bin/vite --host api: - image: node:19 + image: node:21 # user: ${APP_UID}:${APP_GID} # build: # context: ./api @@ -43,7 +43,7 @@ services: - | npm install \ && npx prisma generate client \ - && exec /opt/sca/app/node_modules/.bin/nodemon --signal SIGTERM src/index.js + && exec /opt/sca/app/node_modules/.bin/nodemon --signal SIGTERM src/cluster.js extra_hosts: @@ -70,20 +70,20 @@ services: expose: - 5432 - nginx: - image: nginx:1.25 - ports: - - 80:80 - - 443:443 - volumes: - - ./nginx/src:/usr/share/nginx/html # Mount for Nginx static files - - ./nginx/conf:/etc/nginx/conf.d # Mount for Nginx configuration files - - ./data:/opt/sca/data:ro # Mount for data directory - - ./ui/dist:/opt/sca/ui:ro # Mount for UI static files - - ./nginx/logs:/var/log/nginx # Mount for Nginx logs - - ./ui/.cert:/etc/nginx/certs:ro # Mount for SSL certificates - extra_hosts: - - "host.docker.internal:host-gateway" # for connecting to services running on localhost of the host network + # nginx: + # image: nginx:1.25 + # ports: + # - 80:80 + # - 443:443 + # volumes: + # - ./nginx/src:/usr/share/nginx/html # Mount for Nginx static files + # - ./nginx/conf:/etc/nginx/conf.d # Mount for Nginx configuration files + # - ./data:/opt/sca/data:ro # Mount for data directory + # - ./ui/dist:/opt/sca/ui:ro # Mount for UI static files + # - ./nginx/logs:/var/log/nginx # Mount for Nginx logs + # - ./ui/.cert:/etc/nginx/certs:ro # Mount for SSL certificates + # extra_hosts: + # - "host.docker.internal:host-gateway" # for connecting to services running on localhost of the host network postgres_exporter: image: prometheuscommunity/postgres-exporter @@ -122,7 +122,21 @@ services: - GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_PASSWORD=admin - + docs: + image: node:21 + volumes: + - ./package.json:/opt/sca/app/package.json + - ./package-lock.json:/opt/sca/app/package-lock.json + - ./docs/:/opt/sca/app/docs + - docs_modules:/opt/sca/app/node_modules + ports: + - 127.0.0.1:5173:5173 + working_dir: /opt/sca/app + entrypoint: + - sh + - -c + - | + npm install && exec /opt/sca/app/node_modules/.bin/vitepress dev docs --host volumes: ui_modules: @@ -132,4 +146,5 @@ volumes: external: false grafana_data: - prometheus_data: \ No newline at end of file + prometheus_data: + docs_modules: \ No newline at end of file diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index e9f53e1a3..91e400712 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -1,10 +1,13 @@ -import { defineConfig } from 'vitepress' +import { defineConfig } from 'vitepress'; +import { withSidebar } from 'vitepress-sidebar'; // https://vitepress.dev/reference/site-config -export default defineConfig({ +const vitePressOptions = { title: "Bioloop", base: "/bioloop/docs/", description: "Bioloop Documentation", + head: [['link', { rel: 'icon', href: '/bioloop/docs/favicon.ico' }]], + lastUpdated: true, // Enable last updated timestamp themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ @@ -39,7 +42,15 @@ export default defineConfig({ socialLinks: [ { icon: 'github', link: 'https://github.com/IUSCA/bioloop' } - ] + ], + + search: { + provider: 'local' + }, + + editLink: { + pattern: 'https://github.com/IUSCA/bioloop/edit/main/docs/:path' + } }, ignoreDeadLinks: [ // ignore exact url "/playground" @@ -53,4 +64,21 @@ export default defineConfig({ return url.toLowerCase().includes('ignore') } ] -}) +}; + + +const vitePressSidebarOptions = { + // VitePress Sidebar's options here... + documentRootPath: '/docs', + collapsed: true, + capitalizeFirst: true, + includeFolderIndexFile: false, + useTitleFromFileHeading: true, + useTitleFromFrontmatter: true, + useFolderTitleFromIndexFile: true, + frontmatterOrderDefaultValue: 100, + sortMenusByFrontmatterOrder: true +}; + +export default defineConfig(withSidebar(vitePressOptions, vitePressSidebarOptions)); +// export default vitePressOptions; \ No newline at end of file diff --git a/docs/.vitepress/dist/404.html b/docs/.vitepress/dist/404.html index 0b5542fff..2dd7a5396 100644 --- a/docs/.vitepress/dist/404.html +++ b/docs/.vitepress/dist/404.html @@ -5,17 +5,19 @@
404
But if you don't change your direction, and if you keep looking, you may end up where you are heading.