diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05200c72..53d58839 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,5 +108,7 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=${{ needs.release.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/src/app.ts b/src/app.ts index 7793daf8..1c427599 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,7 @@ interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean } -const { keepAliveTimeout, headersTimeout, isMultitenant } = getConfig() +const { version, keepAliveTimeout, headersTimeout, isMultitenant } = getConfig() const build = (opts: buildOpts = {}): FastifyInstance => { const app = fastify(opts) @@ -66,6 +66,9 @@ const build = (opts: buildOpts = {}): FastifyInstance => { setErrorHandler(app) + app.get('/version', (_, reply) => { + reply.send(version) + }) app.get('/status', async (request, response) => response.status(200).send()) return app diff --git a/src/config.ts b/src/config.ts index cc379c2d..d1dcc518 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import dotenv from 'dotenv' type StorageBackendType = 'file' | 's3' type StorageConfigType = { + version: string keepAliveTimeout: number headersTimeout: number adminApiKeys: string @@ -97,6 +98,7 @@ export function getConfig(): StorageConfigType { dotenv.config() return { + version: getOptionalConfigFromEnv('VERSION') || '0.0.0', keepAliveTimeout: parseInt(getOptionalConfigFromEnv('SERVER_KEEP_ALIVE_TIMEOUT') || '61', 10), headersTimeout: parseInt(getOptionalConfigFromEnv('SERVER_HEADERS_TIMEOUT') || '65', 10), adminApiKeys: getOptionalConfigFromEnv('ADMIN_API_KEYS') || '', diff --git a/src/http/plugins/tenant-id.ts b/src/http/plugins/tenant-id.ts index 27c88ed7..207df37d 100644 --- a/src/http/plugins/tenant-id.ts +++ b/src/http/plugins/tenant-id.ts @@ -21,7 +21,7 @@ export const tenantId = fastifyPlugin(async (fastify) => { }) export const adminTenantId = fastifyPlugin(async (fastify) => { - fastify.decorateRequest('tenantId', tenantId) + fastify.register(tenantId) fastify.addHook('onRequest', async (request) => { const tenantId = (request.params as Record).tenantId if (!tenantId) return diff --git a/src/http/routes/tenant/index.ts b/src/http/routes/tenant/index.ts index 745d261f..bfd1c4b8 100644 --- a/src/http/routes/tenant/index.ts +++ b/src/http/routes/tenant/index.ts @@ -3,7 +3,18 @@ import { FromSchema } from 'json-schema-to-ts' import apiKey from '../../plugins/apikey' import { decrypt, encrypt } from '../../../auth' import { knex } from '../../../database/multitenant-db' -import { deleteTenantConfig, runMigrations } from '../../../database/tenant' +import { + deleteTenantConfig, + getServiceKeyUser, + getTenantConfig, + runMigrations, +} from '../../../database/tenant' +import { TenantConnection } from '../../../database/connection' +import { Knex } from 'knex' +import { getConfig } from '../../../config' +import { StorageBackendError } from '../../../storage' + +const { databaseMaxConnections } = getConfig() const patchSchema = { body: { @@ -245,4 +256,55 @@ export default async function routes(fastify: FastifyInstance) { deleteTenantConfig(request.params.tenantId) reply.code(204).send() }) + + fastify.get('/:tenantId/health', async (req, res) => { + let isExternalPool = false + let connection: TenantConnection | undefined + let tx: Knex.Transaction | undefined + + try { + const { databasePoolUrl, databaseUrl } = await getTenantConfig(req.params.tenantId) + const adminUser = await getServiceKeyUser(req.params.tenantId) + + isExternalPool = Boolean(databasePoolUrl) + connection = await TenantConnection.create({ + tenantId: req.params.tenantId, + headers: {}, + maxConnections: isExternalPool ? 1 : databaseMaxConnections, + dbUrl: databasePoolUrl || databaseUrl, + isExternalPool, + user: adminUser, + superUser: adminUser, + }) + } catch (e) { + if (e instanceof StorageBackendError) { + res.status(200).send({ healthy: false, error: e.message }) + return + } + res.status(200).send({ healthy: false, error: 'Could not create connection' }) + return + } + + try { + tx = await connection.transaction() + } catch (e) { + res.status(500).send({ healthy: false, error: 'Could not acquire connection' }) + return + } + + try { + await tx.raw('SELECT id from storage.buckets limit 1') + await tx.commit() + res.send({ healthy: true }) + } catch (e) { + await tx.rollback(e) + res.status(200).send({ healthy: false }) + } finally { + if (isExternalPool && connection) { + connection.dispose().catch(() => { + // ignore + }) + } + } + }) }