feat: scheduled healthchecks with reporting via Discord webhook
This commit is contained in:
parent
147b64d570
commit
44a9c52c78
7 changed files with 305 additions and 0 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -7,6 +7,8 @@ yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
config.json
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
@ -128,3 +130,6 @@ dist
|
||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# Local Netlify folder
|
||||||
|
.netlify
|
||||||
|
|
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
16
README.md
16
README.md
|
@ -1,2 +1,18 @@
|
||||||
# healthcheck
|
# healthcheck
|
||||||
Personal DiscordOps to keep things I deploy in check
|
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
8
netlify.toml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
[build]
|
||||||
|
publish = "."
|
||||||
|
|
||||||
|
[functions]
|
||||||
|
directory = "src/"
|
||||||
|
|
||||||
|
[functions."scheduledHealthcheck"]
|
||||||
|
schedule = "*/10 * * * *"
|
18
package.json
Normal file
18
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
85
src/scheduledHealthcheck.mts
Normal file
85
src/scheduledHealthcheck.mts
Normal 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
172
yarn.lock
Normal 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
|
Reference in a new issue