diff --git a/package-lock.json b/package-lock.json index 2c35b98..628e5f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1445,10 +1445,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/@myunisoft/sigyn.agent": { - "resolved": "src/agent", - "link": true - }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -1524,6 +1520,10 @@ "node": ">=14" } }, + "node_modules/@sigyn/agent": { + "resolved": "src/agent", + "link": true + }, "node_modules/@types/better-sqlite3": { "version": "7.6.4", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.4.tgz", @@ -2527,6 +2527,17 @@ "node": ">=6" } }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -4278,6 +4289,20 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", + "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4935,6 +4960,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/ts-pattern": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.4.tgz", + "integrity": "sha512-D5iVliqugv2C9541W2CNXFYNEZxr4TiHuLPuf49tKEdQFp/8y8fR0v1RExUvXkiWozKCwE7zv07C6EKxf0lKuQ==" + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -5346,17 +5376,20 @@ } }, "src/agent": { - "name": "@myunisoft/sigyn.agent", + "name": "@sigyn/agent", "version": "1.0.0", "license": "MIT", "dependencies": { "@myunisoft/loki": "^1.2.0", + "@openally/result": "^1.2.0", "better-sqlite3": "^8.4.0", "dayjs": "^1.11.9", "dotenv": "^16.3.1", "ms": "^2.1.3", "pino": "^8.14.1", - "toad-scheduler": "^3.0.0" + "pupa": "^3.1.0", + "toad-scheduler": "^3.0.0", + "ts-pattern": "^5.0.4" }, "devDependencies": { "@types/better-sqlite3": "^7.6.4", diff --git a/src/agent/data/init-db.sql b/src/agent/data/init-db.sql index 2c1ee88..dc8f2ee 100644 --- a/src/agent/data/init-db.sql +++ b/src/agent/data/init-db.sql @@ -2,14 +2,41 @@ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS rules ( - name TEXT UNIQUE, - counter INTEGER DEFAULT 0, - lastRunAt INTEGER + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + counter INTEGER DEFAULT 0, + lastRunAt INTEGER ); CREATE TABLE IF NOT EXISTS counters ( - name TEXT, - counter INTEGER, - timestamp INTEGER + id INTEGER PRIMARY KEY, + ruleId INTEGER, + counter INTEGER, + timestamp INTEGER, + FOREIGN KEY(ruleId) REFERENCES rules(id) + ); + +CREATE TABLE IF NOT EXISTS alerts + ( + id INTEGER PRIMARY KEY, + ruleId INTEGER, + createdAt INTEGER, + FOREIGN KEY(ruleId) REFERENCES rules(id) + ); + +CREATE TABLE IF NOT EXISTS notifiers + ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + +CREATE TABLE IF NOT EXISTS alertNotifs + ( + alertId INTEGER, + notifierId INTEGER, + status TEXT DEFAULT "pending", + retries INTEGER DEFAULT 0, + FOREIGN KEY(alertId) REFERENCES alerts(id), + FOREIGN KEY(notifierId) REFERENCES notifiers(id) ); diff --git a/src/agent/package.json b/src/agent/package.json index 07d6554..b7cb5c6 100644 --- a/src/agent/package.json +++ b/src/agent/package.json @@ -1,7 +1,7 @@ { - "name": "@myunisoft/sigyn.agent", + "name": "@sigyn/agent", "version": "1.0.0", - "description": "Another inspired Rust's Result implementation.", + "description": "Loki alerting agent", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -19,7 +19,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts --clean", "dev": "npm run build -- --watch", "prepublishOnly": "npm run build", - "test": "glob -c \"node --loader tsx --no-warnings --test\" \"./test/**/*.spec.ts\"", + "test": "glob -c \"tsx --test\" \"./test/**/*.spec.ts\"", "coverage": "c8 -r html npm test", "lint": "cross-env eslint src/**/*.ts" }, @@ -32,12 +32,15 @@ "license": "MIT", "dependencies": { "@myunisoft/loki": "^1.2.0", + "@openally/result": "^1.2.0", "better-sqlite3": "^8.4.0", "dayjs": "^1.11.9", "dotenv": "^16.3.1", "ms": "^2.1.3", "pino": "^8.14.1", - "toad-scheduler": "^3.0.0" + "pupa": "^3.1.0", + "toad-scheduler": "^3.0.0", + "ts-pattern": "^5.0.4" }, "devDependencies": { "@types/better-sqlite3": "^7.6.4", diff --git a/src/agent/src/alert.ts b/src/agent/src/alert.ts new file mode 100644 index 0000000..525929f --- /dev/null +++ b/src/agent/src/alert.ts @@ -0,0 +1,24 @@ +// Import Third-party Dependencies +import dayjs from "dayjs"; +import { Logger } from "pino"; + +// Import Internal Dependencies +import { DbRule, getDB } from "./database"; +import { Notifier } from "./notifier"; +import { SigynNotifiers, SigynRule, getConfig } from "./config"; + +export function createAlert(rule: DbRule, config: SigynRule, logger: Logger) { + const notifier = Notifier.getNotifier(logger); + const rulegNotifiers = config.notifiers ?? []; + const globalNotifiers = Object.keys(getConfig().notifiers) as (keyof SigynNotifiers)[]; + const notifierNames = rulegNotifiers.length > 0 ? rulegNotifiers : globalNotifiers; + + for (const notifierName of notifierNames) { + notifier.sendAlert({ rule, notifier: notifierName }); + } + + getDB().prepare("INSERT INTO alerts (ruleId, createdAt) VALUES (?, ?)").run( + rule.id, + dayjs().unix() + ); +} diff --git a/src/agent/src/config.ts b/src/agent/src/config.ts new file mode 100644 index 0000000..001680b --- /dev/null +++ b/src/agent/src/config.ts @@ -0,0 +1,57 @@ +// Import Node.js Dependencies +import path from "node:path"; +import fs from "node:fs"; + +export interface SigynConfig { + notifiers: SigynNotifiers; + rules: SigynRule[] +} + +export interface SigynNotifiers { + discord?: DiscordNotifier; +} + +export interface DiscordNotifier { + webhookUrl: string; +} + +export interface SigynRule { + name: string; + logql: string; + polling: string; + alert: SigynAlert; + disabled?: boolean; + notifiers?: (keyof SigynNotifiers)[]; +} + +export interface SigynAlert { + on: { + count: number; + interval: string; + }, + template: SigynAlertTemplate; +} + +export interface SigynAlertTemplate { + title?: string; + content?: string[]; +} + +let config: SigynConfig; + +export function initConfig(location: string): SigynConfig { + const rawConfig = fs.readFileSync(path.join(location, "/config.json"), "utf-8"); + + config = JSON.parse(rawConfig); + + // TODO: verify configs format ? + return config; +} + +export function getConfig(): SigynConfig { + if (config === undefined) { + throw new Error("You must init config first"); + } + + return config; +} diff --git a/src/agent/src/database.ts b/src/agent/src/database.ts index 4c5f569..b079b77 100644 --- a/src/agent/src/database.ts +++ b/src/agent/src/database.ts @@ -1,21 +1,48 @@ // Import Node.js Dependencies import path from "node:path"; -import url from "node:url"; import fs from "node:fs"; // Import Third-party Dependencies import SQLite3 from "better-sqlite3"; import { Logger } from "pino"; -// CONSTANTS -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - const kDefaultDatabaseFilename = process.env.SIGYN_DB ?? "sigyn.sqlite3"; const kDatabaseInitPath = path.join(__dirname, "../data/init-db.sql"); let db: SQLite3.Database; +export interface DbRule { + id: number; + name: string; + counter: number; + lastRunAt?: number; +} + +export interface DbCounter { + id: number; + name: string; + counter: number; + timestamp: number; +} + +export interface DbAlert { + id: number; + createdAt: number; +} + +export interface DbNotifier { + id: number; + name: string; +} + +export interface DbAlertNotif { + id: number; + alertId: number; + notifierId: number; + status: "pending" | "success" | "error"; + retries: number; +} + export interface InitDbOptions { /** * @default process.env.SIGYN_DB. diff --git a/src/agent/src/discord/discord.ts b/src/agent/src/discord/discord.ts new file mode 100644 index 0000000..6e5fb42 --- /dev/null +++ b/src/agent/src/discord/discord.ts @@ -0,0 +1,41 @@ +// NOTE: This will be redesigned & moved to @sigyn/discord. + +// CONSTANTS +const kWebhookUsername = "Sigyn Agent"; + +export async function executeWebhook(options) { + // pupa is ESM only, need a dynamic import for CommonJS. + const { default: pupa } = await import("pupa"); + const { webhookUrl, ruleConfig, rule } = options; + + function webhookStructure() { + const counter = rule.counter; + const { count, interval } = ruleConfig.alert.on; + const ruleName = ruleConfig.name; + const polling = ruleConfig.polling; + const logql = ruleConfig.logql.replaceAll("`", "'"); + const lokiUrl = "https://todo.com"; + const templateData = { ruleName, count, counter, interval, polling, logql, lokiUrl }; + + const content: any[] = ruleConfig.alert.template.content.map((content) => pupa( + content, + templateData + )); + if (ruleConfig.alert.template.title) { + content.unshift(pupa(ruleConfig.alert.template.title, templateData)); + } + + return { + content: content.join("\n"), + username: kWebhookUsername + }; + } + + await fetch(webhookUrl, { + method: "POST", + body: JSON.stringify(webhookStructure()), + headers: { + "Content-Type": "application/json" + } + }); +} diff --git a/src/agent/src/index.ts b/src/agent/src/index.ts index 1fc8d19..4f60dc7 100644 --- a/src/agent/src/index.ts +++ b/src/agent/src/index.ts @@ -1,7 +1,3 @@ -// Import Node.js Dependencies -import fs from "node:fs"; -import path from "node:path"; - // Import Third-party Dependencies import { ToadScheduler, SimpleIntervalJob } from "toad-scheduler"; import { pino } from "pino"; @@ -9,10 +5,10 @@ import ms from "ms"; // Import Internal Dependencies import { initDB } from "./database"; -import { SigynConfig } from "./types"; import { asyncTask } from "./tasks/asyncTask"; import { Rule } from "./rules"; import * as utils from "./utils"; +import * as config from "./config"; // CONSTANTS const kScheduler = new ToadScheduler(); @@ -28,10 +24,11 @@ export async function start( ) { kLogger.info(`Starting sigyn agent at '${location}'`); - const config = JSON.parse(fs.readFileSync(path.join(location, "/config.json"), "utf-8")) as SigynConfig; initDB(kLogger); - for (const ruleConfig of config.rules) { + const { rules } = config.initConfig(location); + + for (const ruleConfig of rules) { if (ruleConfig.disabled) { continue; } @@ -45,12 +42,5 @@ export async function start( kScheduler.addIntervalJob(job); } - utils.cleanRulesInDb(config.rules); - - /** - * TODO: - * 3. schedule alerting interval - * 3.1 looking in DB, if matching condition -> alert (delete proceeded rows) - * 3.2 store in DB the alert, send event notification to notifiers - */ + utils.cleanRulesInDb(rules); } diff --git a/src/agent/src/notifier.ts b/src/agent/src/notifier.ts new file mode 100644 index 0000000..7520c8d --- /dev/null +++ b/src/agent/src/notifier.ts @@ -0,0 +1,117 @@ +// Import Third-party Dependencies +import { Logger } from "pino"; +import { match } from "ts-pattern"; +import { Err, Ok } from "@openally/result"; + +// Import Internal Dependencies +import { DbAlert, DbAlertNotif, DbNotifier, DbRule, getDB } from "./database"; +import { NOTIFIER_QUEUE_EVENTS, NotifierQueue } from "./notifierQueue"; +import { SigynNotifiers, getConfig } from "./config"; +import * as discord from "./discord/discord"; + +// CONSTANTS +const kPrivateInstancier = Symbol("instancier"); + +export interface NotifierAlert { + rule: DbRule; + notifier: keyof SigynNotifiers; + notif: Pick; + error?: Error; +} + +/** + * This is the global notifier. + * We don't want a notifier per rule but a global notifier shared with each rules. + */ +let notifier: Notifier; + +export class Notifier { + #queue = new NotifierQueue(); + #logger: Logger; + + constructor(logger: Logger, instancier: symbol) { + if (instancier !== kPrivateInstancier) { + throw new Error("Cannot instanciate NotifierQueue, use NotifierQueue.getNotifier instead"); + } + + this.#logger = logger; + this.#queue.on(NOTIFIER_QUEUE_EVENTS.DEQUEUE, (alert) => this.#sendNotifications(alert)); + } + + static getNotifier(logger: Logger): Notifier { + if (notifier === undefined) { + notifier = new Notifier(logger, kPrivateInstancier); + } + + return notifier; + } + + sendAlert(alert: Omit) { + const db = getDB(); + const { id: alertId } = db + .prepare("SELECT id from alerts WHERE ruleId = ?") + .get(alert.rule.id) as Pick; + const notifierId = this.#databaseNotifierId(alert.notifier); + + db.prepare("INSERT INTO alertNotifs (alertId, notifierId) VALUES (?, ?)").run(alertId, notifierId); + + this.#queue.push({ ...alert, notif: { alertId, notifierId } }); + } + + async #sendNotifications(alerts: NotifierAlert[]) { + await Promise.allSettled(alerts.map((alert) => this.#sendNotification(alert))); + } + + async #sendNotification(alert: NotifierAlert) { + const { notifier } = alert; + + const db = getDB(); + const config = getConfig(); + const ruleConfig = config.rules.find((rule) => rule.name === alert.rule.name); + const notifierConfig = config.notifiers[notifier]; + + const result = await match(notifier) + .with("discord", async() => { + try { + await discord.executeWebhook({ ...notifierConfig, ruleConfig, rule: alert.rule }); + + return Ok(void 0); + } + catch (error) { + return Err(error); + } + }) + .otherwise(() => Err(`Unknown notifier: ${notifier}`)); + + if (result.ok) { + db.prepare("UPDATE alertNotifs SET status = ? WHERE alertId = ?").run( + "success", alert.notif.alertId + ); + + this.#logger.info(`[${alert.rule.name}](notify: success|notifier: ${alert.notifier})`); + } + else { + alert.error = result.val; + db.prepare("UPDATE alertNotifs SET status = ? WHERE alertId = ?").run( + "failed", alert.notif.alertId + ); + + this.#logger.error(`[${alert.rule.name}](notify: error|message: ${alert.error!.message})`); + } + + this.#queue.emit(NOTIFIER_QUEUE_EVENTS.DONE); + } + + #databaseNotifierId(notifier: string) { + const db = getDB(); + const dbNotifier = db.prepare("SELECT id FROM notifiers WHERE name = ?").get(notifier) as Pick; + + if (dbNotifier) { + return dbNotifier.id; + } + + const { lastInsertRowid } = db.prepare("INSERT INTO notifiers (name) VALUES (?)").run(notifier); + + return lastInsertRowid as number; + } +} diff --git a/src/agent/src/notifierQueue.ts b/src/agent/src/notifierQueue.ts new file mode 100644 index 0000000..5e2e9a1 --- /dev/null +++ b/src/agent/src/notifierQueue.ts @@ -0,0 +1,64 @@ +// Import Node.js Dependencies +import EventEmitter from "node:events"; + +// Import Internal Dependencies +import { NotifierAlert } from "./notifier"; + +// CONSTANTS +const kNotifsConcurrency = 10; +const kReadyEvent = Symbol("ready"); + +export const NOTIFIER_QUEUE_EVENTS = { + DEQUEUE: Symbol("dequeue"), + DONE: Symbol("done") +}; + +// TODO: handle 2 (or more) same alert in the queue. +export class NotifierQueue extends EventEmitter { + #queue: NotifierAlert[] = []; + #inProgress = 0; + + constructor() { + super(); + this.on(NOTIFIER_QUEUE_EVENTS.DONE, () => this.#notifHandled()); + } + + push(notifs: NotifierAlert) { + this.#queue.push(notifs); + + if (this.#inProgress++ === 0) { + this.emit(NOTIFIER_QUEUE_EVENTS.DEQUEUE, [...this.#dequeue()]); + + return; + } + + this.removeAllListeners(kReadyEvent); + + this.once(kReadyEvent, () => { + this.emit(NOTIFIER_QUEUE_EVENTS.DEQUEUE, [...this.#dequeue()]); + }); + } + + #notifHandled() { + this.#inProgress--; + + if (this.#inProgress === -1) { + console.log("ERROR: inProgress is negative"); + this.#inProgress = 0; + } + + if (this.#inProgress === 0 && this.#queue.length > 0) { + this.emit(NOTIFIER_QUEUE_EVENTS.DEQUEUE, [...this.#dequeue()]); + } + } + + * #dequeue() { + for (let i = 0; i < kNotifsConcurrency; i++) { + if (this.#queue.length === 0) { + break; + } + + yield this.#queue.shift(); + } + } +} diff --git a/src/agent/src/rules.ts b/src/agent/src/rules.ts index fcecc45..12ef804 100644 --- a/src/agent/src/rules.ts +++ b/src/agent/src/rules.ts @@ -1,14 +1,14 @@ // Import Third-party Dependencies import { GrafanaLoki } from "@myunisoft/loki"; -import SQLite3 from "better-sqlite3"; import dayjs from "dayjs"; import { Logger } from "pino"; import ms from "ms"; // Import Internal Dependencies -import { DbCounter, DbRule, SigynRule } from "./types"; -import { getDB } from "./database"; +import { DbCounter, DbRule, getDB } from "./database"; import * as utils from "./utils"; +import { createAlert } from "./alert"; +import { SigynRule } from "./config"; // CONSTANTS const kApi = new GrafanaLoki({ @@ -21,41 +21,42 @@ export interface RuleOptions { export class Rule { #config: SigynRule; - #db: SQLite3.Database; #logger: Logger; constructor(rule: SigynRule, options: RuleOptions) { - this.#logger = options.logger; + const { logger } = options; + + this.#logger = logger; this.#config = rule; - this.#db = getDB(); } + #getRuleFromDatabase(): DbRule { - return this.#db.prepare("SELECT * FROM rules WHERE name = ?").get(this.#config.name) as DbRule; + return getDB().prepare("SELECT * FROM rules WHERE name = ?").get(this.#config.name) as DbRule; } - init() { + init(): void { const databaseRule = this.#getRuleFromDatabase(); if (databaseRule === undefined) { - this.#db.prepare("INSERT INTO rules (name) VALUES (?)").run(this.#config.name); + getDB().prepare("INSERT INTO rules (name) VALUES (?)").run(this.#config.name); this.#logger.info(`[Database] New rule '${this.#config.name}' added`); } } async handleLogs(): Promise { + const db = getDB(); const logs = await kApi.queryRange(this.#config.logql, { start: this.#getQueryRangeStartUnixTimestamp() }); const rule = this.#getRuleFromDatabase(); - const now = dayjs().unix(); const lasttIntervalDate = utils.durationToDate(this.#config.alert.on.interval, "subtract"); const timeThreshold = lasttIntervalDate.unix(); - const previousCounters = this.#db.prepare("SELECT * FROM counters WHERE name = ? AND timestamp >= ?").all( - rule.name, + const previousCounters = db.prepare("SELECT * FROM counters WHERE ruleId = ? AND timestamp >= ?").all( + rule.id, timeThreshold ) as DbCounter[]; @@ -64,22 +65,21 @@ export class Rule { rule.counter -= substractCounter; rule.counter += logs.length; - - this.#db.prepare("UPDATE rules SET counter = ? WHERE name = ?").run( + db.prepare("UPDATE rules SET counter = ? WHERE id = ?").run( rule.counter, - rule.name + rule.id ); if (logs.length) { - this.#db.prepare("INSERT INTO counters (name, counter, timestamp) VALUES (?, ?, ?)").run( - rule.name, + db.prepare("INSERT INTO counters (ruleId, counter, timestamp) VALUES (?, ?, ?)").run( + rule.id, logs.length, now ); } - this.#db.prepare("DELETE FROM counters WHERE name = ? AND timestamp < ?").run( - rule.name, + db.prepare("DELETE FROM counters WHERE ruleId = ? AND timestamp < ?").run( + rule.id, timeThreshold ); @@ -90,6 +90,11 @@ export class Rule { if (rule.counter >= alertThreshold) { + createAlert(rule, this.#config, this.#logger); + + db.prepare("UPDATE rules SET counter = 0 WHERE id = ?").run(rule.id); + db.prepare("DELETE from counters WHERE ruleId = ?").run(rule.id); + this.#logger.error(`[${rule.name}](state: alert|threshold: ${alertThreshold}|actual: ${rule.counter})`); } } @@ -98,7 +103,7 @@ export class Rule { const rule = this.#getRuleFromDatabase(); const now = dayjs(); - this.#db.prepare("UPDATE rules SET lastRunAt = ? WHERE name = ?").run(now.unix(), rule.name); + getDB().prepare("UPDATE rules SET lastRunAt = ? WHERE id = ?").run(now.unix(), rule.id); if (rule.lastRunAt) { const diff = Math.abs(dayjs.unix(rule.lastRunAt!).diff(now, "ms")); diff --git a/src/agent/src/types.ts b/src/agent/src/types.ts deleted file mode 100644 index a5c2f40..0000000 --- a/src/agent/src/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -type TODO = any; - -export interface SigynConfig { - notifier: Record; - rules: SigynRule[] -} - -export interface SigynRule { - name: string; - logql: string; - polling: string; - alert: SigynAlert; - disabled?: boolean; -} - -export interface SigynAlert { - on: { - count: number; - interval: string; - }, - template?: TODO; -} - -export interface DbRule { - name: string; - counter: number; - lastRunAt?: number; -} - -export interface DbCounter { - name: string; - counter: number; - timestamp: number; -} diff --git a/src/agent/test/FT/database.spec.ts b/src/agent/test/FT/database.spec.ts index 59e1355..422f90a 100644 --- a/src/agent/test/FT/database.spec.ts +++ b/src/agent/test/FT/database.spec.ts @@ -1,6 +1,6 @@ // Import Node.js Dependencies import assert from "node:assert"; -import { after, before, describe, it } from "node:test"; +import { after, describe, it } from "node:test"; import fs from "node:fs"; // Import Third-party Dependencies @@ -12,55 +12,96 @@ import * as testHelpers from "./helpers"; // CONSTANTS const kDummyLogger = { info: () => null } as any; +const kExpectedTablesColumns = { + rules: [ + { name: "id", dflt_value: null, type: "INTEGER", pk: 1, notnull: 0 }, + { name: "name", dflt_value: null, type: "TEXT", pk: 0, notnull: 1 }, + { name: "counter", dflt_value: "0", type: "INTEGER", pk: 0, notnull: 0 }, + { name: "lastRunAt", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 } + ], + counters: [ + { name: "id", dflt_value: null, type: "INTEGER", pk: 1, notnull: 0 }, + { name: "ruleId", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 }, + { name: "counter", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 }, + { name: "timestamp", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 } + ], + alerts: [ + { name: "id", dflt_value: null, type: "INTEGER", pk: 1, notnull: 0 }, + { name: "ruleId", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 }, + { name: "createdAt", dflt_value: null, type: "INTEGER", pk: 0, notnull: 0 } + ], + notifiers: [ + { name: "id", dflt_value: null, type: "INTEGER", pk: 1, notnull: 0 }, + { name: "name", dflt_value: null, type: "TEXT", pk: 0, notnull: 1 } + ], + alertNotifs: [ + { name: "alertId", dflt_value: null, type: "INTEGER", pk: 0 }, + { name: "notifierId", dflt_value: null, type: "INTEGER", pk: 0 }, + { name: "status", dflt_value: "\"pending\"", type: "TEXT", pk: 0 }, + { name: "retries", dflt_value: 0, type: "INTEGER", pk: 0 } + ] +}; -describe("Database", () => { - describe("initDB()", () => { - let db: SQLite3.Database; +let db: SQLite3.Database; - before(() => { - if (!fs.existsSync("test/.temp")) { - fs.mkdirSync("test/.temp"); - } - - db = initDB(kDummyLogger, { databaseFilename: "test/.temp/test-db.sqlite3" }); - }); +function initDb() { + if (!fs.existsSync("test/.temp")) { + fs.mkdirSync("test/.temp"); + } + db = initDB(kDummyLogger, { databaseFilename: "test/.temp/test-db.sqlite3" }); +} +// This is a workaround because the test runner does run nested suites BEFORE parent suite's before() hook. +initDb(); +describe("Database", () => { + describe("initDB()", () => { after(() => { // remove the created db testHelpers.deleteDb(); }); - it("should init table 'rules'", () => { - const actualColumns = db.pragma("table_info(rules)") as Record[]; - const expectedColumns = [ - { dflt_value: null, name: "name", type: "TEXT" }, - { dflt_value: "0", name: "counter", type: "INTEGER" }, - { dflt_value: null, name: "lastRunAt", type: "INTEGER" } - ]; + for (const table of Object.keys(kExpectedTablesColumns)) { + describe(`should init table '${table}'`, () => { + const currentTable = kExpectedTablesColumns[table]; + const actualColumns = db.pragma(`table_info(${table})`) as Record[]; - assert.equal(actualColumns.length, expectedColumns.length); + for (const column of currentTable) { + const actualColumn = actualColumns.find((col) => col.name === column.name)!; - for (const column of actualColumns) { - const { dflt_value, name, type } = column; - assert.deepEqual({ dflt_value, name, type }, expectedColumns.shift()); - } - }); + describe(`column ${column.name}`, () => { + it("should exists", () => { + assert.ok(actualColumn); + }); - it("should init table 'counters'", () => { - const actualColumns = db.pragma("table_info(counters)") as Record[]; - const expectedColumns = [ - { dflt_value: null, name: "name", type: "TEXT" }, - { dflt_value: null, name: "counter", type: "INTEGER" }, - { dflt_value: null, name: "timestamp", type: "INTEGER" } - ]; + if (column.dflt_value !== undefined) { + it(`default value should be ${column.dflt_value}`, () => { + assert.equal(actualColumn.dflt_value, column.dflt_value); + }); + } - assert.equal(actualColumns.length, expectedColumns.length); + it(`type should be ${column.type}`, () => { + assert.equal(actualColumn.type, column.type); + }); - for (const column of actualColumns) { - const { dflt_value, name, type } = column; - assert.deepEqual({ dflt_value, name, type }, expectedColumns.shift()); - } - }); + if (column.pk !== undefined) { + it(`should${column.pk === 1 ? " " : " NOT"} be a primary key`, () => { + assert.equal(actualColumn.pk, column.pk); + }); + } + + if (column.notnull !== undefined) { + it(`should${column.notnull === 1 ? " " : " NOT"} be non null`, () => { + assert.equal(actualColumn.notnull, column.notnull); + }); + } + }); + + it("should not have other column", () => { + assert.equal(actualColumn.length, currentTable.lengt); + }); + } + }); + } }); }); diff --git a/src/agent/tsconfig.json b/src/agent/tsconfig.json index e9d6a60..be419d9 100644 --- a/src/agent/tsconfig.json +++ b/src/agent/tsconfig.json @@ -15,6 +15,6 @@ "rootDir": "./src", "types": ["node"] }, - "include": ["src"], + "include": ["src", "src/discord/discord.ts"], "exclude": ["node_modules", "dist"] } diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..398bdb7 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + shims: true +});