From 44a9c52c780516f279af3c5fde1258f334ca5506 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Thu, 14 Mar 2024 00:41:01 -0400 Subject: [PATCH] feat: scheduled healthchecks with reporting via Discord webhook --- .gitignore | 5 + .yarnrc.yml | 1 + README.md | 16 ++++ netlify.toml | 8 ++ package.json | 18 ++++ src/scheduledHealthcheck.mts | 85 +++++++++++++++++ yarn.lock | 172 +++++++++++++++++++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 .yarnrc.yml create mode 100644 netlify.toml create mode 100644 package.json create mode 100644 src/scheduledHealthcheck.mts create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index c6bba59..a60954c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* +config.json + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -128,3 +130,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Local Netlify folder +.netlify diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md index f46adeb..9662155 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # healthcheck Personal DiscordOps to keep things I deploy in check + +## Configuration + +A configuration file should be manually pushed and made accessible to the functions. This can be done via `netlify blobs:set test config -i ./config.json` provided that `config.json` has content that matches the configuration schema: + +``` +{ + "endpoints": [ + { "name": , "url": }, + ... + ], + "webhook_url": +} +``` + +The `webhook-url` is expected to contain `$DISCORD_WEBHOOK_ID` and `$DISCORD_WEBHOOK_TOKEN` placeholders. diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..6b6e613 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] + publish = "." + +[functions] + directory = "src/" + +[functions."scheduledHealthcheck"] + schedule = "*/10 * * * *" diff --git a/package.json b/package.json new file mode 100644 index 0000000..e119c71 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "healthcheck", + "version": "noversion", + "main": "index.js", + "repository": "git@github.com:mcataford/healthcheck.git", + "author": "Marc Cataford ", + "private": true, + "packageManager": "yarn@4.1.1", + "devDependencies": { + "@types/node": "^20.11.27", + "typescript": "^5.4.2" + }, + "dependencies": { + "@netlify/blobs": "^7.0.1", + "@netlify/functions": "^2.6.0", + "axios": "^1.6.7" + } +} diff --git a/src/scheduledHealthcheck.mts b/src/scheduledHealthcheck.mts new file mode 100644 index 0000000..0c0a78c --- /dev/null +++ b/src/scheduledHealthcheck.mts @@ -0,0 +1,85 @@ +import { readFile } from 'node:fs/promises' + +import axios from 'axios' +import { getStore } from '@netlify/blobs' +import type { Context } from '@netlify/functions' + +interface Endpoint { + name: string + url: string +} + +interface Configuration { + endpoints: Endpoint[] + webhook_url: string +} + +interface EndpointReport extends Endpoint { + status: number + healthy: boolean +} + +/* + * Sends a GET request to the specified Endpoint url and reports on whether + * the response given back was a success or not. + * + * Any error when handling the request (request-related or not) is deemed + * to be a failing ping. + * + * Returns an EndpointReport, which is the Endpoint used as input with + * extra status information. + */ +async function pingEndpoint(endpoint: Endpoint): Promise { + try { + const response = await axios.get(endpoint.url) + + return { + ...endpoint, + status: response.status, + healthy: true + } + } catch(e) { + return { + ...endpoint, + healthy: false, + status: e.response.status + } + } +} + +/* + * Formats endpoint reports into a string that can be posted to the Discord webhook. + * + * The report is assembled line-by-line and joined together as one. + */ +function formatReport(endpointReports: EndpointReport[]): string { + const endpointStatuses = endpointReports.map((report: EndpointReport): string => { + if (report.healthy) + return `✅ ${report.name} is healthy (${report.status})` + else + return `🔥 ${report.name} did not respond normally (${report.status})` + }) + + return endpointStatuses.join('\n') +} + +/* + * Pings all endpoints configured to be healthchecked and sends a report on the success/failure + * of requests to the Discord webhook included in the configuration. + * + * This expects DISCORD_WEBHOOK_ID and DISCORD_WEBHOOK_TOKEN to be configured through + * the available environment variables separately. + */ +export default async (request: Request, context: Context) => { + //const conf: Configuration = require("../config.json") + const configurationStore = getStore('functions') + const conf: Configuration = await configurationStore.get('config', { type: 'json' }) + + const pings = await Promise.all(conf.endpoints.map(pingEndpoint)) + + const report = formatReport(pings) + const webhook_url = conf.webhook_url.replace('$DISCORD_WEBHOOK_ID', process.env.DISCORD_WEBHOOK_ID).replace('$DISCORD_WEBHOOK_TOKEN', process.env.DISCORD_WEBHOOK_TOKEN) + await axios.post(webhook_url, { content: report }) + + return new Response() +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..476bf0d --- /dev/null +++ b/yarn.lock @@ -0,0 +1,172 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@netlify/blobs@npm:^7.0.1": + version: 7.0.1 + resolution: "@netlify/blobs@npm:7.0.1" + checksum: 10c0/ad5683ad4f866445d432ef52fc27322c0cc11ea363b9ab900acf61a2c398025766431bae0e73fb51fdcad4ec0ff3a14731955a48ff916f3beb3ebf7b4499b2ff + languageName: node + linkType: hard + +"@netlify/functions@npm:^2.6.0": + version: 2.6.0 + resolution: "@netlify/functions@npm:2.6.0" + dependencies: + "@netlify/serverless-functions-api": "npm:1.14.0" + checksum: 10c0/f4fb83326bb29321f551b70f40e3048cda7582f33aed12d934105e6c4eee93902a40b8301740ba09338c833dfb7195ed5589252db667b3d89928eaf19a328754 + languageName: node + linkType: hard + +"@netlify/node-cookies@npm:^0.1.0": + version: 0.1.0 + resolution: "@netlify/node-cookies@npm:0.1.0" + checksum: 10c0/5d8034d1fd581930e8100af4e5710b79cb3bb0a0b743c716d0d8a1c347aad767fa75130323f1aaee78080a026a4cafd4eef7d11953de01098a661d765a497b16 + languageName: node + linkType: hard + +"@netlify/serverless-functions-api@npm:1.14.0": + version: 1.14.0 + resolution: "@netlify/serverless-functions-api@npm:1.14.0" + dependencies: + "@netlify/node-cookies": "npm:^0.1.0" + urlpattern-polyfill: "npm:8.0.2" + checksum: 10c0/b4dabb8f76a741198a41cc9d985d7eb876f5fcd1cd9d42d3c1d39eb1556360687ef32e44ca89734b642c46a1f97d86e42f876cfeb33906913235aa6ecb36ec33 + languageName: node + linkType: hard + +"@types/node@npm:^20.11.27": + version: 20.11.27 + resolution: "@types/node@npm:20.11.27" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/ec40bea80c60a12b39bd0da9b16333237a84c67ae83c8aa382b88381ae3948943bf6af969442e209270ad3e109f301a6b01ab243f80bd0e69673a877425f9418 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"axios@npm:^1.6.7": + version: 1.6.7 + resolution: "axios@npm:1.6.7" + dependencies: + follow-redirects: "npm:^1.15.4" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/131bf8e62eee48ca4bd84e6101f211961bf6a21a33b95e5dfb3983d5a2fe50d9fffde0b57668d7ce6f65063d3dc10f2212cbcb554f75cfca99da1c73b210358d + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.4": + version: 1.15.5 + resolution: "follow-redirects@npm:1.15.5" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/418d71688ceaf109dfd6f85f747a0c75de30afe43a294caa211def77f02ef19865b547dfb73fde82b751e1cc507c06c754120b848fe5a7400b0a669766df7615 + languageName: node + linkType: hard + +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + +"healthcheck@workspace:.": + version: 0.0.0-use.local + resolution: "healthcheck@workspace:." + dependencies: + "@netlify/blobs": "npm:^7.0.1" + "@netlify/functions": "npm:^2.6.0" + "@types/node": "npm:^20.11.27" + axios: "npm:^1.6.7" + typescript: "npm:^5.4.2" + languageName: unknown + linkType: soft + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b + languageName: node + linkType: hard + +"typescript@npm:^5.4.2": + version: 5.4.2 + resolution: "typescript@npm:5.4.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/583ff68cafb0c076695f72d61df6feee71689568179fb0d3a4834dac343df6b6ed7cf7b6f6c801fa52d43cd1d324e2f2d8ae4497b09f9e6cfe3d80a6d6c9ca52 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.4.2#optional!builtin": + version: 5.4.2 + resolution: "typescript@patch:typescript@npm%3A5.4.2#optional!builtin::version=5.4.2&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/fcf6658073d07283910d9a0e04b1d5d0ebc822c04dbb7abdd74c3151c7aa92fcddbac7d799404e358197222006ccdc4c0db219d223d2ee4ccd9e2b01333b49be + languageName: node + linkType: hard + +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501 + languageName: node + linkType: hard + +"urlpattern-polyfill@npm:8.0.2": + version: 8.0.2 + resolution: "urlpattern-polyfill@npm:8.0.2" + checksum: 10c0/5388bbe8459dbd8861ee7cb97904be915dd863a9789c2191c528056f16adad7836ec22762ed002fed44e8995d0f98bdfb75a606466b77233e70d0f61b969aaf9 + languageName: node + linkType: hard