Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions src/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import config from '../../config/index.js'
import AWS from 'aws-sdk'
import { createClient } from 'redis'
import logger from '../utils/logger.js'
import datasette from '../services/datasette.js'

const router = express.Router()

Expand All @@ -16,36 +17,52 @@ router.get('/', async (req, res) => {
const dependencies = [
{
name: 's3-bucket',
status: await checkS3Bucket() ? 'ok' : 'unreachable'
status: await checkS3Bucket() ? 'ok' : 'down'
},
{
name: 'request-api',
status: await checkRequestApi() ? 'ok' : 'unreachable'
status: await checkRequestApi() ? 'ok' : 'down'
},
{
name: 'datasette',
status: await checkDatasette() ? 'ok' : 'down'
}
]
if (config.redis) {
dependencies.push({
name: 'redis',
status: await checkRedis() ? 'ok' : 'unreachable'
status: await checkRedis() ? 'ok' : 'down',
required: false
})
}

const status = getStatus(dependencies)
const toReturn = {
status,
name: config.serviceNames.submit,
environment: config.environment,
version: process.env.GIT_COMMIT || 'unknown',
maintenance: config.maintenance.serviceUnavailable,
dependencies
}

const isAnyServiceUnreachable = toReturn.dependencies.some(service => service.status === 'unreachable')
if (isAnyServiceUnreachable) {
if (status === 'down') {
res.status(500).json(toReturn)
} else {
res.json(toReturn)
}
})

const getStatus = (dependencies) => {
if (dependencies.some(service => service.status === 'down' && service.required !== false)) {
return 'down'
}
if (dependencies.some(service => service.status === 'down')) {
return 'degraded'
}
return 'ok'
}

const checkS3Bucket = async () => {
const s3 = new AWS.S3()
return await s3.headBucket({ Bucket: config.aws.bucket }).promise()
Expand All @@ -62,6 +79,15 @@ const checkRequestApi = async () => {
}
}

const checkDatasette = async () => {
try {
await datasette.runQuery('SELECT 1')
return true
} catch (error) {
return false
}
}

const checkRedis = async () => {
const urlPrefix = `redis${config.redis.secure ? 's' : ''}`
const client = createClient({
Expand All @@ -85,4 +111,4 @@ const checkRedis = async () => {
}

export default router
export { checkS3Bucket, checkRequestApi, checkRedis }
export { checkS3Bucket, checkRequestApi, checkDatasette, checkRedis, getStatus }
158 changes: 125 additions & 33 deletions test/integration/health.test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
import request from 'supertest'
import express from 'express'
import router from '../../src/routes/health.js'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import AWS from 'aws-sdk'
import { createClient } from 'redis'
import config from '../../config/index.js'
import datasette from '../../src/services/datasette.js'

const app = express()
app.use('/', router)

vi.mock('aws-sdk')
vi.mock('redis')
vi.mock('../../src/services/datasette.js', () => ({
default: {
runQuery: vi.fn()
}
}))

process.env.GIT_COMMIT = 'test_commit_short'

describe('GET health', () => {
it('when all services are healthy', async () => {
AWS.S3.mockReturnValue({
const mockS3Bucket = (promiseFactory) => {
AWS.S3.mockImplementation(function () {
return {
headBucket: vi.fn().mockReturnValue({
promise: vi.fn().mockResolvedValue({})
promise: vi.fn().mockImplementation(promiseFactory)
})
})
}
})
}

describe('GET health', () => {
beforeEach(() => {
vi.clearAllMocks()
config.redis = {
secure: false,
host: 'localhost',
port: 6379
}
})

it('when all services are healthy', async () => {
mockS3Bucket(() => Promise.resolve({}))

const mockClient = {
connect: vi.fn().mockResolvedValue({}),
Expand All @@ -30,6 +51,7 @@ describe('GET health', () => {
createClient.mockReturnValue(mockClient)

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] })

const res = await request(app)
.get('/')
Expand All @@ -41,13 +63,16 @@ describe('GET health', () => {
expect(res.body).toHaveProperty('version')
expect(res.body).toHaveProperty('maintenance')
expect(res.body).toHaveProperty('dependencies')
expect(res.body.dependencies).toHaveLength(3)
expect(res.body).toHaveProperty('status', 'ok')
expect(res.body.dependencies).toHaveLength(4)
expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket')
expect(res.body.dependencies[0]).toHaveProperty('status')
expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api')
expect(res.body.dependencies[1]).toHaveProperty('status')
expect(res.body.dependencies[2]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette')
expect(res.body.dependencies[2]).toHaveProperty('status')
expect(res.body.dependencies[3]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[3]).toHaveProperty('status')

expect(res.body.name).toEqual(config.serviceNames.submit)
expect(res.body.environment).toEqual(config.environment)
Expand All @@ -63,16 +88,19 @@ describe('GET health', () => {
status: 'ok'
},
{
name: 'redis',
name: 'datasette',
status: 'ok'
},
{
name: 'redis',
status: 'ok',
required: false
}
])
})

it('when s3 bucket is unhealthy', async () => {
AWS.S3.mockReturnValue({
headBucket: vi.fn().mockRejectedValue(new Error('Bucket does not exist'))
})
mockS3Bucket(() => Promise.reject(new Error('Bucket does not exist')))

const mockClient = {
connect: vi.fn().mockResolvedValue({}),
Expand All @@ -82,6 +110,7 @@ describe('GET health', () => {
createClient.mockReturnValue(mockClient)

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] })
const res = await request(app)
.get('/')
.expect('Content-Type', /json/)
Expand All @@ -92,37 +121,43 @@ describe('GET health', () => {
expect(res.body).toHaveProperty('version')
expect(res.body).toHaveProperty('maintenance')
expect(res.body).toHaveProperty('dependencies')
expect(res.body.dependencies).toHaveLength(3)
expect(res.body).toHaveProperty('status', 'down')
expect(res.body.dependencies).toHaveLength(4)
expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket')
expect(res.body.dependencies[0]).toHaveProperty('status', 'unhealthy')
expect(res.body.dependencies[0]).toHaveProperty('status', 'down')
expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api')
expect(res.body.dependencies[1]).toHaveProperty('status')
expect(res.body.dependencies[2]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette')
expect(res.body.dependencies[2]).toHaveProperty('status')
expect(res.body.dependencies[3]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[3]).toHaveProperty('status')
expect(res.body.name).toEqual(config.serviceNames.submit)
expect(res.body.environment).toEqual(config.environment)
expect(res.body.version).toEqual('test_commit_short')
expect(res.body.maintenance).toEqual(config.maintenance.serviceUnavailable)
expect(res.body.dependencies).toStrictEqual([
{
name: 's3-bucket',
status: 'unhealthy'
status: 'down'
},
{
name: 'request-api',
status: 'ok'
},
{
name: 'redis',
name: 'datasette',
status: 'ok'
},
{
name: 'redis',
status: 'ok',
required: false
}
])
})

it('when request api is unhealthy', async () => {
AWS.S3.mockReturnValue({
headBucket: vi.fn().mockResolvedValue({})
})
mockS3Bucket(() => Promise.resolve({}))

const mockClient = {
connect: vi.fn().mockResolvedValue({}),
Expand All @@ -132,6 +167,7 @@ describe('GET health', () => {
createClient.mockReturnValue(mockClient)

vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Request API is down')))
datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] })
const res = await request(app)
.get('/')
.expect('Content-Type', /json/)
Expand All @@ -142,13 +178,16 @@ describe('GET health', () => {
expect(res.body).toHaveProperty('version')
expect(res.body).toHaveProperty('maintenance')
expect(res.body).toHaveProperty('dependencies')
expect(res.body.dependencies).toHaveLength(3)
expect(res.body).toHaveProperty('status', 'down')
expect(res.body.dependencies).toHaveLength(4)
expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket')
expect(res.body.dependencies[0]).toHaveProperty('status', 'ok')
expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api')
expect(res.body.dependencies[1]).toHaveProperty('status', 'unhealthy')
expect(res.body.dependencies[2]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[1]).toHaveProperty('status', 'down')
expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette')
expect(res.body.dependencies[2]).toHaveProperty('status', 'ok')
expect(res.body.dependencies[3]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[3]).toHaveProperty('status', 'ok')
expect(res.body.name).toEqual(config.serviceNames.submit)
expect(res.body.environment).toEqual(config.environment)
expect(res.body.version).toEqual('test_commit_short')
Expand All @@ -160,19 +199,22 @@ describe('GET health', () => {
},
{
name: 'request-api',
status: 'unhealthy'
status: 'down'
},
{
name: 'redis',
name: 'datasette',
status: 'ok'
},
{
name: 'redis',
status: 'ok',
required: false
}
])
})

it('when redis is unhealthy', async () => {
AWS.S3.mockReturnValue({
headBucket: vi.fn().mockResolvedValue({})
})
mockS3Bucket(() => Promise.resolve({}))

const mockClient = {
connect: vi.fn().mockRejectedValue(new Error('Redis connection failed')),
Expand All @@ -182,23 +224,28 @@ describe('GET health', () => {
createClient.mockReturnValue(mockClient)

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] })
const res = await request(app)
.get('/')
.expect('Content-Type', /json/)
.expect(500)
.expect(200)

expect(res.body).toHaveProperty('name')
expect(res.body).toHaveProperty('environment')
expect(res.body).toHaveProperty('version')
expect(res.body).toHaveProperty('maintenance')
expect(res.body).toHaveProperty('dependencies')
expect(res.body.dependencies).toHaveLength(3)
expect(res.body).toHaveProperty('status', 'degraded')
expect(res.body.dependencies).toHaveLength(4)
expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket')
expect(res.body.dependencies[0]).toHaveProperty('status', 'ok')
expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api')
expect(res.body.dependencies[1]).toHaveProperty('status', 'ok')
expect(res.body.dependencies[2]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[2]).toHaveProperty('status', 'unhealthy')
expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette')
expect(res.body.dependencies[2]).toHaveProperty('status', 'ok')
expect(res.body.dependencies[3]).toHaveProperty('name', 'redis')
expect(res.body.dependencies[3]).toHaveProperty('status', 'down')
expect(res.body.dependencies[3]).toHaveProperty('required', false)
expect(res.body.name).toEqual(config.serviceNames.submit)
expect(res.body.environment).toEqual(config.environment)
expect(res.body.version).toEqual('test_commit_short')
Expand All @@ -212,9 +259,54 @@ describe('GET health', () => {
name: 'request-api',
status: 'ok'
},
{
name: 'datasette',
status: 'ok'
},
{
name: 'redis',
status: 'down',
required: false
}
])
})

it('when datasette is unhealthy', async () => {
mockS3Bucket(() => Promise.resolve({}))

const mockClient = {
connect: vi.fn().mockResolvedValue({}),
isOpen: true,
quit: vi.fn()
}
createClient.mockReturnValue(mockClient)

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
datasette.runQuery.mockRejectedValue(new Error('Datasette is down'))

const res = await request(app)
.get('/')
.expect('Content-Type', /json/)
.expect(500)

expect(res.body).toHaveProperty('status', 'down')
expect(res.body.dependencies).toStrictEqual([
{
name: 's3-bucket',
status: 'ok'
},
{
name: 'request-api',
status: 'ok'
},
{
name: 'datasette',
status: 'down'
},
{
name: 'redis',
status: 'unhealthy'
status: 'ok',
required: false
}
])
})
Expand Down
Loading
Loading