feat: scheduled healthchecks with reporting via Discord webhook

This commit is contained in:
Marc 2024-03-14 00:41:01 -04:00
parent 147b64d570
commit 44a9c52c78
Signed by: marc
GPG key ID: 048E042F22B5DC79
7 changed files with 305 additions and 0 deletions

5
.gitignore vendored
View file

@ -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

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -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": <service-name>, "url": <endpoint-url> },
...
],
"webhook_url": <webhook-url>
}
```
The `webhook-url` is expected to contain `$DISCORD_WEBHOOK_ID` and `$DISCORD_WEBHOOK_TOKEN` placeholders.

8
netlify.toml Normal file
View file

@ -0,0 +1,8 @@
[build]
publish = "."
[functions]
directory = "src/"
[functions."scheduledHealthcheck"]
schedule = "*/10 * * * *"

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "healthcheck",
"version": "noversion",
"main": "index.js",
"repository": "git@github.com:mcataford/healthcheck.git",
"author": "Marc Cataford <mcat@riseup.net>",
"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"
}
}

View file

@ -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<EndpointReport> {
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()
}

172
yarn.lock Normal file
View file

@ -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<compat/typescript>":
version: 5.4.2
resolution: "typescript@patch:typescript@npm%3A5.4.2#optional!builtin<compat/typescript>::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