Skip to content
Open
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
4 changes: 4 additions & 0 deletions platform/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
90 changes: 90 additions & 0 deletions platform/backend/src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ApiError } from "@/types";
import {
enterpriseLicenseMiddleware,
isEnterpriseOnlyRoute,
isMaintenanceModeBypassRoute,
maintenanceModeMiddleware,
} from "./middleware";

/**
Expand Down Expand Up @@ -37,6 +39,79 @@ const createTestFastify = () => {
return fastify;
};

describe.sequential("maintenanceModeMiddleware", () => {
let fastify: ReturnType<typeof createTestFastify>;
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<typeof createTestFastify>;
const originalValue = config.enterpriseFeatures.core;
Expand Down Expand Up @@ -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);
});
});
34 changes: 34 additions & 0 deletions platform/backend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
10 changes: 9 additions & 1 deletion platform/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
*
Expand Down