From 9d7da9e86af612b51035c4aa5eb157e25736bfda Mon Sep 17 00:00:00 2001 From: ff6347 Date: Tue, 13 Dec 2022 14:26:58 +0100 Subject: [PATCH] feat(server): Adds control method for shutdown Introduce flag/env var to be able to shutdown specific routes --- src/index.ts | 2 + src/lib/env.ts | 24 +++- src/lib/server.ts | 286 ++++++++++++++++++++++++++++++---------------- 3 files changed, 215 insertions(+), 97 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5ac255cc..5b0eb2fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { logLevel, logFlareApiKey, logFlareSourceToken, + shutdownLevel, } from "./lib/env"; // eslint-disable-next-line @typescript-eslint/no-var-requires import pino from "pino"; @@ -50,6 +51,7 @@ const server = buildServer({ supabaseServiceRoleKey, logger: pinoLogger, issuer, + shutdownLevel, }); async function main(): Promise { try { diff --git a/src/lib/env.ts b/src/lib/env.ts index 0413603f..45de0450 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -12,9 +12,15 @@ const logLevel = env.require("LOG_LEVEL") as LogLevel; const redisUrl = env.require("REDIS_URL"); const stage = env.require("STAGE"); +const shutdownLevel = parseInt(env.get("SHUTDOWN_LEVEL", "0") as string); + const logFlareApiKey = env.get("LOG_FLARE_API_KEY"); const logFlareSourceToken = env.get("LOG_FLARE_SOURCE_TOKEN"); - +enum ShutdownLevels { + none = 0, + graceperiod = 1, + shutdown = 2, +} const logLevels = ["info", "error", "debug", "fatal", "warn", "trace"]; const supabaseMaxRows = parseInt(env.require("SUPABASE_MAX_ROWS"), 10); if (isNaN(supabaseMaxRows)) { @@ -29,6 +35,20 @@ if (!logLevels.includes(logLevel)) { ); } +if ( + ![ + ShutdownLevels.none, + ShutdownLevels.graceperiod, + ShutdownLevels.shutdown, + ].includes(shutdownLevel) +) { + throw new Error( + `Environment variable SHUTDOWN_LEVEL must be one of ${Object.keys( + ShutdownLevels + ).join(", ")}` + ); +} + export { databaseUrl, issuer, @@ -42,4 +62,6 @@ export { supabaseMaxRows, supabaseServiceRoleKey, supabaseUrl, + shutdownLevel, + ShutdownLevels, }; diff --git a/src/lib/server.ts b/src/lib/server.ts index 7abb7615..02a6e603 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -5,6 +5,7 @@ import fastify, { FastifyLoggerOptions, FastifyReply, FastifyRequest, + HTTPMethods, } from "fastify"; import fastifyBlipp from "fastify-blipp"; import fastifyJwt from "@fastify/jwt"; @@ -28,7 +29,7 @@ import http from "../integrations/http"; import { getResponseDefaultSchema } from "../common/schemas"; import pino from "pino"; import Redis from "ioredis"; -import { redisUrl, stage } from "./env"; +import { redisUrl, ShutdownLevels, stage } from "./env"; const apiVersion = config.get("apiVersion"); const mountPoint = config.get("mountPoint"); @@ -38,127 +39,220 @@ export const buildServer: (options: { supabaseServiceRoleKey: string; logger: boolean | FastifyLoggerOptions | pino.Logger; issuer: string; + shutdownLevel?: ShutdownLevels; }) => FastifyInstance = ({ jwtSecret, supabaseUrl, supabaseServiceRoleKey, logger, issuer, + shutdownLevel = 0, }) => { - const authtokensRouteOptions = { - endpoint: "authtokens", - mount: mountPoint, - apiVersion: `v${apiVersion}`, - issuer, - }; - const singinRouteOptoins = { - endpoint: "signin", - mount: mountPoint, - apiVersion: `v${apiVersion}`, - }; - const singupRouteOptoins = { - endpoint: "signup", - mount: mountPoint, - apiVersion: `v${apiVersion}`, - }; - const sensorsRouteOptions = { - endpoint: "sensors", - mount: mountPoint, - apiVersion: `v${apiVersion}`, - }; + const authtokensRouteOptions = { + endpoint: "authtokens", + mount: mountPoint, + apiVersion: `v${apiVersion}`, + issuer, + }; + const singinRouteOptoins = { + endpoint: "signin", + mount: mountPoint, + apiVersion: `v${apiVersion}`, + }; + const singupRouteOptoins = { + endpoint: "signup", + mount: mountPoint, + apiVersion: `v${apiVersion}`, + }; + const sensorsRouteOptions = { + endpoint: "sensors", + mount: mountPoint, + apiVersion: `v${apiVersion}`, + }; - const server = fastify({ - logger, - ignoreTrailingSlash: true, - exposeHeadRoutes: true, - // TODO: [BA-71] Update ajvError to latests once we are in fastify 4 - ajv: { - plugins: [ajvError /*,[ajvFormats, { formats: ["iso-date-time"] }]*/], - customOptions: { - // jsonPointers: true, - allErrors: true, - removeAdditional: false, - }, + const server = fastify({ + logger, + ignoreTrailingSlash: true, + exposeHeadRoutes: true, + // TODO: [BA-71] Update ajvError to latests once we are in fastify 4 + ajv: { + plugins: [ajvError /*,[ajvFormats, { formats: ["iso-date-time"] }]*/], + customOptions: { + // jsonPointers: true, + allErrors: true, + removeAdditional: false, }, - }); - let redis: Redis.Redis | undefined; + }, + }); + let redis: Redis.Redis | undefined; - // eslint-disable-next-line prefer-const - redis = new Redis(redisUrl, { - connectionName: `stadtpuls-api-${stage}`, + // eslint-disable-next-line prefer-const + redis = new Redis(redisUrl, { + connectionName: `stadtpuls-api-${stage}`, - autoResubscribe: false, - // lazyConnect: true, - connectTimeout: 500, - maxRetriesPerRequest: 0, - enableOfflineQueue: false, - }); - redis.on("error", (err) => { - server.log.error(err); - }); + autoResubscribe: false, + // lazyConnect: true, + connectTimeout: 500, + maxRetriesPerRequest: 0, + enableOfflineQueue: false, + }); + redis.on("error", (err) => { + server.log.error(err); + }); - server.register(fastifyBlipp); + server.register(fastifyBlipp); - server.register(fastifyRateLimit, { - allowList: ["127.0.0.1"], - redis, - }); - server.register(fastifyHelmet); - server.register(fastifyCors, { - origin: "*", - methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], - }); - server.register(fastifySensible); - server.register(fastifyAuth); - // server.register(fastifyPostgres, { - // connectionString: databaseUrl, - // }); - server.register(fastifyJwt, { - secret: jwtSecret, - }); + server.register(fastifyRateLimit, { + allowList: ["127.0.0.1"], + redis, + }); + server.register(fastifyHelmet); + server.register(fastifyCors, { + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + }); + server.register(fastifySensible); + server.register(fastifyAuth); + // server.register(fastifyPostgres, { + // connectionString: databaseUrl, + // }); + server.register(fastifyJwt, { + secret: jwtSecret, + }); - server.register(fastifySupabase, { - supabaseUrl, - supabaseServiceRoleKey, - }); - server.decorate( - "verifyJWT", - async (request: FastifyRequest, _reply: FastifyReply) => { - await request.jwtVerify(); - } - ); + server.register(fastifySupabase, { + supabaseUrl, + supabaseServiceRoleKey, + }); + server.decorate( + "verifyJWT", + async (request: FastifyRequest, _reply: FastifyReply) => { + await request.jwtVerify(); + } + ); - // TODO: [STADTPULS-398] Write Schemas for all responses - // https://www.fastify.io/docs/latest/Validation-and-Serialization/#adding-a-shared-schema - server.addSchema(getResponseDefaultSchema); + // TODO: [STADTPULS-398] Write Schemas for all responses + // https://www.fastify.io/docs/latest/Validation-and-Serialization/#adding-a-shared-schema + server.addSchema(getResponseDefaultSchema); - server.register(signin, singinRouteOptoins); + // disable singup when we go into grace period + if (shutdownLevel === ShutdownLevels.none) { server.register(signup, singupRouteOptoins); + } else { + server.route({ + method: "POST", + url: `/${singupRouteOptoins.mount}/${singupRouteOptoins.apiVersion}/${singupRouteOptoins.endpoint}`, + handler: (_request, reply) => { + reply.code(404).send({ + error: "Not Found", + statusCode: 404, + message: + "Signup is disabled - we are in shutdown mode. Please see https://stadtpuls.com for further information", + }); + }, + }); + } + // Disable all actionable routes when we are in shutdown + // This means we can also pause the project on render.com + if (shutdownLevel !== ShutdownLevels.shutdown) { + server.register(signin, singinRouteOptoins); server.register(routesAuth, authtokensRouteOptions); server.register(sensorsRecordsRoutes, sensorsRouteOptions); server.register(ttn); server.register(http); + } else { + server.route({ + url: `/${singinRouteOptoins.mount}/${singinRouteOptoins.apiVersion}/${singinRouteOptoins.endpoint}`, + method: "POST", - [ - "/", - `/${authtokensRouteOptions.mount}`, - `/${authtokensRouteOptions.mount}/${authtokensRouteOptions.apiVersion}`, - ].forEach((path) => { - server.route({ + handler: (_request, reply) => { + reply.code(404).send({ + error: "Not Found", + statusCode: 404, + message: + "Signin is disabled - we are in shutdown mode. Please see https://stadtpuls.com for further information", + }); + }, + }); + server.route({ + url: `/${authtokensRouteOptions.mount}/${authtokensRouteOptions.apiVersion}/${authtokensRouteOptions.endpoint}`, + method: ["POST", "GET", "DELETE", "PUT"], + handler: (_request, reply) => { + reply.code(404).send({ + error: "Not Found", + statusCode: 404, + message: + "Authtoken retrieval is disabled - we are in shutdown mode. Please see https://stadtpuls.com for further information", + }); + }, + }); + const apiVersion = config.get("apiVersion"); + const mountPoint = config.get("mountPoint"); + const sensorRoutes: { + url: string; + method: HTTPMethods[]; + message?: string; + }[] = [ + { + url: `/${mountPoint}/v${apiVersion}/integrations/ttn/v3`, + method: ["POST"], + message: + "TTN integration is disabled - we are in shutdown mode. Please see https://stadtpuls.com for further information", + }, + { + method: ["GET", "HEAD", "POST"], + url: `/${mountPoint}/v${apiVersion}/sensors/:sensorId/records`, + }, + { + method: ["GET", "HEAD"], + url: `/${mountPoint}/v${apiVersion}/sensors/:sensorId/records/recordId`, + }, + { method: ["GET", "HEAD"], - url: path, - logLevel: process.env.NODE_ENV === "production" ? "warn" : "info", - exposeHeadRoute: true, - handler: async (request, reply) => { - reply.send({ - comment: "healthcheck", - method: `${request.method}`, - url: `${request.url}`, + url: `/${mountPoint}/v${apiVersion}/sensors/:sensorId`, + }, + { + method: ["GET", "HEAD"], + url: `/${mountPoint}/v${apiVersion}/sensors/`, + }, + ]; + sensorRoutes.forEach((item) => { + server.route({ + url: item.url, + method: item.method, + handler: (_request, reply) => { + reply.code(404).send({ + error: "Not Found", + statusCode: 404, + message: item.message + ? item.message + : "Sensors and records creation and retrieval is disabled - we are in shutdown mode. Please see https://stadtpuls.com for further information", }); }, }); }); - return server; - }; + } + + [ + "/", + `/${authtokensRouteOptions.mount}`, + `/${authtokensRouteOptions.mount}/${authtokensRouteOptions.apiVersion}`, + ].forEach((path) => { + server.route({ + method: ["GET", "HEAD"], + url: path, + logLevel: process.env.NODE_ENV === "production" ? "warn" : "info", + exposeHeadRoute: true, + handler: async (request, reply) => { + reply.send({ + comment: "healthcheck", + method: `${request.method}`, + url: `${request.url}`, + }); + }, + }); + }); + return server; +}; export default buildServer;