From 29bcb9e5f6e3e20ed50153a05c3dd811f9de4763 Mon Sep 17 00:00:00 2001 From: Hari Nugraha <15191978+haricnugraha@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:05:44 +0700 Subject: [PATCH] Test: Add tests for Monika configuration codes (#1279) * test: add tests for monika configuration * revert: revert copy * revert: log * ci: fix code coverage uploader * test: add production tests * revert: do not validate with json schema in symon mode * chore: remove unnecessary code * revert: test:watch script --- .github/workflows/node.js.yml | 5 +- package.json | 2 +- packages/notification/channel/index.ts | 2 +- packages/notification/package.json | 2 +- prod_test/test.ts | 181 ++++++++++++++ src/commands/monika.ts | 25 +- .../config/__tests__/parse-postman.test.ts | 2 +- src/components/config/create-config.ts | 59 ----- src/components/config/create.ts | 18 +- src/components/config/get.test.ts | 199 ++++++++++++++- src/components/config/get.ts | 148 +++++------- src/components/config/index.test.ts | 152 ++++++++++-- src/components/config/index.ts | 227 +++++------------- src/components/config/parse.ts | 100 -------- .../config/{parse-har.ts => parser/har.ts} | 6 +- .../{parse-insomnia.ts => parser/insomnia.ts} | 7 +- .../config/{fetch.ts => parser/parse.ts} | 89 ++++++- .../{parse-postman.ts => parser/postman.ts} | 2 +- .../{parse-sitemap.ts => parser/sitemap.ts} | 19 +- .../config/{parse-text.ts => parser/text.ts} | 10 +- src/components/config/sanitize.ts | 23 ++ src/components/config/validate.ts | 59 ++++- src/components/config/validation/index.ts | 2 - .../validation/validator/config-file.ts | 54 ----- src/components/config/watcher.test.ts | 118 +++++++++ src/components/config/watcher.ts | 60 +++++ src/components/logger/startup-message.test.ts | 23 +- src/components/logger/startup-message.ts | 8 +- .../schedule-notification.test.ts | 23 +- .../notification/schedule-notification.ts | 10 +- src/components/reporter/index.ts | 37 --- src/components/tls-checker/index.ts | 4 +- src/context/index.ts | 4 +- src/events/subscribers/application.ts | 13 +- src/interfaces/certificate.ts | 38 --- src/interfaces/config.ts | 41 +++- src/interfaces/validation.ts | 28 --- src/jobs/check-database.ts | 38 +-- src/jobs/summary-notification.ts | 22 +- src/jobs/tls-check.ts | 6 +- .../symon-config.ts => loaders/index.test.ts} | 54 +++-- src/loaders/index.ts | 10 +- src/loaders/jobs.ts | 15 +- src/monika-config-schema.json | 13 + src/symon/index.test.ts | 7 +- src/symon/index.ts | 16 +- src/utils/hash.ts | 30 --- src/utils/read-file.ts | 18 -- test/index.test.ts | 2 +- 49 files changed, 1187 insertions(+), 844 deletions(-) delete mode 100644 src/components/config/create-config.ts delete mode 100644 src/components/config/parse.ts rename src/components/config/{parse-har.ts => parser/har.ts} (95%) rename src/components/config/{parse-insomnia.ts => parser/insomnia.ts} (97%) rename src/components/config/{fetch.ts => parser/parse.ts} (50%) rename src/components/config/{parse-postman.ts => parser/postman.ts} (99%) rename src/components/config/{parse-sitemap.ts => parser/sitemap.ts} (91%) rename src/components/config/{parse-text.ts => parser/text.ts} (94%) create mode 100644 src/components/config/sanitize.ts delete mode 100644 src/components/config/validation/validator/config-file.ts create mode 100644 src/components/config/watcher.test.ts create mode 100644 src/components/config/watcher.ts delete mode 100644 src/components/reporter/index.ts delete mode 100644 src/interfaces/certificate.ts delete mode 100644 src/interfaces/validation.ts rename src/{components/config/validation/validator/symon-config.ts => loaders/index.test.ts} (67%) delete mode 100644 src/utils/hash.ts delete mode 100644 src/utils/read-file.ts diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 6c0e47d57..8875dbd48 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -90,7 +90,10 @@ jobs: node-version: 20.x - run: npm ci - run: npm run build -w packages/notification - - run: npm test && npx codecov + - run: npm test + - uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Cancel workflow on failure if: 'failure()' uses: 'andymckay/cancel-action@0.3' diff --git a/package.json b/package.json index 06933294e..4560e6872 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "prepack": "npm run clean && tsc -b && oclif manifest", "start": "npm run prepack && ./bin/dev.js", "start:prod": "npm run prepack && ./bin/run.js", - "test": "npm run prepack && cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text --extension .ts mocha --config .mocharc.json --forbid-only \"{packages,src,test,workers}/**/*.test.ts\" --exclude test/cli.test.ts", + "test": "npm run prepack && cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text --extension .ts mocha --config .mocharc.json --forbid-only '{packages,src,test}/**/*.test.ts'", "cli-test": "bats test/bats/cli.bats", "test:watch": "npm run clean && NODE_ENV=test mocha {packages,src,test}/**/*.test.ts --watch --watch-files {packages,src,test}/**/*", "prod_test": "npm run prod_test:bin && npm run prod_test:cli", diff --git a/packages/notification/channel/index.ts b/packages/notification/channel/index.ts index 17dd8b374..260fcb61c 100644 --- a/packages/notification/channel/index.ts +++ b/packages/notification/channel/index.ts @@ -105,7 +105,7 @@ type NotificationChannel = { export type Notification = { id: string type: string - data: object | undefined + data?: object } export const channels: Record = { diff --git a/packages/notification/package.json b/packages/notification/package.json index bf96f27e9..58fda769a 100644 --- a/packages/notification/package.json +++ b/packages/notification/package.json @@ -1,6 +1,6 @@ { "name": "@hyperjumptech/monika-notification", - "version": "1.17.0", + "version": "1.17.1", "description": "notification package for monika", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/prod_test/test.ts b/prod_test/test.ts index e29df3c18..24a7add32 100644 --- a/prod_test/test.ts +++ b/prod_test/test.ts @@ -100,6 +100,187 @@ describe('Node CLI Testing', () => { await cleanup() }) + + it('should starts Monika with a JSON config from a URL', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 -c https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.json` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 1. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with a YAML config from a URL', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 -c https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.yml` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 1. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with a HAR file', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 --har ./src/components/config/__tests__/form_encoded.har` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 1. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with an Insomnia file', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 --insomnia ./src/components/config/__tests__/petstore.insomnia.yaml` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 18. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with a Postman file', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 --postman ./src/components/config/__tests__/mock_files/grouped-postman_collection-v2.1.json` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 2. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with a Sitemap file', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 --sitemap ./src/components/config/__tests__/sitemap.xml` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 2. Notifications: 1') + + await cleanup() + }) + + it('should starts Monika with a text file', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 --text ./src/components/config/__tests__/textfile` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 2. Notifications: 1') + + await cleanup() + }) + + it('should overwrites native config', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 -c ./monika.example.yml --text ./src/components/config/__tests__/textfile` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 2. Notifications: 1') + + await cleanup() + }) + + it('should runs with multiple config', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStdout, waitForText } = await spawn( + 'node', + `${monika} -r 1 -c ./monika.example.json ./monika.example.yml` + ) + + // assert + await waitForText('Starting Monika.') + const stdout = getStdout().join('\r\n') + expect(stdout).to.contain('Starting Monika. Probes: 1. Notifications: 1') + + await cleanup() + }) + + it('should not run if no probes in the config', async () => { + // arrange + const { spawn, cleanup } = await prepareEnvironment() + + // act + const { getStderr, waitForText } = await spawn( + 'node', + `${monika} -r 1 -c ./test/testConfigs/noProbes.yml` + ) + + // assert + await waitForText('Monika configuration is invalid') + const stdout = getStderr().join('\r\n') + expect(stdout).to.contain('Monika configuration is invalid') + + await cleanup() + }) + it('Detect config file changes successfully', async () => { // arrange const { spawn, cleanup, writeFile } = await prepareEnvironment() diff --git a/src/commands/monika.ts b/src/commands/monika.ts index 0f6f4844a..5d3456efc 100644 --- a/src/commands/monika.ts +++ b/src/commands/monika.ts @@ -25,10 +25,14 @@ import { Command, Errors } from '@oclif/core' import pEvent from 'p-event' -import type { Config } from '../interfaces/config' +import type { ValidatedConfig } from '../interfaces/config' import type { Probe } from '../interfaces/probe' -import { getConfig, isSymonModeFrom } from '../components/config' +import { + getValidatedConfig, + isSymonModeFrom, + initConfig, +} from '../components/config' import { createConfig } from '../components/config/create' import { sortProbes } from '../components/config/sort' import { printAllLogs } from '../components/logger' @@ -113,9 +117,12 @@ export default class Monika extends Command { return } - await initLoaders(flags, this.config) - const isSymonMode = isSymonModeFrom(flags) + if (!isSymonMode) { + await initConfig() + } + + await initLoaders(flags, this.config) await logRunningInfo({ isSymonMode, isVerbose: flags.verbose }) if (isSymonMode) { @@ -126,7 +133,7 @@ export default class Monika extends Command { let isFirstRun = true for (;;) { - const config = getConfig() + const config = getValidatedConfig() const probes = getProbes({ config, flags }) // emit the sanitized probe @@ -142,7 +149,7 @@ export default class Monika extends Command { const controller = new AbortController() const { signal } = controller - const notifications = config.notifications || [] + const { notifications } = config startProbing({ notifications, probes, @@ -169,7 +176,7 @@ export default class Monika extends Command { } catch (error: unknown) { this.error((error as Error)?.message, { exit: 1 }) } finally { - await closeLog() + closeLog() } } } @@ -195,7 +202,7 @@ async function logRunningInfo({ isVerbose, isSymonMode }: RunningInfoParams) { } type GetProbesParams = { - config: Config + config: ValidatedConfig flags: MonikaFlags } @@ -207,7 +214,7 @@ function getProbes({ config, flags }: GetProbesParams): Probe[] { ) } -function deprecationHandler(config: Config): Config { +function deprecationHandler(config: ValidatedConfig): ValidatedConfig { const showDeprecateMsg: Record<'query', boolean> = { query: false, } diff --git a/src/components/config/__tests__/parse-postman.test.ts b/src/components/config/__tests__/parse-postman.test.ts index 38f3ba30e..e8d2d72dd 100644 --- a/src/components/config/__tests__/parse-postman.test.ts +++ b/src/components/config/__tests__/parse-postman.test.ts @@ -24,7 +24,7 @@ import { expect } from 'chai' import { Config } from '../../../interfaces/config' -import { parseConfigFromPostman } from '../parse-postman' +import { parseConfigFromPostman } from '../parser/postman' import basicCollectionV20 from './mock_files/basic-postman_collection-v2.0.json' import basicCollectionV21 from './mock_files/basic-postman_collection-v2.1.json' import groupedCollectionV20 from './mock_files/grouped-postman_collection-v2.0.json' diff --git a/src/components/config/create-config.ts b/src/components/config/create-config.ts deleted file mode 100644 index b044979c8..000000000 --- a/src/components/config/create-config.ts +++ /dev/null @@ -1,59 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -import fs from 'fs' -import type { MonikaFlags } from '../../flag' -import { log } from '../../utils/pino' -import { sendHttpRequest } from '../../utils/http' - -export const createConfigFile = async (flags: MonikaFlags): Promise => { - const filename = flags['config-filename'] - - try { - const url = - 'https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.yml' - await sendHttpRequest({ url }).then((resp) => { - fs.writeFileSync(filename, resp.data, 'utf8') - }) - log.info( - `${filename} file has been created in this directory. You can change the URL to probe and other configurations in that ${filename} file.` - ) - } catch { - const ymlConfig = ` - probes: - - id: '1' - requests: - - url: http://github.com - - db_limit: - max_db_size: 1000000000 - deleted_data: 1 - cron_schedule: '*/1 * * * *' - ` - - fs.writeFileSync(filename, ymlConfig, 'utf8') - } - - return filename -} diff --git a/src/components/config/create.ts b/src/components/config/create.ts index 5c7fb2f5d..a70241814 100644 --- a/src/components/config/create.ts +++ b/src/components/config/create.ts @@ -6,10 +6,10 @@ import { type } from 'node:os' import { ux } from '@oclif/core' import yml from 'js-yaml' -import { addDefaultNotifications } from './get' -import { parseConfig } from './parse' import { getContext } from '../../context' import { log } from '../../utils/pino' +import { addDefaultNotifications } from './get' +import { type ConfigType, parseByType } from './parser/parse' export async function createConfig(): Promise { const { flags } = getContext() @@ -30,12 +30,11 @@ export async function createConfig(): Promise { throw new Error(`Couldn't found the ${path} file.`) } - const parse = await parseConfig(path, type, flags) - const file = flags.output || 'monika.yml' + const { force, output } = flags - if (existsSync(file) && !flags.force) { + if (existsSync(output) && !force) { const answer = await ux.ux.prompt( - `\n${file} file is already exists. Overwrite (Y/n)?` + `\n${output} file is already exists. Overwrite (Y/n)?` ) if (answer.toLowerCase() !== 'y') { @@ -46,11 +45,12 @@ export async function createConfig(): Promise { } } + const parse = await parseByType(path, type) const data = yml.dump(addDefaultNotifications(parse)) - await writeFile(file, data, { + await writeFile(output, data, { encoding: 'utf8', }) - log.info(`${file} file has been created.`) + log.info(`${output} file has been created.`) } function open(url: string) { @@ -80,7 +80,7 @@ function open(url: string) { type PathAndType = { path: string - type: string + type: ConfigType } function getPathAndType(): PathAndType { diff --git a/src/components/config/get.test.ts b/src/components/config/get.test.ts index 9a34885f9..2f0123f00 100644 --- a/src/components/config/get.test.ts +++ b/src/components/config/get.test.ts @@ -23,13 +23,18 @@ **********************************************************************************/ import { expect } from '@oclif/test' +import { getContext, resetContext, setContext } from '../../context' import type { Config } from '../../interfaces/config' -import { addDefaultNotifications } from './get' +import { addDefaultNotifications, getRawConfig } from './get' describe('Add default notification', () => { + beforeEach(() => { + resetContext() + }) + it('should add default notification', () => { // arrange - const config: Partial = { + const config: Config = { probes: [], } @@ -39,37 +44,215 @@ describe('Add default notification', () => { // assert expect(newConfig).deep.eq({ probes: [], - notifications: [{ id: 'default', type: 'desktop', data: undefined }], + notifications: [{ id: 'default', type: 'desktop' }], }) }) it('should add default notification (empty config)', () => { // arrange - const config: Partial = {} + const config = {} as Config // act const newConfig = addDefaultNotifications(config) // assert expect(newConfig).deep.eq({ - notifications: [{ id: 'default', type: 'desktop', data: undefined }], + notifications: [{ id: 'default', type: 'desktop' }], }) }) it('should override existing notification', () => { // arrange - const config: Partial = { + const config = { notifications: [ { id: '1', type: 'webhook', data: { url: 'https://example.com' } }, ], - } + } as Config // act const newConfig = addDefaultNotifications(config) // assert expect(newConfig).deep.eq({ - notifications: [{ id: 'default', type: 'desktop', data: undefined }], + notifications: [{ id: 'default', type: 'desktop' }], + }) + }) + + it('should combine no probes config', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: [ + './src/components/config/__tests__/expected.textfile.yml', + './test/testConfigs/noProbes.yml', + ], + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect(config.probes.length).greaterThan(0) + }) + + it('should return config', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.textfile.yml'], + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect(config.notifications?.length).eq(1) + }) + + it('should overwrite native config with non native config (HAR)', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.textfile.yml'], + har: './src/components/config/__tests__/form_encoded.har', + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://namb.ch/api/admin/login') + ) + ).not.undefined + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://monika.hyperjump.tech') + ) + ).to.be.undefined + expect(config.notifications?.length).eq(1) + }) + + it('should overwrite native config with non native config (Postman)', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.textfile.yml'], + postman: + './src/components/config/__tests__/mock_files/basic-postman_collection-v2.0.json', + }, }) + + // act + const config = await getRawConfig() + + expect( + config.probes.find(({ requests }) => + requests?.find( + ({ url }) => url === 'https://api.github.com/users/hyperjumptech' + ) + ) + ).not.undefined + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://monika.hyperjump.tech') + ) + ).to.be.undefined + expect(config.notifications?.length).eq(1) + }) + + it('should overwrite native config with non native config (Insomnia)', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.textfile.yml'], + insomnia: './src/components/config/__tests__/petstore.insomnia.yaml', + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect( + config.probes.find(({ requests }) => + requests?.find( + ({ url }) => url === 'https://petstore3.swagger.io/api/v3/user/' + ) + ) + ).not.undefined + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://monika.hyperjump.tech') + ) + ).to.be.undefined + expect(config.notifications?.length).eq(1) + }) + + it('should overwrite native config with non native config (Sitemap)', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.textfile.yml'], + sitemap: './src/components/config/__tests__/sitemap.xml', + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect( + config.probes.find(({ requests }) => + requests?.find( + ({ url }) => url === 'https://monika.hyperjump.tech/articles' + ) + ) + ).not.undefined + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://monika.hyperjump.tech') + ) + ).to.be.undefined + expect(config.notifications?.length).eq(1) + }) + + it('should overwrite native config with non native config (Text)', async () => { + // arrange + setContext({ + flags: { + ...getContext().flags, + config: ['./src/components/config/__tests__/expected.sitemap.yml'], + text: './src/components/config/__tests__/textfile', + }, + }) + + // act + const config = await getRawConfig() + + // assert + expect( + config.probes.find(({ requests }) => + requests?.find(({ url }) => url === 'https://github.com') + ) + ).not.undefined + expect( + config.probes.find(({ requests }) => + requests?.find( + ({ url }) => url === 'https://monika.hyperjump.tech/articles' + ) + ) + ).to.be.undefined + expect(config.notifications?.length).eq(1) }) }) diff --git a/src/components/config/get.ts b/src/components/config/get.ts index 3de6ae06c..b63a2ca57 100644 --- a/src/components/config/get.ts +++ b/src/components/config/get.ts @@ -22,124 +22,92 @@ * SOFTWARE. * **********************************************************************************/ -import type { MonikaFlags } from '../../flag' +import { getContext } from '../../context' import type { Config } from '../../interfaces/config' import { log } from '../../utils/pino' -import { parseConfig } from './parse' -import { validateConfigWithSchema } from './validation' -import { validateConfig } from './validate' - -export type ConfigType = - | 'monika' - | 'har' - | 'insomnia' - | 'postman' - | 'sitemap' - | 'text' - -export async function getConfigFrom(flags: MonikaFlags): Promise { - const defaultConfigs = await parseDefaultConfig(flags) - - const nonDefaultConfig = setDefaultNotifications( - defaultConfigs, - await getNonDefaultFlags(flags) +import { parseByType } from './parser/parse' + +export async function getRawConfig(): Promise { + const nativeConfig = await parseNativeConfig() + const nonNativeConfig = await parseNonNativeConfig() + const config = mergeConfigs( + nonNativeConfig ? [...nativeConfig, nonNativeConfig] : nativeConfig ) + const hasNotification = + config.notifications !== undefined && config.notifications.length > 0 + + if (!hasNotification) { + log.info('Notifications not found, using desktop as default.') + return addDefaultNotifications(config) + } - return mergeConfigs(defaultConfigs, nonDefaultConfig) + return config } // mergeConfigs merges configs by overwriting each other -// with initial value taken from nonDefaultConfig -export function mergeConfigs( - defaultConfigs: Partial[], - nonDefaultConfig: Partial -): Config { - if (defaultConfigs.length === 0 && nonDefaultConfig !== undefined) { - return nonDefaultConfig as Config +// with initial value taken from nonNativeConfig +function mergeConfigs(configs: Config[]): Config { + let mergedConfig = configs[0] + + for (const config of configs.splice(1)) { + const hasNotification = + config.notifications && config.notifications.length > 0 + const hasProbe = config.probes && config.probes.length > 0 + + mergedConfig = { + ...mergedConfig, + ...config, + notifications: hasNotification + ? config.notifications + : mergedConfig.notifications, + probes: hasProbe ? config.probes : mergedConfig.probes, + } } - // eslint-disable-next-line unicorn/no-array-reduce - const mergedConfig = defaultConfigs.reduce( - (prev, current) => ({ - ...prev, - ...current, - notifications: current.notifications || prev.notifications, - probes: current.probes || prev.probes, - }), - nonDefaultConfig || {} - ) - - return mergedConfig as Config + return mergedConfig } -export function addDefaultNotifications( - config: Partial -): Partial { - log.info('Notifications not found, using desktop as default...') +export function addDefaultNotifications(config: Config): Config { return { ...config, - notifications: [{ id: 'default', type: 'desktop', data: undefined }], + notifications: [{ id: 'default', type: 'desktop' }], } } -async function parseDefaultConfig( - flags: MonikaFlags -): Promise[]> { +async function parseNativeConfig(): Promise { + const { flags } = getContext() + return Promise.all( - flags.config.map((source) => parseConfigType(source, 'monika', flags)) + flags.config.map((source) => parseByType(source, 'monika')) ) } -async function parseConfigType( - source: string, - configType: ConfigType, - flags: MonikaFlags -): Promise> { - const parsed = await parseConfig(source, configType, flags) - - // ensure that the parsed config meets our formatting - const validatedConfig = await validateConfig(parsed) +async function parseNonNativeConfig(): Promise { + const { flags } = getContext() + const hasNonNativeConfig = + flags.har || flags.insomnia || flags.postman || flags.sitemap || flags.text - if (configType !== 'har') { - const isValidConfig = validateConfigWithSchema(validatedConfig) - if (!isValidConfig.valid) { - throw new Error(isValidConfig.message) - } + if (!hasNonNativeConfig) { + return } - return validatedConfig -} - -async function getNonDefaultFlags( - flags: MonikaFlags -): Promise> { - let result = {} - if (flags.har) { - result = await parseConfigType(flags.har, 'har', flags) - } else if (flags.postman) { - result = await parseConfigType(flags.postman, 'postman', flags) - } else if (flags.insomnia) { - result = await parseConfigType(flags.insomnia, 'insomnia', flags) - } else if (flags.sitemap) { - result = await parseConfigType(flags.sitemap, 'sitemap', flags) - } else if (flags.text) { - result = await parseConfigType(flags.text, 'text', flags) + return parseByType(flags.har, 'har') } - return result -} + if (flags.postman) { + return parseByType(flags.postman, 'postman') + } -function setDefaultNotifications( - defaultConfigs: Partial[], - nonDefaultConfig: Partial -): Partial { - const hasDefaultConfig = defaultConfigs.length > 0 - const hasNonDefaultConfig = Object.keys(nonDefaultConfig).length > 0 + if (flags.insomnia) { + return parseByType(flags.insomnia, 'insomnia') + } - if (!hasDefaultConfig && hasNonDefaultConfig) { - return addDefaultNotifications(nonDefaultConfig) + if (flags.sitemap) { + return parseByType(flags.sitemap, 'sitemap') } - return nonDefaultConfig + if (flags.text) { + return parseByType(flags.text, 'text') + } } diff --git a/src/components/config/index.test.ts b/src/components/config/index.test.ts index 470e7418a..e093d12c0 100644 --- a/src/components/config/index.test.ts +++ b/src/components/config/index.test.ts @@ -22,24 +22,37 @@ * SOFTWARE. * **********************************************************************************/ +import { readFile, rename } from 'node:fs/promises' +import { existsSync } from 'node:fs' import { expect } from '@oclif/test' -import { getConfig, isSymonModeFrom, updateConfig } from '.' +import chai from 'chai' +import spies from 'chai-spies' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' + import { getContext, resetContext, setContext } from '../../context' -import type { Config } from '../../interfaces/config' import events from '../../events' -import { md5Hash } from '../../utils/hash' +import type { Config } from '../../interfaces/config' +import { sanitizeFlags } from '../../flag' +import { getErrorMessage } from '../../utils/catch-error-handler' import { getEventEmitter } from '../../utils/events' -import type { MonikaFlags } from '../../flag' +import { log } from '../../utils/pino' +import { sanitizeConfig } from './sanitize' +import { + getValidatedConfig, + isSymonModeFrom, + initConfig, + updateConfig, +} from '.' import { validateProbes } from './validation' -import { getErrorMessage } from '../../utils/catch-error-handler' -describe('getConfig', () => { +describe('getValidatedConfig', () => { beforeEach(() => { resetContext() }) it('should throw error when config is empty', () => { - expect(() => getConfig()).to.throw( + expect(() => getValidatedConfig()).to.throw( 'Configuration setup has not been run yet' ) }) @@ -47,27 +60,37 @@ describe('getConfig', () => { it('should not throw error when config is empty in symon mode', () => { // arrange setContext({ - flags: { + flags: sanitizeFlags({ symonKey: 'bDF8j', symonUrl: 'https://example.com', - } as MonikaFlags, + }), }) // act & assert - expect(getConfig()).deep.eq({ probes: [] }) + expect(getValidatedConfig()).deep.eq({ + notifications: [], + probes: [], + 'status-notification': '0 6 * * *', + version: 'efde57d3b97ff787384fc2afde824615', + }) }) it('should return empty array when config is empty in Symon mode', () => { // arrange setContext({ - flags: { + flags: sanitizeFlags({ symonKey: 'bDF8j', symonUrl: 'https://example.com', - } as MonikaFlags, + }), }) // act and assert - expect(getConfig()).deep.eq({ probes: [] }) + expect(getValidatedConfig()).deep.eq({ + notifications: [], + probes: [], + 'status-notification': '0 6 * * *', + version: 'efde57d3b97ff787384fc2afde824615', + }) }) }) @@ -141,8 +164,9 @@ describe('updateConfig', () => { it('should update config', async () => { // arrange - const config: Config = { - probes: [ + const config = { + notifications: [], + probes: await validateProbes([ { id: '1', name: '', @@ -157,11 +181,7 @@ describe('updateConfig', () => { ], alerts: [], }, - ], - } - const validatedProbes = { - ...config, - probes: await validateProbes(config.probes), + ]), } let configFromEmitter = '' const eventEmitter = getEventEmitter() @@ -173,10 +193,7 @@ describe('updateConfig', () => { await updateConfig(config) // assert - expect(getContext().config).to.deep.eq({ - ...validatedProbes, - version: md5Hash(validatedProbes), - }) + expect(getContext().config).to.deep.eq(sanitizeConfig(config)) expect(configFromEmitter).not.eq('') }) @@ -216,3 +233,90 @@ describe('updateConfig', () => { eventEmitter.removeListener(events.config.updated, eventListener) }) }) + +describe('Setup Config', () => { + const server = setupServer() + chai.use(spies) + let logMessages: string[] = [] + + before(() => { + server.listen({ onUnhandledRequest: 'bypass' }) + }) + + beforeEach(async () => { + if (existsSync('monika.yml')) { + await rename('monika.yml', 'monika_backup.yml') + } + + setContext({ + ...getContext(), + flags: { ...getContext().flags, config: [] }, + }) + + chai.spy.on(log, 'info', (message: string) => { + logMessages.push(message) + }) + }) + + afterEach(async () => { + server.resetHandlers() + if (existsSync('monika_backup.yml')) { + await rename('monika_backup.yml', 'monika.yml') + } + + chai.spy.restore() + logMessages = [] + }) + + after(() => { + server.close() + }) + + it('should create example config if config does not exist', async () => { + // act + await initConfig() + + // assert + expect(existsSync('monika.yml')).eq(true) + }) + + it('should create local example config if the url is unreachable', async () => { + // arrange + server.use( + http.get( + 'https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.yml', + () => HttpResponse.error() + ) + ) + + // act + await initConfig() + + // assert + expect(existsSync('monika.yml')).eq(true) + expect(await readFile('monika.yml', { encoding: 'utf8' })).include( + 'http://example.com' + ) + }) + + it('log config changes info', async () => { + // arrange + server.use( + http.get( + 'https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.yml', + () => HttpResponse.error() + ) + ) + setContext({ config: sanitizeConfig({ probes: [], version: '0.0.1' }) }) + + // act + await initConfig() + + // assert + expect( + logMessages.find( + (message) => message === 'Config changes. Updating config...' + ) + ).not.undefined + }) +}) diff --git a/src/components/config/index.ts b/src/components/config/index.ts index b1fea4cd9..2d935037f 100644 --- a/src/components/config/index.ts +++ b/src/components/config/index.ts @@ -22,201 +22,102 @@ * SOFTWARE. * **********************************************************************************/ -import { watch } from 'chokidar' -import isUrl from 'is-url' +import { writeFile } from 'node:fs/promises' import events from '../../events' -import type { Config } from '../../interfaces/config' +import type { Config, ValidatedConfig } from '../../interfaces/config' import { getContext, setContext } from '../../context' import type { MonikaFlags } from '../../flag' import { getEventEmitter } from '../../utils/events' -import { md5Hash } from '../../utils/hash' +import { sendHttpRequest } from '../..//utils/http' import { log } from '../../utils/pino' -import { parseConfig } from './parse' -import { validateConfig } from './validate' -import { createConfigFile } from './create-config' -import { exit } from 'process' -import { type ConfigType, getConfigFrom, mergeConfigs } from './get' +import { getRawConfig } from './get' import { getProbes, setProbes } from './probe' -import { getErrorMessage } from '../../utils/catch-error-handler' - -type ScheduleRemoteConfigFetcherParams = { - configType: ConfigType - interval: number - url: string - index?: number -} - -type WatchConfigFileParams = { - flags: MonikaFlags - path: string -} - -const emitter = getEventEmitter() - -const defaultConfigs: Partial[] = [] -let nonDefaultConfig: Partial - -export const getConfig = (): Config => { - const { config, flags } = getContext() - - if (!config) { - if (!isSymonModeFrom(flags)) { - throw new Error('Configuration setup has not been run yet') - } - - return { - probes: [], - } - } - - return { ...config, probes: getProbes() } -} - -export const updateConfig = async (config: Config): Promise => { - log.info('Updating config') - try { - const validatedConfig = await validateConfig(config) - const version = md5Hash(validatedConfig) - const hasChangeConfig = getContext().config?.version !== version - - if (!hasChangeConfig) { - return - } - - const newConfig = addConfigVersion(validatedConfig) - - setContext({ config: newConfig }) - setProbes(newConfig.probes) - emitter.emit(events.config.updated) - log.info('Config file update detected') - } catch (error: unknown) { - const message = getErrorMessage(error) - - if (getContext().isTest) { - // return error during tests - throw new Error(message) - } - - log.error(message) - exit(1) - } -} - -export const setupConfig = async (flags: MonikaFlags): Promise => { - const validFlag = await createConfigIfEmpty(flags) - const config = await getConfigFrom(validFlag) - await updateConfig(config) - - watchConfigsChange(validFlag) -} - -function addConfigVersion(config: Config) { - if (config.version) { - return config - } - - const version = config.version || md5Hash(config) - - return { ...config, version } -} +import { sanitizeConfig } from './sanitize' +import { validateConfig } from './validate' -async function createConfigIfEmpty(flags: MonikaFlags): Promise { - // check for default config path when -c/--config not provided +export async function initConfig() { + const { flags } = getContext() const hasConfig = flags.config.length > 0 || flags.har || - flags.postman || flags.insomnia || + flags.postman || flags.sitemap || flags.text if (!hasConfig) { - log.info('No Monika configuration available, initializing...') - const configFilename = await createConfigFile(flags) - return { ...flags, config: [configFilename] } + await createExampleConfigFile() } - return flags + const config = await getRawConfig() + await updateConfig(config) } -async function watchConfigsChange(flags: MonikaFlags) { - await Promise.all( - flags.config.map((source, index) => - watchConfigChange({ - flags, - interval: flags['config-interval'], - source, - type: 'monika', - index, - }) - ) +async function createExampleConfigFile() { + log.info('No Monika configuration available, initializing...') + const outputFilePath = getContext().flags['config-filename'] + const url = + 'https://raw.githubusercontent.com/hyperjumptech/monika/main/monika.example.yml' + + try { + const resp = await sendHttpRequest({ url }) + await writeFile(outputFilePath, resp.data, { encoding: 'utf8' }) + } catch { + const ymlConfig = ` + probes: + - id: '1' + requests: + - url: http://example.com + + db_limit: + max_db_size: 1000000000 + deleted_data: 1 + cron_schedule: '*/1 * * * *' + ` + await writeFile(outputFilePath, ymlConfig, { encoding: 'utf8' }) + } + + setContext({ flags: { ...getContext().flags, config: [outputFilePath] } }) + log.info( + `${outputFilePath} file has been created in this directory. You can change the URL to probe and other configurations in that ${outputFilePath} file.` ) } -type WatchConfigChangeParams = { - flags: MonikaFlags - interval: number - source: string - type: ConfigType - index?: number -} +export function getValidatedConfig(): ValidatedConfig { + const { config, flags } = getContext() + + if (!config) { + if (!isSymonModeFrom(flags)) { + throw new Error('Configuration setup has not been run yet') + } -function watchConfigChange({ - flags, - interval, - source, - type, - index, -}: WatchConfigChangeParams) { - if (isUrl(source)) { - scheduleRemoteConfigFetcher({ - configType: type, - interval, - url: source, - index, + return sanitizeConfig({ + probes: [], }) - return } - watchConfigFile({ - flags, - path: source, - }) + return { ...config, probes: getProbes() } } -function scheduleRemoteConfigFetcher({ - configType, - interval, - url, - index, -}: ScheduleRemoteConfigFetcherParams) { - setInterval(async () => { - try { - const newConfig = await parseConfig(url, configType) - if (index === undefined) { - nonDefaultConfig = newConfig - } else { - defaultConfigs[index] = newConfig - } - - await updateConfig(mergeConfigs(defaultConfigs, nonDefaultConfig)) - } catch (error: unknown) { - log.error(getErrorMessage(error)) - } - }, interval * 1000) -} +export async function updateConfig(config: Config): Promise { + const validatedConfig = await validateConfig(config) + const sanitizedConfig = sanitizeConfig(validatedConfig) + const hasConfigChange = + getContext().config?.version !== sanitizedConfig.version -function watchConfigFile({ flags, path }: WatchConfigFileParams) { - const isWatchConfigFile = !(getContext().isTest || flags.repeat !== 0) - if (isWatchConfigFile) { - const watcher = watch(path) - watcher.on('change', async () => { - const config = await getConfigFrom(flags) + if (!hasConfigChange) { + return + } - await updateConfig(config) - }) + const isInitalSetup = getContext().config?.version === undefined + if (!isInitalSetup) { + log.info('Config changes. Updating config...') } + + setContext({ config: sanitizedConfig }) + setProbes(sanitizedConfig.probes) + getEventEmitter().emit(events.config.updated) } export function isSymonModeFrom({ diff --git a/src/components/config/parse.ts b/src/components/config/parse.ts deleted file mode 100644 index 8dc07af94..000000000 --- a/src/components/config/parse.ts +++ /dev/null @@ -1,100 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -import { Config } from '../../interfaces/config' -import type { MonikaFlags } from '../../flag' -import { parseConfigFromPostman } from './parse-postman' -import { parseConfigFromSitemap } from './parse-sitemap' -import { parseConfigFromText } from './parse-text' -import { parseHarFile } from './parse-har' -import path from 'path' -import yml from 'js-yaml' -import parseInsomnia from './parse-insomnia' -import isUrl from 'is-url' -import { fetchConfig } from './fetch' -import { readFile } from '../../utils/read-file' - -function sleep(ms: number): Promise { - // eslint-disable-next-line no-promise-executor-return - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export const parseConfig = async ( - source: string, - type: string, - flag?: MonikaFlags -): Promise> => { - try { - let configString = isUrl(source) - ? await fetchConfig(source) - : await readFile(source, 'utf8') - - if (configString.length === 0) { - if (isUrl(source)) - // was the remote file empty - throw new Error( - `The remote file ${source} is empty. Please check the URL or your connection again.` - ) - - let tries = 10 // tries multiple times to load the file - while (configString.length === 0 && tries > 0) { - sleep(700) - // eslint-disable-next-line no-await-in-loop - configString = await readFile(source, 'utf8') - if (configString.length > 0) { - break - } - - tries-- - } - - if (configString.length === 0) - throw new Error(`Failed to read ${source}, got empty config string.`) - } - - const ext = path.extname(source) - - if (type === 'har') return parseHarFile(configString) - if (type === 'text') return parseConfigFromText(configString) - if (type === 'postman') return parseConfigFromPostman(configString) - if (type === 'sitemap') return parseConfigFromSitemap(configString, flag) - if (type === 'insomnia') - return parseInsomnia(configString, ext.replace('.', '')) - - if (ext === '.yml' || ext === '.yaml') { - const cfg = yml.load(configString, { json: true }) - return cfg as unknown as Config - } - - return JSON.parse(configString) - } catch (error: unknown) { - const parsingError = - error instanceof Error ? error : new Error(String(error)) - if (parsingError.name === 'SyntaxError') { - throw new Error('JSON configuration file is in invalid JSON format!') - } - - throw new Error(parsingError.message) - } -} diff --git a/src/components/config/parse-har.ts b/src/components/config/parser/har.ts similarity index 95% rename from src/components/config/parse-har.ts rename to src/components/config/parser/har.ts index 4cdbd7f9a..37cdb40c5 100644 --- a/src/components/config/parse-har.ts +++ b/src/components/config/parser/har.ts @@ -23,9 +23,9 @@ **********************************************************************************/ import Joi from 'joi' -import { Config } from '../../interfaces/config' -import { RequestConfig } from '../../interfaces/request' -import { DEFAULT_INTERVAL } from './validation/validator/default-values' +import type { Config } from '../../../interfaces/config' +import type { RequestConfig } from '../../../interfaces/request' +import { DEFAULT_INTERVAL } from '../validation/validator/default-values' const keyValValidator = Joi.array().items( Joi.object({ key: Joi.string(), value: Joi.string() }) diff --git a/src/components/config/parse-insomnia.ts b/src/components/config/parser/insomnia.ts similarity index 97% rename from src/components/config/parse-insomnia.ts rename to src/components/config/parser/insomnia.ts index 9e212eb68..f901091e5 100644 --- a/src/components/config/parse-insomnia.ts +++ b/src/components/config/parser/insomnia.ts @@ -22,7 +22,7 @@ * SOFTWARE. * **********************************************************************************/ -import type { Config } from '../../interfaces/config' +import type { Config } from '../../../interfaces/config' import yml from 'js-yaml' import { compile as compileTemplate } from 'handlebars' import type { AxiosRequestHeaders, Method } from 'axios' @@ -69,10 +69,7 @@ const insomniaValidator = Joi.object({ let baseUrl = '' let environmentVariables: object | undefined -export default function parseInsomnia( - configString: string, - format: string -): Config { +export function parseInsomnia(configString: string, format: string): Config { const parseResult = format === 'yaml' || format === 'yml' ? yml.load(configString, { json: true }) diff --git a/src/components/config/fetch.ts b/src/components/config/parser/parse.ts similarity index 50% rename from src/components/config/fetch.ts rename to src/components/config/parser/parse.ts index 5779cbcc6..29f18e00d 100644 --- a/src/components/config/fetch.ts +++ b/src/components/config/parser/parse.ts @@ -22,14 +22,95 @@ * SOFTWARE. * **********************************************************************************/ -import { sendHttpRequest } from '../../utils/http' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import isUrl from 'is-url' +import yml from 'js-yaml' -export const fetchConfig = async (url: string): Promise => { +import type { Config } from '../../../interfaces/config' +import { sendHttpRequest } from '../../../utils/http' +import { parseHarFile } from './har' +import { parseInsomnia } from './insomnia' +import { parseConfigFromPostman } from './postman' +import { parseConfigFromSitemap } from './sitemap' +import { parseConfigFromText } from './text' + +export type ConfigType = + | 'har' + | 'insomnia' + | 'monika' + | 'postman' + | 'sitemap' + | 'text' + +export async function parseByType( + source: string, + type: ConfigType +): Promise { + const config = isUrl(source) + ? await getConfigFileFromUrl(source) + : await readFile(source, { encoding: 'utf8' }) + const isEmpty = config.length === 0 + + if (isEmpty) { + throw new Error(`Failed to read ${source}, the file is empty.`) + } + + const extension = path.extname(source) + + if (type === 'har') return parseHarFile(config) + if (type === 'text') return parseConfigFromText(config) + if (type === 'postman') return parseConfigFromPostman(config) + if (type === 'sitemap') return parseConfigFromSitemap(config) + if (type === 'insomnia') + return parseInsomnia(config, extension.replace('.', '')) + + return parseConfigByExt({ + config, + extension, + source, + }) +} + +async function getConfigFileFromUrl(url: string) { + const config = await fetchConfigFile(url) + const isEmpty = config.length === 0 + + if (isEmpty) { + throw new Error( + `The remote file ${url} is empty. Please check the URL or your connection again.` + ) + } + + return config +} + +async function fetchConfigFile(url: string) { try { const { data } = await sendHttpRequest({ url }) + return data } catch { - throw new Error(`The configuration file in ${url} is unreachable. Please check the URL again or your internet connection. - `) + throw new Error(`The configuration file in ${url} is unreachable.`) } } + +type ParseConfigByExtParams = { + config: string + extension: string + source: string +} + +function parseConfigByExt({ + config, + extension, + source, +}: ParseConfigByExtParams) { + const isYaml = ['.yaml', '.yml'].includes(extension) + + if (isYaml) { + return yml.load(config, { json: true }) as Config + } + + return isUrl(source) ? config : JSON.parse(config) +} diff --git a/src/components/config/parse-postman.ts b/src/components/config/parser/postman.ts similarity index 99% rename from src/components/config/parse-postman.ts rename to src/components/config/parser/postman.ts index e54f7f56e..47d9c08af 100644 --- a/src/components/config/parse-postman.ts +++ b/src/components/config/parser/postman.ts @@ -23,7 +23,7 @@ **********************************************************************************/ import Joi from 'joi' -import type { Config } from '../../interfaces/config' +import type { Config } from '../../../interfaces/config' type CollectionVersion = 'v2.0' | 'v2.1' diff --git a/src/components/config/parse-sitemap.ts b/src/components/config/parser/sitemap.ts similarity index 91% rename from src/components/config/parse-sitemap.ts rename to src/components/config/parser/sitemap.ts index ecf733013..aad4fd8f6 100644 --- a/src/components/config/parse-sitemap.ts +++ b/src/components/config/parser/sitemap.ts @@ -22,15 +22,15 @@ * SOFTWARE. * **********************************************************************************/ -import type { Config } from '../../interfaces/config' import { XMLParser } from 'fast-xml-parser' -import { getContext } from '../../context' -import { monikaFlagsDefaultValue } from '../../flag' -import type { MonikaFlags } from '../../flag' -import type { Probe, ProbeAlert } from '../../interfaces/probe' -import type { RequestConfig } from '../../interfaces/request' import Joi from 'joi' +import type { Config } from '../../../interfaces/config' +import { getContext } from '../../../context' +import { monikaFlagsDefaultValue } from '../../../flag' +import type { Probe, ProbeAlert } from '../../../interfaces/probe' +import type { RequestConfig } from '../../../interfaces/request' + const sitemapValidator = Joi.object({ config: Joi.object({ urlset: Joi.object({ @@ -137,15 +137,12 @@ const generateProbesFromXmlOneProbe = (parseResult: unknown) => { return probe ? [probe] : [] } -export const parseConfigFromSitemap = ( - configString: string, - flags?: MonikaFlags -): Config => { +export const parseConfigFromSitemap = (configString: string): Config => { try { const parser = new XMLParser({ ignoreAttributes: false }) const xmlObj = parser.parse(configString) let probes = generateProbesFromXml(xmlObj) - if (flags && flags['one-probe']) { + if (getContext().flags['one-probe']) { probes = generateProbesFromXmlOneProbe(xmlObj) } diff --git a/src/components/config/parse-text.ts b/src/components/config/parser/text.ts similarity index 94% rename from src/components/config/parse-text.ts rename to src/components/config/parser/text.ts index 5ab44d021..3558d24e3 100644 --- a/src/components/config/parse-text.ts +++ b/src/components/config/parser/text.ts @@ -46,11 +46,11 @@ * SOFTWARE. * **********************************************************************************/ -import { getContext } from '../../context' -import type { Config } from '../../interfaces/config' -import { monikaFlagsDefaultValue } from '../../flag' -import type { Probe, ProbeAlert } from '../../interfaces/probe' -import { isValidURL } from '../../utils/is-valid-url' +import { getContext } from '../../../context' +import type { Config } from '../../../interfaces/config' +import { monikaFlagsDefaultValue } from '../../../flag' +import type { Probe, ProbeAlert } from '../../../interfaces/probe' +import { isValidURL } from '../../../utils/is-valid-url' export const parseConfigFromText = (configString: string): Config => { let probes: Probe[] = [] diff --git a/src/components/config/sanitize.ts b/src/components/config/sanitize.ts new file mode 100644 index 000000000..8f130e1fb --- /dev/null +++ b/src/components/config/sanitize.ts @@ -0,0 +1,23 @@ +import { createHash } from 'node:crypto' +import type { Config, ValidatedConfig } from '../../interfaces/config' + +const DEFAULT_STATUS_NOTIFICATION = '0 6 * * *' + +export function sanitizeConfig(config: Config): ValidatedConfig { + const { notifications = [], version } = config + const sanitizedConfigWithoutVersion = { + ...config, + notifications, + 'status-notification': + config['status-notification'] || DEFAULT_STATUS_NOTIFICATION, + } + + return { + ...sanitizedConfigWithoutVersion, + version: version || md5Hash(sanitizedConfigWithoutVersion), + } +} + +function md5Hash(data: Config): string { + return createHash('md5').update(JSON.stringify(data)).digest('hex') +} diff --git a/src/components/config/validate.ts b/src/components/config/validate.ts index 3efdef3d5..a033b4f4e 100644 --- a/src/components/config/validate.ts +++ b/src/components/config/validate.ts @@ -22,24 +22,63 @@ * SOFTWARE. * **********************************************************************************/ -import type { Config } from '../../interfaces/config' -import { validateProbes, validateSymonConfig } from './validation' import { validateNotification } from '@hyperjumptech/monika-notification' +import Ajv from 'ajv' +import Joi from 'joi' + +import { getContext } from '../../context' +import type { Config, SymonConfig } from '../../interfaces/config' +import monikaConfigSchema from '../../monika-config-schema.json' +import { validateProbes } from './validation' +import { isSymonModeFrom } from '.' export const validateConfig = async ( - configuration: Partial + configuration: Config ): Promise => { const { notifications = [], probes = [], symon } = configuration - const symonConfigError = validateSymonConfig(symon) - - if (symonConfigError) { - throw new Error(`Monika configuration: symon ${symonConfigError}`) - } - const [validatedProbes] = await Promise.all([ validateProbes(probes), validateNotification(notifications), + validateSymon(symon), ]) + const validatedConfig = { + ...configuration, + notifications, + probes: validatedProbes, + } + + if (!isSymonModeFrom(getContext().flags)) { + validateConfigWithJSONSchema(validatedConfig) + } + + return validatedConfig +} + +async function validateSymon(symonConfig?: SymonConfig) { + if (!symonConfig) { + return + } + + const schema = Joi.object({ + id: Joi.string().required(), + url: Joi.string().uri().required(), + key: Joi.string().required(), + projectID: Joi.string().required(), + organizationID: Joi.string().required(), + interval: Joi.number(), + }) - return { ...configuration, probes: validatedProbes } + await schema.validateAsync(symonConfig) +} + +function validateConfigWithJSONSchema(config: Config) { + const ajv = new Ajv() + const validate = ajv.compile(monikaConfigSchema) + const isValid = validate(config) + + if (!isValid) { + throw new Error( + `${validate.errors?.[0].instancePath} ${validate.errors?.[0].message}` + ) + } } diff --git a/src/components/config/validation/index.ts b/src/components/config/validation/index.ts index 5776a4ebe..e5c290c39 100644 --- a/src/components/config/validation/index.ts +++ b/src/components/config/validation/index.ts @@ -22,6 +22,4 @@ * SOFTWARE. * **********************************************************************************/ -export { validateConfigWithSchema } from './validator/config-file' export { validateProbes } from './validator/probe' -export { validateSymonConfig } from './validator/symon-config' diff --git a/src/components/config/validation/validator/config-file.ts b/src/components/config/validation/validator/config-file.ts deleted file mode 100644 index 86afcb876..000000000 --- a/src/components/config/validation/validator/config-file.ts +++ /dev/null @@ -1,54 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -import Ajv from 'ajv' -import { Validation } from '../../../../interfaces/validation' -import mySchema from '../../../../monika-config-schema.json' -import { Config } from '../../../../interfaces/config' - -const ajv = new Ajv() - -// validate the config file loaded by monika against a JSON Schema -export function validateConfigWithSchema(config: Partial): Validation { - const result: Validation = { - valid: false, - message: `Errors detected in config file ${JSON.stringify( - config, - null, - 2 - )}`, - } - - const validate = ajv.compile(mySchema) - - const isValid = validate(config) - - if (isValid) { - result.valid = true - result.message = `config: ${config} is ok` - return result - } - - return result -} diff --git a/src/components/config/watcher.test.ts b/src/components/config/watcher.test.ts new file mode 100644 index 000000000..73c282ff2 --- /dev/null +++ b/src/components/config/watcher.test.ts @@ -0,0 +1,118 @@ +import { existsSync } from 'node:fs' +import { rename, writeFile } from 'node:fs/promises' +import { expect } from 'chai' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' + +import { resetContext, setContext } from '../../context' +import { sanitizeFlags } from '../../flag' +import type { Config } from '../../interfaces/config' +import type { Probe } from '../../interfaces/probe' +import { getProbes } from './probe' +import { watchConfigChanges } from './watcher' + +describe('Config watcher', () => { + const config: Config = { + probes: [ + { + id: '1', + requests: [{ url: 'https://example.com' }], + } as Probe, + ], + } + const server = setupServer( + http.get( + 'https://example.com/monika.json', + () => new HttpResponse(JSON.stringify(config)) + ) + ) + + before(() => { + server.listen() + }) + + afterEach(() => { + server.resetHandlers() + resetContext() + }) + + after(() => { + server.close() + }) + + it('should polling config from a URL', async () => { + // arrange + const configIntervalSeconds = 1 + setContext({ + flags: sanitizeFlags({ + config: ['https://example.com/monika.json'], + 'config-interval': configIntervalSeconds, + }), + }) + + // act + const watchers = watchConfigChanges() + const seconds = 1000 + await sleep(configIntervalSeconds * seconds) + + // assert + expect(getProbes()[0].requests?.[0].url).eq( + config.probes[0].requests?.[0].url + ) + + for (const { cancel } of watchers) { + cancel() + } + }) + + it('should watch config file changes', async () => { + // arrange + if (existsSync('monika.json')) { + await rename('monika.json', 'monika_backup.json') + } + + await writeFile('monika.json', JSON.stringify(config), { encoding: 'utf8' }) + + setContext({ + flags: sanitizeFlags({ + config: ['monika.json'], + }), + }) + + // act + const watchers = watchConfigChanges() + const newConfig = { + probes: [ + { + id: '2', + requests: [ + { + url: 'https://example.com/changed', + }, + ], + }, + ], + } + await writeFile('monika.json', JSON.stringify(newConfig), { + encoding: 'utf8', + }) + await sleep(1000) + + // assert + expect(getProbes()[0].id).eq('2') + expect(getProbes()[0].requests?.[0].url).eq('https://example.com/changed') + for (const { cancel } of watchers) { + cancel() + } + + if (existsSync('monika_backup.json')) { + await rename('monika_backup.json', 'monika.json') + } + }) +}) + +function sleep(durationMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, durationMs) + }) +} diff --git a/src/components/config/watcher.ts b/src/components/config/watcher.ts new file mode 100644 index 000000000..e79cbb64f --- /dev/null +++ b/src/components/config/watcher.ts @@ -0,0 +1,60 @@ +import { watch } from 'chokidar' +import isUrl from 'is-url' + +import { getContext } from '../../context' +import { getErrorMessage } from '../../utils/catch-error-handler' +import { log } from '../../utils/pino' +import { getRawConfig } from './get' +import { parseByType } from './parser/parse' +import { updateConfig } from '.' + +type WatcherCancellation = { + cancel: () => void +} + +export function watchConfigChanges() { + const clearWatchers: WatcherCancellation[] = [] + + for (const source of getContext().flags.config) { + if (isUrl(source)) { + clearWatchers.push(pollingRemoteConfig(source)) + continue + } + + clearWatchers.push(watchConfigFile(source)) + } + + return clearWatchers +} + +function pollingRemoteConfig(url: string) { + const intervalId = setInterval(async () => { + try { + const config = await parseByType(url, 'monika') + + await updateConfig(config) + } catch (error) { + log.error(getErrorMessage(error)) + } + }, getContext().flags['config-interval'] * 1000) + + return { + cancel: () => clearInterval(intervalId), + } +} + +function watchConfigFile(path: string) { + const watcher = watch(path).on('change', async () => { + try { + const config = await getRawConfig() + + await updateConfig(config) + } catch (error) { + log.error(getErrorMessage(error)) + } + }) + + return { + cancel: () => watcher.close(), + } +} diff --git a/src/components/logger/startup-message.test.ts b/src/components/logger/startup-message.test.ts index c27f3209d..6d0a9c0f4 100644 --- a/src/components/logger/startup-message.test.ts +++ b/src/components/logger/startup-message.test.ts @@ -24,14 +24,15 @@ import chai, { expect } from 'chai' import spies from 'chai-spies' +import { sanitizeConfig } from '../config/sanitize' import { sanitizeFlags } from '../../flag' -import type { Config } from '../../interfaces/config' +import type { ValidatedConfig } from '../../interfaces/config' import { log } from '../../utils/pino' import { logStartupMessage } from './startup-message' chai.use(spies) -const defaultConfig: Config = { +const defaultConfig: ValidatedConfig = { probes: [ { id: 'uYJaw', @@ -55,7 +56,9 @@ const defaultConfig: Config = { ], }, ], - notifications: [{ id: 'UVIsL', type: 'desktop', data: undefined }], + notifications: [{ id: 'UVIsL', type: 'desktop' }], + 'status-notification': '', + version: '1', } describe('Startup message', () => { @@ -82,7 +85,7 @@ describe('Startup message', () => { it('should show running in Symon mode', () => { // act logStartupMessage({ - config: { probes: [] }, + config: sanitizeConfig({ probes: [] }), flags: sanitizeFlags({ config: [], symonKey: 'secret-key', @@ -102,7 +105,12 @@ describe('Startup message', () => { it('should show has no notification warning', () => { // arrange logStartupMessage({ - config: { probes: [] }, + config: { + notifications: [], + probes: [], + 'status-notification': '', + version: '1', + }, flags: sanitizeFlags({ config: [], symonKey: '', @@ -226,6 +234,7 @@ describe('Startup message', () => { // arrange logStartupMessage({ config: { + notifications: [], probes: [ { id: 'uYJaw', @@ -235,6 +244,8 @@ describe('Startup message', () => { alerts: [], }, ], + 'status-notification': '', + version: '1', }, flags: sanitizeFlags({ config: [], @@ -367,7 +378,7 @@ describe('Startup message', () => { logStartupMessage({ config: { ...defaultConfig, - notifications: [{ id: 'UVIsL', type: 'desktop', data: undefined }], + notifications: [{ id: 'UVIsL', type: 'desktop' }], }, flags: sanitizeFlags({ config: [], diff --git a/src/components/logger/startup-message.ts b/src/components/logger/startup-message.ts index a430e1f50..d62f17018 100644 --- a/src/components/logger/startup-message.ts +++ b/src/components/logger/startup-message.ts @@ -27,7 +27,7 @@ import isUrl from 'is-url' import boxen from 'boxen' import chalk from 'chalk' import type { MonikaFlags } from '../../flag' -import type { Config } from '../../interfaces/config' +import type { ValidatedConfig } from '../../interfaces/config' import type { Notification } from '@hyperjumptech/monika-notification' import { channels } from '@hyperjumptech/monika-notification' import type { Probe } from '../../interfaces/probe' @@ -36,7 +36,7 @@ import { isSymonModeFrom } from '../config' import { createProber } from '../probe/prober/factory' type LogStartupMessage = { - config: Config + config: ValidatedConfig flags: Pick< MonikaFlags, 'config' | 'symonKey' | 'symonUrl' | 'verbose' | 'native-fetch' @@ -75,7 +75,7 @@ function generateStartupMessage({ flags, isFirstRun, }: LogStartupMessage): string { - const { notifications = [], probes } = config + const { notifications, probes } = config const notificationTotal = notifications.length const probeTotal = probes.length const hasNotification = notificationTotal > 0 @@ -95,7 +95,7 @@ function generateStartupMessage({ if (flags.verbose) { startupMessage += generateProbeMessage(probes) - startupMessage += generateNotificationMessage(notifications || []) + startupMessage += generateNotificationMessage(notifications) } return startupMessage diff --git a/src/components/notification/schedule-notification.test.ts b/src/components/notification/schedule-notification.test.ts index 209a503df..79f8c1828 100644 --- a/src/components/notification/schedule-notification.test.ts +++ b/src/components/notification/schedule-notification.test.ts @@ -35,7 +35,7 @@ describe('Schedule notification', () => { it('should not schedule notification on Symon mode', () => { // act scheduleSummaryNotification({ - config: {}, + config: { 'status-notification': '' }, flags: { symonKey: 'secret-key', symonUrl: 'https://example.com' }, }) @@ -46,7 +46,7 @@ describe('Schedule notification', () => { it('should not schedule notification if status notification flag is false', () => { // act scheduleSummaryNotification({ - config: {}, + config: { 'status-notification': '' }, flags: { 'status-notification': 'false' }, }) @@ -79,22 +79,19 @@ describe('Schedule notification', () => { sinon.assert.calledOnce(cronScheduleStub) expect(cronExpression).eq('0 0 0 0 0') }) - - it('should schedule notification use default schedule', () => { - // act - scheduleSummaryNotification({ config: {}, flags: {} }) - - // assert - sinon.assert.calledOnce(cronScheduleStub) - expect(cronExpression).eq('0 6 * * *') - }) }) describe('Reset schedule notification', () => { it('should stop all running scheduled notification', () => { // act - scheduleSummaryNotification({ config: {}, flags: {} }) - scheduleSummaryNotification({ config: {}, flags: {} }) + scheduleSummaryNotification({ + config: { 'status-notification': '' }, + flags: {}, + }) + scheduleSummaryNotification({ + config: { 'status-notification': '' }, + flags: {}, + }) // arrange expect(taskStopCalledTotal).eq(2) diff --git a/src/components/notification/schedule-notification.ts b/src/components/notification/schedule-notification.ts index 63febba62..482c8ddec 100644 --- a/src/components/notification/schedule-notification.ts +++ b/src/components/notification/schedule-notification.ts @@ -1,12 +1,12 @@ import { schedule as scheduleCron } from 'node-cron' import type { ScheduledTask } from 'node-cron' import type { MonikaFlags } from '../../flag' -import type { Config } from '../../interfaces/config' +import type { ValidatedConfig } from '../../interfaces/config' import { getSummaryAndSendNotif } from '../../jobs/summary-notification' import { isSymonModeFrom } from '../config' type scheduleSummaryNotification = { - config: Pick + config: Pick flags: Pick } @@ -34,11 +34,7 @@ export function scheduleSummaryNotification({ // defaults to 6 AM // default value is not defined in flag configuration, // because the value can also come from config file - const DEFAULT_SCHEDULE_CRON_EXPRESSION = '0 6 * * *' - const schedule = - flags['status-notification'] || - config['status-notification'] || - DEFAULT_SCHEDULE_CRON_EXPRESSION + const schedule = flags['status-notification'] || config['status-notification'] const scheduledStatusUpdateTask = scheduleCron( schedule, diff --git a/src/components/reporter/index.ts b/src/components/reporter/index.ts deleted file mode 100644 index 285d36f2a..000000000 --- a/src/components/reporter/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -export interface SymonConfig { - id: string - url: string - key: string - projectID: string - organizationID: string - interval?: number -} - -export type SymonResponse = { - result: string - message: string -} diff --git a/src/components/tls-checker/index.ts b/src/components/tls-checker/index.ts index 921e3e523..7fbcbd00b 100644 --- a/src/components/tls-checker/index.ts +++ b/src/components/tls-checker/index.ts @@ -22,9 +22,9 @@ * SOFTWARE. * **********************************************************************************/ -import type { RequestOptions } from 'https' +import type { RequestOptions } from 'node:https' import sslChecker from 'ssl-checker' -import type { Domain } from '../../interfaces/certificate' +import type { Domain } from '../../interfaces/config' export async function checkTLS( domain: Domain, diff --git a/src/context/index.ts b/src/context/index.ts index 1913b6ac1..37ea6cfa4 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -22,7 +22,7 @@ * SOFTWARE. * **********************************************************************************/ -import type { Config } from '../interfaces/config' +import type { ValidatedConfig } from '../interfaces/config' import type { ProbeAlert } from '../interfaces/probe' import { type MonikaFlags, monikaFlagsDefaultValue } from '../flag' @@ -39,7 +39,7 @@ type Context = { userAgent: string incidents: Incident[] isTest: boolean - config?: Omit + config?: Omit flags: MonikaFlags } diff --git a/src/events/subscribers/application.ts b/src/events/subscribers/application.ts index 6dee21842..8cfcab96a 100644 --- a/src/events/subscribers/application.ts +++ b/src/events/subscribers/application.ts @@ -22,8 +22,8 @@ * SOFTWARE. * **********************************************************************************/ -import { hostname } from 'os' -import { getConfig } from '../../components/config' +import { hostname } from 'node:os' +import { getValidatedConfig } from '../../components/config' import { sendNotifications } from '@hyperjumptech/monika-notification' import { getMessageForTerminate } from '../../components/notification/alert-message' import { getContext } from '../../context' @@ -32,15 +32,12 @@ import { getEventEmitter } from '../../utils/events' import getIp from '../../utils/ip' import { log } from '../../utils/pino' -const eventEmitter = getEventEmitter() - -eventEmitter.on(events.application.terminated, async () => { +getEventEmitter().on(events.application.terminated, async () => { if (!getContext().isTest) { const message = await getMessageForTerminate(hostname(), getIp()) - const config = getConfig() - sendNotifications(config.notifications ?? [], message).catch((error) => - log.error(error) + sendNotifications(getValidatedConfig().notifications, message).catch( + (error) => log.error(error) ) } }) diff --git a/src/interfaces/certificate.ts b/src/interfaces/certificate.ts deleted file mode 100644 index 207fdc9ea..000000000 --- a/src/interfaces/certificate.ts +++ /dev/null @@ -1,38 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -import type { RequestOptions } from 'https' - -type DomainWithOptions = { - domain: string - options?: RequestOptions -} - -export type Domain = string | DomainWithOptions - -export interface Certificate { - domains: Domain[] - // The reminder is the number of days to send notification to user before the domain expires. - reminder?: number -} diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index 0113f0711..b51ad84c0 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -22,20 +22,49 @@ * SOFTWARE. * **********************************************************************************/ -import { Certificate } from './certificate' +import type { RequestOptions } from 'node:https' import type { Notification } from '@hyperjumptech/monika-notification' -import { Probe } from './probe' -import { SymonConfig } from '../components/reporter' +import type { Probe } from './probe' import type { DBLimit } from './data' -export interface Config { - certificate?: Certificate +type DomainWithOptions = { + domain: string + options?: RequestOptions +} + +export type Domain = string | DomainWithOptions + +type Certificate = { + domains: Domain[] + // The reminder is the number of days to send notification to user before the domain expires. + reminder?: number +} + +export type SymonConfig = { + id: string + url: string + key: string + projectID: string + organizationID: string interval?: number - notifications?: Notification[] +} + +export type Config = { probes: Probe[] + certificate?: Certificate + db_limit?: DBLimit + notifications?: Notification[] 'status-notification'?: string symon?: SymonConfig version?: string +} +export type ValidatedConfig = { + probes: Probe[] + notifications: Notification[] + 'status-notification': string + version: string + certificate?: Certificate db_limit?: DBLimit + symon?: SymonConfig } diff --git a/src/interfaces/validation.ts b/src/interfaces/validation.ts deleted file mode 100644 index d7fe85a6e..000000000 --- a/src/interfaces/validation.ts +++ /dev/null @@ -1,28 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -export interface Validation { - valid: boolean - message: string -} diff --git a/src/jobs/check-database.ts b/src/jobs/check-database.ts index 5e79c568d..ad1b7f96f 100644 --- a/src/jobs/check-database.ts +++ b/src/jobs/check-database.ts @@ -22,38 +22,38 @@ * SOFTWARE. * **********************************************************************************/ -import path from 'path' -import fs from 'fs' -import { getConfig } from '../components/config' +import { resolve } from 'node:path' +import { stat } from 'node:fs/promises' +import { getValidatedConfig } from '../components/config' import { deleteFromAlerts, deleteFromNotifications, deleteFromProbeRequests, } from '../components/logger/history' -import { Config } from '../interfaces/config' -const dbPath = path.resolve(process.cwd(), 'monika-logs.db') +import type { ValidatedConfig } from '../interfaces/config' -export function checkDBSize(): void { - const config = getConfig() - deleteData(config) +const dbPath = resolve(process.cwd(), 'monika-logs.db') + +export async function checkDBSize() { + await deleteData(getValidatedConfig()) } -async function deleteData(config: Config) { - const { db_limit: DBLimit } = config - const stats = fs.statSync(dbPath) +async function deleteData(config: ValidatedConfig) { + const { db_limit: dbLimit } = config - if (!DBLimit?.max_db_size) { + if (!dbLimit?.max_db_size || !dbLimit.deleted_data) { return } - if (!DBLimit.deleted_data) { - return - } + const { deleted_data: deletedData, max_db_size: maxDbSize } = dbLimit + const { size } = await stat(dbPath) - if (stats.size > DBLimit.max_db_size) { - const probeRes = await deleteFromProbeRequests(DBLimit.deleted_data) - await deleteFromNotifications(probeRes.probeIds) - await deleteFromAlerts(probeRes.probeRequestIds) + if (size > maxDbSize) { + const { probeRequestIds, probeIds } = await deleteFromProbeRequests( + deletedData + ) + await deleteFromNotifications(probeIds) + await deleteFromAlerts(probeRequestIds) deleteData(config) // recursive until reached expected file size } diff --git a/src/jobs/summary-notification.ts b/src/jobs/summary-notification.ts index 830648bd3..97d6cd315 100644 --- a/src/jobs/summary-notification.ts +++ b/src/jobs/summary-notification.ts @@ -27,7 +27,7 @@ import { hostname } from 'node:os' import { sendNotifications } from '@hyperjumptech/monika-notification' import format from 'date-fns/format' -import { getConfig } from '../components/config' +import { getValidatedConfig } from '../components/config' import { getSummary } from '../components/logger/history' import { maxResponseTime, @@ -43,7 +43,7 @@ import { } from '../components/notification/alert-message' import { getContext } from '../context' import events from '../events' -import type { Config } from '../interfaces/config' +import type { ValidatedConfig } from '../interfaces/config' import { getEventEmitter } from '../utils/events' import { getErrorMessage } from '../utils/catch-error-handler' import getIp from '../utils/ip' @@ -60,7 +60,7 @@ type TweetMessage = { } export async function getSummaryAndSendNotif(): Promise { - const config = getConfig() + const config = getValidatedConfig() const { notifications, probes } = config if (!notifications) return @@ -126,19 +126,17 @@ ${tweetMessage} } } -/** - * savePidFile saves a monika.pid file with some useful information - * @param {string} configFile is the configuration file used - * @param {obj} config is a Config object - * @returns void - */ -export function savePidFile(configFile: string[], config: Config): void { +// savePidFile saves a monika.pid file with some useful information +export function savePidFile( + configFile: string[], + { notifications, probes }: ValidatedConfig +): void { const data = JSON.stringify({ monikaStartTime: new Date(), monikaConfigFile: configFile, monikaPid: process.pid, - monikaProbes: config.probes ? config.probes.length : '0', - monikaNotifs: config.notifications ? config.notifications.length : '0', + monikaProbes: probes.length, + monikaNotifs: notifications.length, }) fs.writeFile('monika.pid', data, (err) => { diff --git a/src/jobs/tls-check.ts b/src/jobs/tls-check.ts index 00b2f57a1..bedc4854d 100644 --- a/src/jobs/tls-check.ts +++ b/src/jobs/tls-check.ts @@ -29,7 +29,7 @@ import { } from '@hyperjumptech/monika-notification' import { format } from 'date-fns' -import { getConfig } from '../components/config' +import { getValidatedConfig } from '../components/config' import { saveNotificationLog } from '../components/logger/history' import { getMonikaInstance, @@ -42,7 +42,7 @@ import { log } from '../utils/pino' import { publicIpAddress } from '../utils/public-ip' export function tlsChecker(): void { - const config = getConfig() + const config = getValidatedConfig() const hasDomain = (config?.certificate?.domains?.length || 0) > 0 if (!hasDomain) { @@ -71,7 +71,7 @@ export function tlsChecker(): void { sendErrorNotification({ errorMessage: error.message, hostname, - notifications: notifications || [], + notifications, }).catch(console.error) }) } diff --git a/src/components/config/validation/validator/symon-config.ts b/src/loaders/index.test.ts similarity index 67% rename from src/components/config/validation/validator/symon-config.ts rename to src/loaders/index.test.ts index a21b81524..e751baccb 100644 --- a/src/components/config/validation/validator/symon-config.ts +++ b/src/loaders/index.test.ts @@ -22,25 +22,43 @@ * SOFTWARE. * **********************************************************************************/ -import Joi from 'joi' -import type { SymonConfig } from '../../../reporter' +import type { Config } from '@oclif/core' +import { expect } from '@oclif/test' -export const validateSymonConfig = ( - symonConfig?: SymonConfig -): string | undefined => { - if (!symonConfig) { - return '' - } +import { sanitizeFlags } from '../flag' +import init from '.' - const schema = Joi.object({ - id: Joi.string().required(), - url: Joi.string().uri().required(), - key: Joi.string().required(), - projectID: Joi.string().required(), - organizationID: Joi.string().required(), - interval: Joi.number(), +describe('Loader', () => { + it('should enable auto update', async () => { + // arrange + const flags = sanitizeFlags({ + 'auto-update': 'minor', + prometheus: 3000, + stun: -1, + symonUrl: 'https://example.com', + symonKey: '1234', + }) + const cliConfig = { arch: 'arm64' } as Config + + // assert + expect(init(flags, cliConfig)).to.eventually.throw() }) - const validationError = schema.validate(symonConfig) - return validationError?.error?.message -} + it('should enable prometheus', async () => { + // arrange + const flags = sanitizeFlags({ + prometheus: 3000, + stun: -1, + symonUrl: 'https://example.com', + symonKey: '1234', + }) + const cliConfig = {} as Config + + // act + await init(flags, cliConfig) + + // assert + const resp = await fetch('http://localhost:3000/metrics') + expect(resp.status).eq(200) + }) +}) diff --git a/src/loaders/index.ts b/src/loaders/index.ts index f0c7c0d78..3f079b351 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -22,8 +22,9 @@ * SOFTWARE. * **********************************************************************************/ -import type { Config as IConfig } from '@oclif/core' -import { isSymonModeFrom, setupConfig } from '../components/config' +import type { Config } from '@oclif/core' +import { isSymonModeFrom } from '../components/config' +import { watchConfigChanges } from '../components/config/watcher' import { openLogfile } from '../components/logger/history' import { getContext } from '../context' import events from '../events' @@ -45,7 +46,7 @@ import '../events/subscribers/probe' export default async function init( flags: MonikaFlags, - cliConfig: IConfig + cliConfig: Config ): Promise { await openLogfile() // check if connected to STUN Server and getting the public IP in the same time @@ -61,8 +62,7 @@ export default async function init( } if (!isSymonModeFrom(flags)) { - await setupConfig(flags) - + watchConfigChanges() // check TLS when Monika starts tlsChecker() diff --git a/src/loaders/jobs.ts b/src/loaders/jobs.ts index 7a26355e2..652234719 100644 --- a/src/loaders/jobs.ts +++ b/src/loaders/jobs.ts @@ -21,17 +21,22 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * * SOFTWARE. * **********************************************************************************/ -import * as cron from 'node-cron' + +import { schedule } from 'node-cron' import { tlsChecker } from '../jobs/tls-check' import { checkDBSize } from '../jobs/check-database' -import { getConfig } from '../components/config' +import { getValidatedConfig } from '../components/config' +import { getErrorMessage } from '../utils/catch-error-handler' +import { log } from '../utils/pino' export function jobsLoader() { - const config = getConfig() // schedule TLS checker every day at 00:00 - cron.schedule('0 0 * * *', tlsChecker) + schedule('0 0 * * *', tlsChecker) // schedule database size check + const config = getValidatedConfig() if (config?.db_limit && config?.db_limit.cron_schedule) { - cron.schedule(config.db_limit.cron_schedule, checkDBSize) + schedule(config.db_limit.cron_schedule, () => + checkDBSize().catch((error) => log.error(getErrorMessage(error))) + ) } } diff --git a/src/monika-config-schema.json b/src/monika-config-schema.json index 8ec49ced8..50b9e3416 100644 --- a/src/monika-config-schema.json +++ b/src/monika-config-schema.json @@ -247,6 +247,13 @@ "additionalProperties": false, "required": ["url"], "properties": { + "id": { + "title": "Id", + "type": "string", + "description": "Unique string identification of the request", + "examples": ["1"], + "default": "1" + }, "method": { "title": "HTTP Method", "type": "string", @@ -1322,6 +1329,12 @@ "examples": [30] } } + }, + "version": { + "type": "string", + "title": "Monika configuration version", + "description": "Monika configuration version", + "default": "1" } }, "type": "object" diff --git a/src/symon/index.test.ts b/src/symon/index.test.ts index 7fa71d441..bb04352db 100644 --- a/src/symon/index.test.ts +++ b/src/symon/index.test.ts @@ -41,7 +41,7 @@ import { getErrorMessage } from '../utils/catch-error-handler' import { getProbeState, initializeProbeStates } from '../utils/probe-state' const config: Config = { - version: 'asdfg123', + version: 'f7c35f9f873da44bca433427b4d30fc5', probes: [ { id: '1', @@ -189,10 +189,7 @@ describe('Symon initiate', () => { await symon.initiate() await symon.stop() - expect(getContext().config).deep.equals({ - ...config, - probes: await validateProbes(config.probes), - }) + expect(getContext().config?.version).deep.eq(config.version) expect(getProbes()).deep.eq(await validateProbes(config.probes)) }).timeout(15_000) diff --git a/src/symon/index.ts b/src/symon/index.ts index 5083f4233..99102f53e 100644 --- a/src/symon/index.ts +++ b/src/symon/index.ts @@ -30,7 +30,6 @@ import { hostname } from 'os' import path from 'path' import Piscina from 'piscina' -import type { Config } from '../interfaces/config' import type { Probe } from '../interfaces/probe' import type { ValidatedResponse } from '../plugins/validate-response' @@ -415,9 +414,8 @@ export default class SymonClient { private async fetchProbesAndUpdateConfig() { // Fetch the probes const { hash, probes } = await this.fetchProbes() - const newConfig: Config = { probes, version: hash } - await setConfig(newConfig) + await updateConfig({ probes, version: hash }) log.info('[Symon] Get probes succeed') } @@ -515,15 +513,3 @@ async function applyProbeChanges(probeChanges: ProbeChange[]) { }) ) } - -async function setConfig(newConfig: Config) { - if ( - !newConfig.version || - getContext().config?.version === newConfig.version - ) { - return - } - - log.info('[Symon] Config changes. Reloading Monika') - await updateConfig(newConfig) -} diff --git a/src/utils/hash.ts b/src/utils/hash.ts deleted file mode 100644 index b849eb2b4..000000000 --- a/src/utils/hash.ts +++ /dev/null @@ -1,30 +0,0 @@ -/********************************************************************************** - * MIT License * - * * - * Copyright (c) 2021 Hyperjump Technology * - * * - * Permission is hereby granted, free of charge, to any person obtaining a copy * - * of this software and associated documentation files (the "Software"), to deal * - * in the Software without restriction, including without limitation the rights * - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * - * copies of the Software, and to permit persons to whom the Software is * - * furnished to do so, subject to the following conditions: * - * * - * The above copyright notice and this permission notice shall be included in all * - * copies or substantial portions of the Software. * - * * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * - * SOFTWARE. * - **********************************************************************************/ - -import { createHash } from 'crypto' - -export const md5Hash = (data: string | object): string => { - const str = typeof data === 'string' ? data : JSON.stringify(data) - return createHash('md5').update(str).digest('hex') -} diff --git a/src/utils/read-file.ts b/src/utils/read-file.ts deleted file mode 100644 index 5c11bf307..000000000 --- a/src/utils/read-file.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { log } from './pino' -import fs from 'fs' - -// Alternative of fs.readFileSync() API with type safety error NodeJS.ErrnoException -export async function readFile(path: string, encoding: BufferEncoding) { - return new Promise((resolve, reject) => { - fs.readFile(path, { encoding }, (error, result) => { - if (error && error?.code === 'ENOENT') { - log.info( - `Could not find the file: ${path}. Monika is probably not running or ran from a diffent directory` - ) - reject(error) - } else { - resolve(result) - } - }) - }) -} diff --git a/test/index.test.ts b/test/index.test.ts index 96286eba2..eeee87c88 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -77,7 +77,7 @@ describe('monika', () => { ) .it('detects valid remote config', (ctx) => { expect(ctx.stdout).to.contain( - 'Starting Monika. Probes: 1. Notifications: 0' + 'Starting Monika. Probes: 1. Notifications: 1' ) })