mirror of
https://github.com/danbulant/pinguin
synced 2026-07-06 11:30:40 +00:00
initial commit
This commit is contained in:
commit
3e276b0740
14 changed files with 2350 additions and 0 deletions
4
.env
Normal file
4
.env
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
REDIS_HOST='127.0.0.1'
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=pinguin
|
||||||
|
APPLICATION_ROLE='queue'
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
lib
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
FROM node:20
|
||||||
|
|
||||||
|
COPY . /usr/src/app/
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
RUN pnpm install
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Pinguin
|
||||||
|
|
||||||
|
Pinguin is a simple service for monitoring websites. It is written in Node.js and uses Redis as message broker.
|
||||||
|
Its composed of two parts: ingress and worker. Ingress is responsible for receiving HTTP requests and forwards them to
|
||||||
|
workers through BullMQ.
|
||||||
|
|
||||||
|
## Build instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build an image
|
||||||
|
docker build -t haxagon/pinguin .
|
||||||
|
|
||||||
|
# push an image to private registry
|
||||||
|
docker push haxagon/pinguin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# start local redis server
|
||||||
|
docker run -d --name redis -p 127.0.0.1:6379:6379 --rm redis:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Ingress
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APPLICATION_ROLE='ingress' ./node_modules/nodemon/bin/nodemon.js lib/bradlo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APPLICATION_ROLE='worker' ./node_modules/nodemon/bin/nodemon.js lib/bradlo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### POST /ping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --json '{"domain": "seznam.cz"}' -X POST localhost:3000/ping
|
||||||
|
```
|
||||||
37
package.json
Normal file
37
package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "pinguin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "./node_modules/typescript/bin/tsc",
|
||||||
|
"start": "node ./lib/app.js",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.18",
|
||||||
|
"@types/node": "^20.7.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||||
|
"@typescript-eslint/parser": "^6.7.3",
|
||||||
|
"eslint": "^8.50.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bullmq": "^4.12.0",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"pino": "^6.14.0",
|
||||||
|
"pino-pretty": "^10.2.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
2041
pnpm-lock.yaml
Normal file
2041
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
55
src/api/index.ts
Normal file
55
src/api/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import express from 'express';
|
||||||
|
import {body, validationResult} from 'express-validator';
|
||||||
|
import {logger} from '../services/logger.js';
|
||||||
|
import {sendPingTask} from "../services/queue.js";
|
||||||
|
|
||||||
|
const api = express();
|
||||||
|
api.use(express.json());
|
||||||
|
const pingValidation = [
|
||||||
|
body('domain')
|
||||||
|
.exists()
|
||||||
|
.withMessage('domain is required')
|
||||||
|
.isString()
|
||||||
|
];
|
||||||
|
api.post('/ping', pingValidation, async (req, res) => {
|
||||||
|
const validationErrors = validationResult(req);
|
||||||
|
if (!validationErrors.isEmpty()) {
|
||||||
|
logger.warn('invalid domain ping request received', {
|
||||||
|
Data: req.body,
|
||||||
|
Errors: validationErrors.array()
|
||||||
|
});
|
||||||
|
return res.json(validationErrors.array());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pingResult = await sendPingTask(req.body.domain);
|
||||||
|
return res.json({"result": pingResult});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({
|
||||||
|
Data: req.body,
|
||||||
|
err: e
|
||||||
|
}, 'error ping domain');
|
||||||
|
return res.status(500).json({error: e.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// in case I want to remotely poweroff the server
|
||||||
|
api.post('/poweroff', async (req, res) => {
|
||||||
|
logger.info(`poweroff request received`)
|
||||||
|
try {
|
||||||
|
process.exit(0)
|
||||||
|
return res.json({"result": "OK"});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({
|
||||||
|
Data: req.body,
|
||||||
|
err: e
|
||||||
|
}, 'error poweroff');
|
||||||
|
return res.status(500).json({error: e.message});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function startApiServer() {
|
||||||
|
logger.info('starting API server');
|
||||||
|
api.listen(3000, () => {
|
||||||
|
logger.info('API server listening on port 3000');
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/app.ts
Normal file
20
src/app.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import {startApiServer} from './api/index.js';
|
||||||
|
import {config} from "./config.js";
|
||||||
|
import {createPingQueue} from "./services/queue.js";
|
||||||
|
import {startPingWorker} from "./services/worker.js";
|
||||||
|
import {logger} from "./services/logger.js";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
dotenv.config();
|
||||||
|
logger.info(`starting pinguin with application role ${config.applicationRole}`)
|
||||||
|
switch (config.applicationRole) {
|
||||||
|
case "queue":
|
||||||
|
await createPingQueue();
|
||||||
|
startApiServer()
|
||||||
|
break;
|
||||||
|
case "worker":
|
||||||
|
await startPingWorker();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
27
src/config.ts
Normal file
27
src/config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import {ConnectionOptions} from "bullmq";
|
||||||
|
|
||||||
|
export interface PinguinConfig {
|
||||||
|
redis: {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
}
|
||||||
|
applicationRole: "worker" | "queue"
|
||||||
|
}
|
||||||
|
|
||||||
|
// use dotenv to load .env variables to config (mainly for development)
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
export const config: PinguinConfig = {
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT) || 6379
|
||||||
|
},
|
||||||
|
applicationRole: process.env.APPLICATION_ROLE as "worker" | "queue"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redisConnectionConfig: ConnectionOptions = {
|
||||||
|
host: config.redis.host,
|
||||||
|
port: config.redis.port,
|
||||||
|
password: process.env.REDIS_PASSWORD
|
||||||
|
}
|
||||||
8
src/services/executor.ts
Normal file
8
src/services/executor.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {exec as childProcessExec} from "child_process";
|
||||||
|
import * as util from "util";
|
||||||
|
const exec = util.promisify(childProcessExec)
|
||||||
|
|
||||||
|
export const execCommand = async (command: string) => {
|
||||||
|
const commandResult = await exec(command)
|
||||||
|
return commandResult.stdout
|
||||||
|
}
|
||||||
9
src/services/logger.ts
Normal file
9
src/services/logger.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
name: 'bradlo',
|
||||||
|
level: 'debug',
|
||||||
|
prettyPrint: {
|
||||||
|
colorize: true
|
||||||
|
}
|
||||||
|
});
|
||||||
28
src/services/queue.ts
Normal file
28
src/services/queue.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import {redisConnectionConfig} from "../config.js";
|
||||||
|
import {Job, Queue, QueueEvents} from "bullmq";
|
||||||
|
import {logger} from "./logger.js";
|
||||||
|
|
||||||
|
let queue: Queue
|
||||||
|
let queueEvents: QueueEvents
|
||||||
|
export const createPingQueue = async () => {
|
||||||
|
logger.info(`starting ping queue`)
|
||||||
|
queue = new Queue('ping', {
|
||||||
|
connection: redisConnectionConfig
|
||||||
|
})
|
||||||
|
logger.info(redisConnectionConfig)
|
||||||
|
queueEvents = new QueueEvents('ping', {
|
||||||
|
connection: redisConnectionConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
await queue.waitUntilReady()
|
||||||
|
await queueEvents.waitUntilReady()
|
||||||
|
logger.info(`ping queue started`)
|
||||||
|
}
|
||||||
|
export const sendPingTask = async (domain: string) => {
|
||||||
|
logger.info(`adding ping task to queue to ping domain ${domain}`)
|
||||||
|
const job = await queue.add('ping', {domain})
|
||||||
|
logger.info(`ping task added to queue to ping domain ${domain}`)
|
||||||
|
await job.waitUntilFinished(queueEvents)
|
||||||
|
const returnedJob = await Job.fromId(queue, job.id)
|
||||||
|
return returnedJob.returnvalue
|
||||||
|
}
|
||||||
31
src/services/worker.ts
Normal file
31
src/services/worker.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {Worker} from 'bullmq';
|
||||||
|
import {logger} from "./logger.js";
|
||||||
|
import {execCommand} from "./executor.js";
|
||||||
|
import {redisConnectionConfig} from "../config.js";
|
||||||
|
|
||||||
|
interface PingTaskData {
|
||||||
|
domain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// starts the worker which picks tasks from the bullmq queue and executes them
|
||||||
|
export const startPingWorker = async () => {
|
||||||
|
logger.info(`starting ping worker`)
|
||||||
|
const worker = new Worker<PingTaskData>('ping', async job => {
|
||||||
|
if (!job.data.domain) {
|
||||||
|
const error = new Error(`Invalid job ${job.id} received from queue ${worker.name}. Domain is missing in job data.`)
|
||||||
|
logger.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.info(`worker retrieved job ${job.id} from queue ${worker.name} with id ${job.id} and domain to ping ${job.data.domain}`);
|
||||||
|
const result = await execCommand(`ping -c 1 ${job.data.domain}`)
|
||||||
|
logger.info(`worker finished job ${job.id} from queue ${worker.name} with id ${job.id} and domain to ping ${job.data.domain} with result ${result}`);
|
||||||
|
return result
|
||||||
|
}, {
|
||||||
|
connection: redisConnectionConfig
|
||||||
|
});
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
logger.error(`worker failed job ${job.id} from queue ${worker.name} with id ${job.id} and domain to ping ${job.data.domain} with error ${err}`);
|
||||||
|
})
|
||||||
|
await worker.waitUntilReady();
|
||||||
|
logger.info(`ping worker started and waiting for jobs`)
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "node16",
|
||||||
|
"lib": [
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "lib",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noImplicitThis": false,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue