From 33425d5fd9ff082073117f8763b42f3d9b1072c2 Mon Sep 17 00:00:00 2001 From: Yassinbrine Date: Tue, 12 May 2026 19:51:08 +0400 Subject: [PATCH] Add deployment maintenance mode guard --- platform/backend/src/config.ts | 4 ++ platform/backend/src/middleware.test.ts | 90 +++++++++++++++++++++++++ platform/backend/src/middleware.ts | 34 ++++++++++ platform/backend/src/server.ts | 10 ++- 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/platform/backend/src/config.ts b/platform/backend/src/config.ts index 6549061c67..2f45ce76f9 100644 --- a/platform/backend/src/config.ts +++ b/platform/backend/src/config.ts @@ -910,6 +910,10 @@ const config = { isQuickstart: process.env.ARCHESTRA_QUICKSTART === "true", ngrokDomain: process.env.ARCHESTRA_NGROK_DOMAIN || "", processType: parseProcessType(process.env.ARCHESTRA_PROCESS_TYPE), + maintenanceMode: { + message: + process.env.ARCHESTRA_MAINTENANCE_MODE_MESSAGE?.trim() || undefined, + }, }; export const shouldRunWebServer = config.processType !== "worker"; diff --git a/platform/backend/src/middleware.test.ts b/platform/backend/src/middleware.test.ts index 769383bb8f..a6343f828a 100644 --- a/platform/backend/src/middleware.test.ts +++ b/platform/backend/src/middleware.test.ts @@ -5,6 +5,8 @@ import { ApiError } from "@/types"; import { enterpriseLicenseMiddleware, isEnterpriseOnlyRoute, + isMaintenanceModeBypassRoute, + maintenanceModeMiddleware, } from "./middleware"; /** @@ -37,6 +39,79 @@ const createTestFastify = () => { return fastify; }; +describe.sequential("maintenanceModeMiddleware", () => { + let fastify: ReturnType; + const originalMessage = config.maintenanceMode.message; + + const setMaintenanceModeMessage = (message: string | undefined) => { + Object.defineProperty(config.maintenanceMode, "message", { + value: message, + writable: true, + configurable: true, + }); + }; + + beforeEach(async () => { + fastify = createTestFastify(); + await fastify.register(maintenanceModeMiddleware); + + fastify.get("/api/profiles", async () => ({ profiles: [] })); + fastify.get("/health", async () => ({ status: "ok" })); + fastify.get("/ready", async () => ({ status: "ok" })); + fastify.get("/metrics", async () => "metrics"); + + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + setMaintenanceModeMessage(originalMessage); + }); + + it("allows requests when maintenance mode is disabled", async () => { + setMaintenanceModeMessage(undefined); + + const response = await fastify.inject({ + method: "GET", + url: "/api/profiles", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ profiles: [] }); + }); + + it("returns 503 when maintenance mode is enabled", async () => { + setMaintenanceModeMessage("Scheduled maintenance in progress"); + + const response = await fastify.inject({ + method: "GET", + url: "/api/profiles", + }); + + expect(response.statusCode).toBe(503); + expect(JSON.parse(response.payload)).toEqual({ + error: { + message: "Scheduled maintenance in progress", + type: "api_service_unavailable", + }, + }); + }); + + it("allows probe and metrics routes during maintenance mode", async () => { + setMaintenanceModeMessage("Scheduled maintenance in progress"); + + for (const url of [ + "/health", + "/ready", + "/metrics", + "/metrics?format=openmetrics", + ]) { + const response = await fastify.inject({ method: "GET", url }); + expect(response.statusCode).toBe(200); + } + }); +}); + describe.sequential("enterpriseLicenseMiddleware", () => { let fastify: ReturnType; const originalValue = config.enterpriseFeatures.core; @@ -350,3 +425,18 @@ describe("isEnterpriseOnlyRoute", () => { expect(isEnterpriseOnlyRoute("/health")).toBe(false); }); }); + +describe("isMaintenanceModeBypassRoute", () => { + it("returns true for health, readiness, and metrics routes", () => { + expect(isMaintenanceModeBypassRoute("/health")).toBe(true); + expect(isMaintenanceModeBypassRoute("/ready")).toBe(true); + expect(isMaintenanceModeBypassRoute("/api/health")).toBe(true); + expect(isMaintenanceModeBypassRoute("/metrics?format=openmetrics")).toBe( + true, + ); + }); + + it("returns false for regular API routes", () => { + expect(isMaintenanceModeBypassRoute("/api/profiles")).toBe(false); + }); +}); diff --git a/platform/backend/src/middleware.ts b/platform/backend/src/middleware.ts index 84913c0ce7..18150a0ede 100644 --- a/platform/backend/src/middleware.ts +++ b/platform/backend/src/middleware.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from "fastify"; import fp from "fastify-plugin"; import config from "@/config"; import { IDENTITY_PROVIDERS_API_PREFIX } from "@/constants"; +import { HEALTH_PATH, METRICS_PATH, READY_PATH } from "@/routes/route-paths"; import { ApiError } from "@/types"; // Pattern to match team external groups routes: /api/teams/:id/external-groups @@ -63,3 +64,36 @@ const enterpriseLicenseMiddlewarePlugin: FastifyPluginAsync = async ( export const enterpriseLicenseMiddleware = fp( enterpriseLicenseMiddlewarePlugin, ); + +const API_HEALTH_PATH = "/api/health"; + +/** @public - exported for testability */ +export function isMaintenanceModeBypassRoute(url: string): boolean { + const pathname = url.split("?")[0]; + return ( + pathname === HEALTH_PATH || + pathname === READY_PATH || + pathname === API_HEALTH_PATH || + pathname === METRICS_PATH + ); +} + +const maintenanceModeMiddlewarePlugin: FastifyPluginAsync = async ( + fastify, +) => { + fastify.addHook("onRequest", async (request, reply) => { + const message = config.maintenanceMode.message; + if (!message || isMaintenanceModeBypassRoute(request.url)) { + return; + } + + return reply.status(503).send({ + error: { + message, + type: "api_service_unavailable", + }, + }); + }); +}; + +export const maintenanceModeMiddleware = fp(maintenanceModeMiddlewarePlugin); diff --git a/platform/backend/src/server.ts b/platform/backend/src/server.ts index 207f55e29c..96a48517e3 100644 --- a/platform/backend/src/server.ts +++ b/platform/backend/src/server.ts @@ -56,7 +56,10 @@ import { initializeDatabase, isDatabaseHealthy } from "@/database"; import { seedRequiredStartingData } from "@/database/seed"; import { McpServerRuntimeManager } from "@/k8s/mcp-server-runtime"; import logger from "@/logging"; -import { enterpriseLicenseMiddleware } from "@/middleware"; +import { + enterpriseLicenseMiddleware, + maintenanceModeMiddleware, +} from "@/middleware"; import OrganizationModel from "@/models/organization"; import { initializeObservabilityMetrics } from "@/observability"; import { enrichOpenApiWithRbac } from "@/openapi/enrich-openapi-with-rbac"; @@ -735,6 +738,11 @@ const startWebServer = async () => { Sentry.setupFastifyErrorHandler(fastify); } + /** + * Maintenance mode should short-circuit requests before auth and routes. + */ + fastify.register(maintenanceModeMiddleware); + /** * The auth plugin is responsible for authentication and authorization checks *