Skip to content
This repository has been archived by the owner on Feb 20, 2023. It is now read-only.

Commit

Permalink
feat(server): Adds control method for shutdown
Browse files Browse the repository at this point in the history
Introduce flag/env var to be able to shutdown specific routes
  • Loading branch information
ff6347 committed Dec 13, 2022
1 parent e43225a commit 9d7da9e
Show file tree
Hide file tree
Showing 3 changed files with 215 additions and 97 deletions.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +51,7 @@ const server = buildServer({
supabaseServiceRoleKey,
logger: pinoLogger,
issuer,
shutdownLevel,
});
async function main(): Promise<void> {
try {
Expand Down
24 changes: 23 additions & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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,
Expand All @@ -42,4 +62,6 @@ export {
supabaseMaxRows,
supabaseServiceRoleKey,
supabaseUrl,
shutdownLevel,
ShutdownLevels,
};
286 changes: 190 additions & 96 deletions src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fastify, {
FastifyLoggerOptions,
FastifyReply,
FastifyRequest,
HTTPMethods,
} from "fastify";
import fastifyBlipp from "fastify-blipp";
import fastifyJwt from "@fastify/jwt";
Expand All @@ -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<number>("apiVersion");
const mountPoint = config.get<string>("mountPoint");

Expand All @@ -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<number>("apiVersion");
const mountPoint = config.get<string>("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;

0 comments on commit 9d7da9e

Please sign in to comment.