diff --git a/frontend/package.json b/frontend/package.json
index e5e42872a1..59b4712f74 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"start:enterprise": "UNLEASH_API=https://unleash4.herokuapp.com yarn run start",
"start:demo": "UNLEASH_BASE_PATH=/demo/ yarn start",
"test": "tsc && vitest run",
+ "test:snapshot": "yarn test -u",
"test:watch": "vitest watch",
"fmt": "prettier src --write --loglevel warn",
"fmt:check": "prettier src --check",
diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx
index 993faa9c12..981d998922 100644
--- a/frontend/src/component/App.tsx
+++ b/frontend/src/component/App.tsx
@@ -19,7 +19,7 @@ import { useStyles } from './App.styles';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useProjects from '../hooks/api/getters/useProjects/useProjects';
import { useLastViewedProject } from '../hooks/useLastViewedProject';
-import Maintenance from './maintenance/Maintenance';
+import MaintenanceBanner from './maintenance/MaintenanceBanner';
const InitialRedirect = () => {
const { lastViewed } = useLastViewedProject();
@@ -75,10 +75,13 @@ export const App = () => {
elseShow={
<>
}
+ condition={
+ Boolean(
+ uiConfig?.flags?.maintenance
+ ) &&
+ Boolean(uiConfig?.maintenanceMode)
+ }
+ show={}
/>
diff --git a/frontend/src/component/admin/maintenance/MaintenanceToggle.tsx b/frontend/src/component/admin/maintenance/MaintenanceToggle.tsx
new file mode 100644
index 0000000000..c5726d88d1
--- /dev/null
+++ b/frontend/src/component/admin/maintenance/MaintenanceToggle.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import {
+ Box,
+ Card,
+ CardContent,
+ FormControlLabel,
+ styled,
+ Switch,
+ Typography,
+} from '@mui/material';
+import { useMaintenance } from 'hooks/api/getters/useMaintenance/useMaintenance';
+import { useMaintenanceApi } from 'hooks/api/actions/useMaintenanceApi/useMaintenanceApi';
+
+const StyledCard = styled(Card)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ padding: theme.spacing(2.5),
+ border: `1px solid ${theme.palette.dividerAlternative}`,
+ borderRadius: theme.shape.borderRadiusLarge,
+ boxShadow: theme.boxShadows.card,
+ fontSize: theme.fontSizes.smallBody,
+}));
+
+const CardTitleRow = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+}));
+
+const CardDescription = styled(Box)(({ theme }) => ({
+ color: theme.palette.text.secondary,
+}));
+
+const SwitchLabel = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+}));
+
+export const MaintenanceToggle = () => {
+ const { enabled, refetchMaintenance } = useMaintenance();
+ const { toggleMaintenance } = useMaintenanceApi();
+ const updateEnabled = async () => {
+ await toggleMaintenance({ enabled: !enabled });
+ refetchMaintenance();
+ };
+
+ return (
+
+
+
+ Maintenance Mode
+
+ }
+ label={
+
+ {enabled ? 'Enabled' : 'Disabled'}
+
+ }
+ />
+
+
+ Maintenance Mode is useful when you want to freeze your
+ system so nobody can do any changes during this time. When
+ enabled it will show a banner at the top of the applications
+ and only an admin can enable it or disable it.
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/maintenance/MaintenanceTooltip.tsx b/frontend/src/component/admin/maintenance/MaintenanceTooltip.tsx
new file mode 100644
index 0000000000..c5455731a6
--- /dev/null
+++ b/frontend/src/component/admin/maintenance/MaintenanceTooltip.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Alert } from '@mui/material';
+
+export const MaintenanceTooltip = () => {
+ return (
+
+
+ Heads up! If you enable maintenance mode, edit access in
+ the entire system will be disabled for all the users (admins,
+ editors, custom roles, etc). During this time nobody will be
+ able to do changes or to make new configurations.
+
+
+ );
+};
diff --git a/frontend/src/component/admin/maintenance/index.tsx b/frontend/src/component/admin/maintenance/index.tsx
new file mode 100644
index 0000000000..b65ad7cdaa
--- /dev/null
+++ b/frontend/src/component/admin/maintenance/index.tsx
@@ -0,0 +1,56 @@
+import { useLocation } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import AdminMenu from '../menu/AdminMenu';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import AccessContext from 'contexts/AccessContext';
+import React, { useContext } from 'react';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { Box, CardContent, styled } from '@mui/material';
+import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
+import { CorsForm } from 'component/admin/cors/CorsForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { MaintenanceTooltip } from './MaintenanceTooltip';
+import { MaintenanceToggle } from './MaintenanceToggle';
+
+export const MaintenanceAdmin = () => {
+ const { pathname } = useLocation();
+ const showAdminMenu = pathname.includes('/admin/');
+ const { hasAccess } = useContext(AccessContext);
+
+ return (
+
+
}
+ />
+
}
+ elseShow={
}
+ />
+
+ );
+};
+
+const StyledBox = styled(Box)(({ theme }) => ({
+ display: 'grid',
+ gap: theme.spacing(2),
+}));
+const MaintenancePage = () => {
+ const { uiConfig, loading } = useUiConfig();
+
+ if (loading) {
+ return null;
+ }
+
+ return (
+
}>
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx
index efbc1b95a6..9c505ed38a 100644
--- a/frontend/src/component/admin/menu/AdminMenu.tsx
+++ b/frontend/src/component/admin/menu/AdminMenu.tsx
@@ -98,6 +98,17 @@ function AdminMenu() {
}
/>
)}
+ {flags.maintenance && (
+
+ Maintenance
+
+ }
+ />
+ )}
+
{isBilling && (
({
whiteSpace: 'pre-wrap',
}));
-const Maintenance = () => {
+const MaintenanceBanner = () => {
return (
@@ -33,4 +33,4 @@ const Maintenance = () => {
);
};
-export default Maintenance;
+export default MaintenanceBanner;
diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
index db394a42c7..9e98cc09e7 100644
--- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
+++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
@@ -476,6 +476,17 @@ exports[`returns all baseRoutes 1`] = `
"title": "Network",
"type": "protected",
},
+ {
+ "component": [Function],
+ "flag": "maintenance",
+ "menu": {
+ "adminSettings": true,
+ },
+ "parent": "/admin",
+ "path": "/admin/maintenance",
+ "title": "Maintenance",
+ "type": "protected",
+ },
{
"component": [Function],
"flag": "embedProxyFrontend",
diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts
index 4b7d7cb04c..4c1003c560 100644
--- a/frontend/src/component/menu/routes.ts
+++ b/frontend/src/component/menu/routes.ts
@@ -61,6 +61,7 @@ import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
import { Profile } from 'component/user/Profile/Profile';
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
import { Network } from 'component/admin/network/Network';
+import { MaintenanceAdmin } from '../admin/maintenance';
export const routes: IRoute[] = [
// Splash
@@ -519,6 +520,15 @@ export const routes: IRoute[] = [
menu: { adminSettings: true },
flag: 'networkView',
},
+ {
+ path: '/admin/maintenance',
+ parent: '/admin',
+ title: 'Maintenance',
+ component: MaintenanceAdmin,
+ type: 'protected',
+ menu: { adminSettings: true },
+ flag: 'maintenance',
+ },
{
path: '/admin/cors',
parent: '/admin',
diff --git a/frontend/src/hooks/api/actions/useMaintenanceApi/useMaintenanceApi.ts b/frontend/src/hooks/api/actions/useMaintenanceApi/useMaintenanceApi.ts
new file mode 100644
index 0000000000..ba31a25216
--- /dev/null
+++ b/frontend/src/hooks/api/actions/useMaintenanceApi/useMaintenanceApi.ts
@@ -0,0 +1,29 @@
+import useAPI from '../useApi/useApi';
+
+export interface IMaintenancePayload {
+ enabled: boolean;
+}
+
+export const useMaintenanceApi = () => {
+ const { makeRequest, createRequest, errors, loading } = useAPI({
+ propagateErrors: true,
+ });
+
+ const toggleMaintenance = async (payload: IMaintenancePayload) => {
+ const path = `api/admin/maintenance`;
+ const req = createRequest(path, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ try {
+ await makeRequest(req.caller, req.id);
+ } catch (e) {
+ throw e;
+ }
+ };
+ return {
+ toggleMaintenance,
+ errors,
+ loading,
+ };
+};
diff --git a/frontend/src/hooks/api/getters/useMaintenance/useMaintenance.ts b/frontend/src/hooks/api/getters/useMaintenance/useMaintenance.ts
new file mode 100644
index 0000000000..5282910d89
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useMaintenance/useMaintenance.ts
@@ -0,0 +1,36 @@
+import useSWR from 'swr';
+import { useMemo } from 'react';
+import { formatApiPath } from 'utils/formatPath';
+import handleErrorResponses from '../httpErrorResponseHandler';
+import { IMaintenancePayload } from 'hooks/api/actions/useMaintenanceApi/useMaintenanceApi';
+
+export interface IUseMaintenance extends IMaintenancePayload {
+ enabled: boolean;
+ refetchMaintenance: () => void;
+ loading: boolean;
+ status?: number;
+ error?: Error;
+}
+
+export const useMaintenance = (): IUseMaintenance => {
+ const { data, error, mutate } = useSWR(
+ formatApiPath(`api/admin/maintenance`),
+ fetcher
+ );
+
+ return useMemo(
+ () => ({
+ enabled: Boolean(data?.enabled),
+ loading: !error && !data,
+ refetchMaintenance: mutate,
+ error,
+ }),
+ [data, error, mutate]
+ );
+};
+
+const fetcher = (path: string) => {
+ return fetch(path)
+ .then(handleErrorResponses('Maintenance'))
+ .then(res => res.json());
+};
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 4592776ade..558dbeab57 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -13,6 +13,8 @@ export interface IUiConfig {
links: ILinks[];
disablePasswordAuth?: boolean;
emailEnabled?: boolean;
+
+ maintenanceMode?: boolean;
toast?: IProclamationToast;
segmentValuesLimit?: number;
strategySegmentsLimit?: number;
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index 601e7287fa..4540c3dd2f 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -76,6 +76,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": true,
"favorites": false,
"maintenance": false,
+ "maintenanceMode": false,
"networkView": false,
"proxyReturnAllToggles": false,
"responseTimeWithAppName": false,
@@ -93,6 +94,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": true,
"favorites": false,
"maintenance": false,
+ "maintenanceMode": false,
"networkView": false,
"proxyReturnAllToggles": false,
"responseTimeWithAppName": false,
diff --git a/src/lib/app.ts b/src/lib/app.ts
index aab8048495..1e0226d03b 100644
--- a/src/lib/app.ts
+++ b/src/lib/app.ts
@@ -139,7 +139,13 @@ export default async function getApp(
rbacMiddleware(config, stores, services.accessService),
);
- app.use('/api/admin', maintenanceMiddleware(config));
+ app.use(
+ `${baseUriPath}/api/admin`,
+ conditionalMiddleware(
+ () => config.flagResolver.isEnabled('maintenance'),
+ maintenanceMiddleware(config, services.maintenanceService),
+ ),
+ );
if (typeof config.preRouterHook === 'function') {
config.preRouterHook(app, config, services, stores, db);
diff --git a/src/lib/middleware/maintenance-middleware.ts b/src/lib/middleware/maintenance-middleware.ts
index 595aee9bbb..ad03618d21 100644
--- a/src/lib/middleware/maintenance-middleware.ts
+++ b/src/lib/middleware/maintenance-middleware.ts
@@ -1,16 +1,22 @@
import { IUnleashConfig } from '../types';
-import { Request } from 'express';
+import MaintenanceService from '../services/maintenance-service';
+import { IAuthRequest } from '../routes/unleash-types';
-const maintenanceMiddleware = ({
- getLogger,
- flagResolver,
-}: Pick): any => {
+const maintenanceMiddleware = (
+ { getLogger }: Pick,
+ maintenanceService: MaintenanceService,
+): any => {
const logger = getLogger('/middleware/maintenance-middleware.ts');
logger.debug('Enabling Maintenance middleware');
- return async (req: Request, res, next) => {
+ return async (req: IAuthRequest, res, next) => {
+ const isProtectedPath = !req.path.includes('/maintenance');
const writeMethod = ['POST', 'PUT', 'DELETE'].includes(req.method);
- if (writeMethod && flagResolver.isEnabled('maintenance')) {
+ if (
+ isProtectedPath &&
+ writeMethod &&
+ (await maintenanceService.isMaintenanceMode())
+ ) {
res.status(503).send({});
} else {
next();
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index 05b69c5cd8..8b635cc3de 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -129,6 +129,7 @@ import { mapValues, omitKeys } from '../util';
import { openApiTags } from './util';
import { URL } from 'url';
import apiVersion from '../util/version';
+import { maintenanceSchema } from './spec/maintenance-schema';
// All schemas in `openapi/spec` should be listed here.
export const schemas = {
@@ -189,6 +190,7 @@ export const schemas = {
instanceAdminStatsSchema,
legalValueSchema,
loginSchema,
+ maintenanceSchema,
meSchema,
nameSchema,
overrideSchema,
diff --git a/src/lib/openapi/spec/maintenance-schema.ts b/src/lib/openapi/spec/maintenance-schema.ts
new file mode 100644
index 0000000000..69f066acfd
--- /dev/null
+++ b/src/lib/openapi/spec/maintenance-schema.ts
@@ -0,0 +1,16 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const maintenanceSchema = {
+ $id: '#/components/schemas/maintenanceSchema',
+ type: 'object',
+ additionalProperties: false,
+ required: ['enabled'],
+ properties: {
+ enabled: {
+ type: 'boolean',
+ },
+ },
+ components: {},
+} as const;
+
+export type MaintenanceSchema = FromSchema;
diff --git a/src/lib/openapi/spec/ui-config-schema.ts b/src/lib/openapi/spec/ui-config-schema.ts
index fa7dc3e163..8ece7660af 100644
--- a/src/lib/openapi/spec/ui-config-schema.ts
+++ b/src/lib/openapi/spec/ui-config-schema.ts
@@ -31,6 +31,9 @@ export const uiConfigSchema = {
emailEnabled: {
type: 'boolean',
},
+ maintenanceMode: {
+ type: 'boolean',
+ },
segmentValuesLimit: {
type: 'number',
},
diff --git a/src/lib/openapi/util/openapi-tags.ts b/src/lib/openapi/util/openapi-tags.ts
index a3a639f371..06bb6aea6e 100644
--- a/src/lib/openapi/util/openapi-tags.ts
+++ b/src/lib/openapi/util/openapi-tags.ts
@@ -96,6 +96,10 @@ const OPENAPI_TAGS = [
'Experimental endpoints that may change or disappear at any time.',
},
{ name: 'Edge', description: 'Endpoints related to Unleash on the Edge.' },
+ {
+ name: 'Maintenance',
+ description: 'Enable/disable the maintenance mode of Unleash.',
+ },
] as const;
// make the export mutable, so it can be used in a schema
diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts
index cf38d9098c..a398b8133a 100644
--- a/src/lib/routes/admin-api/config.ts
+++ b/src/lib/routes/admin-api/config.ts
@@ -4,8 +4,8 @@ import { IUnleashServices } from '../../types/services';
import { IAuthType, IUnleashConfig } from '../../types/option';
import version from '../../util/version';
import Controller from '../controller';
-import VersionService from '../../services/version-service';
-import SettingService from '../../services/setting-service';
+import VersionService from 'lib/services/version-service';
+import SettingService from 'lib/services/setting-service';
import {
simpleAuthSettingsKey,
SimpleAuthSettings,
@@ -25,6 +25,7 @@ import NotFoundError from '../../error/notfound-error';
import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { ProxyService } from 'lib/services';
+import MaintenanceService from 'lib/services/maintenance-service';
class ConfigController extends Controller {
private versionService: VersionService;
@@ -35,6 +36,8 @@ class ConfigController extends Controller {
private emailService: EmailService;
+ private maintenanceService: MaintenanceService;
+
private readonly openApiService: OpenApiService;
constructor(
@@ -45,6 +48,7 @@ class ConfigController extends Controller {
emailService,
openApiService,
proxyService,
+ maintenanceService,
}: Pick<
IUnleashServices,
| 'versionService'
@@ -52,6 +56,7 @@ class ConfigController extends Controller {
| 'emailService'
| 'openApiService'
| 'proxyService'
+ | 'maintenanceService'
>,
) {
super(config);
@@ -60,6 +65,7 @@ class ConfigController extends Controller {
this.emailService = emailService;
this.openApiService = openApiService;
this.proxyService = proxyService;
+ this.maintenanceService = maintenanceService;
this.route({
method: 'get',
@@ -97,10 +103,14 @@ class ConfigController extends Controller {
req: AuthedRequest,
res: Response,
): Promise {
- const [frontendSettings, simpleAuthSettings] = await Promise.all([
- this.proxyService.getFrontendSettings(false),
- this.settingService.get(simpleAuthSettingsKey),
- ]);
+ const [frontendSettings, simpleAuthSettings, maintenanceMode] =
+ await Promise.all([
+ this.proxyService.getFrontendSettings(false),
+ this.settingService.get(
+ simpleAuthSettingsKey,
+ ),
+ this.maintenanceService.isMaintenanceMode(),
+ ]);
const disablePasswordAuth =
simpleAuthSettings?.disabled ||
@@ -124,6 +134,7 @@ class ConfigController extends Controller {
frontendApiOrigins: frontendSettings.frontendApiOrigins,
versionInfo: this.versionService.getVersionInfo(),
disablePasswordAuth,
+ maintenanceMode,
};
this.openApiService.respondWithValidation(
diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts
index cd9c046656..8e54a8e055 100644
--- a/src/lib/routes/admin-api/index.ts
+++ b/src/lib/routes/admin-api/index.ts
@@ -28,6 +28,7 @@ import { PublicSignupController } from './public-signup';
import InstanceAdminController from './instance-admin';
import FavoritesController from './favorites';
import { conditionalMiddleware } from '../../middleware';
+import MaintenanceController from './maintenance';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) {
@@ -124,6 +125,11 @@ class AdminApi extends Controller {
new FavoritesController(config, services).router,
),
);
+
+ this.app.use(
+ '/maintenance',
+ new MaintenanceController(config, services).router,
+ );
}
}
diff --git a/src/lib/routes/admin-api/maintenance.ts b/src/lib/routes/admin-api/maintenance.ts
new file mode 100644
index 0000000000..56ee244b28
--- /dev/null
+++ b/src/lib/routes/admin-api/maintenance.ts
@@ -0,0 +1,100 @@
+import { ADMIN, IUnleashConfig, IUnleashServices } from '../../types';
+import { Request, Response } from 'express';
+import Controller from '../controller';
+import { Logger } from '../../logger';
+import {
+ createRequestSchema,
+ createResponseSchema,
+ emptyResponse,
+} from '../../openapi';
+import { OpenApiService } from '../../services';
+import { IAuthRequest } from '../unleash-types';
+import { extractUsername } from '../../util';
+import {
+ MaintenanceSchema,
+ maintenanceSchema,
+} from '../../openapi/spec/maintenance-schema';
+import MaintenanceService from 'lib/services/maintenance-service';
+import { InvalidOperationError } from '../../error';
+
+export default class MaintenanceController extends Controller {
+ private maintenanceService: MaintenanceService;
+
+ private openApiService: OpenApiService;
+
+ private logger: Logger;
+
+ constructor(
+ config: IUnleashConfig,
+ {
+ maintenanceService,
+ openApiService,
+ }: Pick,
+ ) {
+ super(config);
+ this.maintenanceService = maintenanceService;
+ this.openApiService = openApiService;
+ this.logger = config.getLogger('routes/admin-api/maintenance');
+ this.route({
+ method: 'post',
+ path: '',
+ permission: ADMIN,
+ handler: this.toggleMaintenance,
+ middleware: [
+ this.openApiService.validPath({
+ tags: ['Maintenance'],
+ operationId: 'toggleMaintenance',
+ responses: {
+ 204: emptyResponse,
+ },
+ requestBody: createRequestSchema('maintenanceSchema'),
+ }),
+ ],
+ });
+ this.route({
+ method: 'get',
+ path: '',
+ permission: ADMIN,
+ handler: this.getMaintenance,
+ middleware: [
+ this.openApiService.validPath({
+ tags: ['Maintenance'],
+ operationId: 'getMaintenance',
+ responses: {
+ 200: createResponseSchema('maintenanceSchema'),
+ },
+ }),
+ ],
+ });
+ }
+
+ async toggleMaintenance(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ this.verifyMaintenanceEnabled();
+ await this.maintenanceService.toggleMaintenanceMode(
+ req.body,
+ extractUsername(req),
+ );
+ res.status(204).end();
+ }
+
+ async getMaintenance(req: Request, res: Response): Promise {
+ this.verifyMaintenanceEnabled();
+ const settings = await this.maintenanceService.getMaintenanceSetting();
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ maintenanceSchema.$id,
+ settings,
+ );
+ }
+
+ private verifyMaintenanceEnabled() {
+ if (!this.config.flagResolver.isEnabled('maintenance')) {
+ throw new InvalidOperationError('Maintenance is not enabled');
+ }
+ }
+}
+module.exports = MaintenanceController;
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index ddfd38201a..e77922ef4e 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -38,6 +38,7 @@ import { PublicSignupTokenService } from './public-signup-token-service';
import { LastSeenService } from './client-metrics/last-seen-service';
import { InstanceStatsService } from './instance-stats-service';
import { FavoritesService } from './favorites-service';
+import MaintenanceService from './maintenance-service';
export const createServices = (
stores: IUnleashStores,
@@ -128,6 +129,12 @@ export const createServices = (
versionService,
);
+ const maintenanceService = new MaintenanceService(
+ stores,
+ config,
+ settingService,
+ );
+
return {
accessService,
addonService,
@@ -168,6 +175,7 @@ export const createServices = (
lastSeenService,
instanceStatsService,
favoritesService,
+ maintenanceService,
};
};
diff --git a/src/lib/services/maintenance-service.ts b/src/lib/services/maintenance-service.ts
new file mode 100644
index 0000000000..07b0957aa7
--- /dev/null
+++ b/src/lib/services/maintenance-service.ts
@@ -0,0 +1,62 @@
+import { IUnleashConfig, IUnleashStores } from '../types';
+import { Logger } from '../logger';
+import { IPatStore } from '../types/stores/pat-store';
+import { IEventStore } from '../types/stores/event-store';
+import SettingService from './setting-service';
+import { maintenanceSettingsKey } from '../types/settings/maintenance-settings';
+import { MaintenanceSchema } from '../openapi/spec/maintenance-schema';
+
+export default class MaintenanceService {
+ private config: IUnleashConfig;
+
+ private logger: Logger;
+
+ private patStore: IPatStore;
+
+ private eventStore: IEventStore;
+
+ private settingService: SettingService;
+
+ constructor(
+ {
+ patStore,
+ eventStore,
+ }: Pick,
+ config: IUnleashConfig,
+ settingService: SettingService,
+ ) {
+ this.config = config;
+ this.logger = config.getLogger('services/pat-service.ts');
+ this.patStore = patStore;
+ this.eventStore = eventStore;
+ this.settingService = settingService;
+ }
+
+ async isMaintenanceMode(): Promise {
+ return (
+ this.config.flagResolver.isEnabled('maintenanceMode') ||
+ (await this.getMaintenanceSetting()).enabled
+ );
+ }
+
+ async getMaintenanceSetting(): Promise {
+ return (
+ (await this.settingService.get(maintenanceSettingsKey)) || {
+ enabled: false,
+ }
+ );
+ }
+
+ async toggleMaintenanceMode(
+ setting: MaintenanceSchema,
+ user: string,
+ ): Promise {
+ return this.settingService.insert(
+ maintenanceSettingsKey,
+ setting,
+ user,
+ );
+ }
+}
+
+module.exports = MaintenanceService;
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index 94b49f5af7..b6c9a2f121 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -47,6 +47,10 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE,
false,
),
+ maintenanceMode: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE_MODE,
+ false,
+ ),
};
export const defaultExperimentalOptions: IExperimentalOptions = {
diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts
index 8d2115c61e..99a111504b 100644
--- a/src/lib/types/services.ts
+++ b/src/lib/types/services.ts
@@ -36,6 +36,7 @@ import { PublicSignupTokenService } from '../services/public-signup-token-servic
import { LastSeenService } from '../services/client-metrics/last-seen-service';
import { InstanceStatsService } from '../services/instance-stats-service';
import { FavoritesService } from '../services';
+import MaintenanceService from '../services/maintenance-service';
export interface IUnleashServices {
accessService: AccessService;
@@ -77,4 +78,5 @@ export interface IUnleashServices {
lastSeenService: LastSeenService;
instanceStatsService: InstanceStatsService;
favoritesService: FavoritesService;
+ maintenanceService: MaintenanceService;
}
diff --git a/src/lib/types/settings/maintenance-settings.ts b/src/lib/types/settings/maintenance-settings.ts
new file mode 100644
index 0000000000..77b8fe9d32
--- /dev/null
+++ b/src/lib/types/settings/maintenance-settings.ts
@@ -0,0 +1,5 @@
+export const maintenanceSettingsKey = 'maintenance.mode';
+
+export interface MaintenanceSettings {
+ enabled: boolean;
+}
diff --git a/src/server-dev.ts b/src/server-dev.ts
index 5116842781..1d4bbdf880 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -42,7 +42,6 @@ process.nextTick(async () => {
changeRequests: true,
favorites: true,
variantsPerEnvironment: true,
- maintenance: false,
},
},
authentication: {
diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts
index 0d2a62e1b1..c31e73fd3f 100644
--- a/src/test/e2e/api/admin/feature.e2e.test.ts
+++ b/src/test/e2e/api/admin/feature.e2e.test.ts
@@ -837,21 +837,3 @@ test('should have access to the get all features endpoint even if api is disable
.get('/api/admin/features')
.expect(200);
});
-
-test('should not allow creation of feature toggle in maintenance mode', async () => {
- const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
- experimental: {
- flags: {
- maintenance: true,
- },
- },
- });
-
- return appWithMaintenanceMode.request
- .post('/api/admin/features')
- .send({
- name: 'maintenance-feature',
- })
- .set('Content-Type', 'application/json')
- .expect(503);
-});
diff --git a/src/test/e2e/api/admin/maintenance.e2e.test.ts b/src/test/e2e/api/admin/maintenance.e2e.test.ts
new file mode 100644
index 0000000000..ec43f98863
--- /dev/null
+++ b/src/test/e2e/api/admin/maintenance.e2e.test.ts
@@ -0,0 +1,144 @@
+import dbInit, { ITestDb } from '../../helpers/database-init';
+import { setupAppWithCustomConfig } from '../../helpers/test-helper';
+import getLogger from '../../../fixtures/no-logger';
+
+let db: ITestDb;
+
+beforeAll(async () => {
+ db = await dbInit('maintenance_api_serial', getLogger);
+});
+
+afterEach(async () => {
+ await db.stores.featureToggleStore.deleteAll();
+});
+
+afterAll(async () => {
+ await db.destroy();
+});
+
+test('should not allow to create feature toggles in maintenance mode', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: true,
+ maintenanceMode: true,
+ },
+ },
+ });
+
+ return appWithMaintenanceMode.request
+ .post('/api/admin/features')
+ .send({
+ name: 'maintenance-feature',
+ })
+ .set('Content-Type', 'application/json')
+ .expect(503);
+});
+
+test('should not go into maintenance, when maintenance feature is off', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: false,
+ maintenanceMode: true,
+ },
+ },
+ });
+
+ return appWithMaintenanceMode.request
+ .post('/api/admin/features')
+ .send({
+ name: 'maintenance-feature1',
+ })
+ .set('Content-Type', 'application/json')
+ .expect(201);
+});
+
+test('maintenance mode is off by default', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: true,
+ },
+ },
+ });
+
+ return appWithMaintenanceMode.request
+ .post('/api/admin/features')
+ .send({
+ name: 'maintenance-feature1',
+ })
+ .set('Content-Type', 'application/json')
+ .expect(201);
+});
+
+test('should go into maintenance mode, when user has set it', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: true,
+ },
+ },
+ });
+
+ await appWithMaintenanceMode.request
+ .post('/api/admin/maintenance')
+ .send({
+ enabled: true,
+ })
+ .set('Content-Type', 'application/json')
+ .expect(204);
+
+ return appWithMaintenanceMode.request
+ .post('/api/admin/features')
+ .send({
+ name: 'maintenance-feature1',
+ })
+ .set('Content-Type', 'application/json')
+ .expect(503);
+});
+
+test('should 404 on maintenance endpoint, when disabled', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: false,
+ },
+ },
+ });
+
+ await appWithMaintenanceMode.request
+ .post('/api/admin/maintenance')
+ .send({
+ enabled: true,
+ })
+ .set('Content-Type', 'application/json')
+ .expect(403);
+});
+
+test('maintenance mode flag should take precedence over maintenance mode setting', async () => {
+ const appWithMaintenanceMode = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ maintenance: true,
+ maintenanceMode: true,
+ },
+ },
+ });
+
+ await appWithMaintenanceMode.request
+ .post('/api/admin/maintenance')
+ .send({
+ enabled: false,
+ })
+ .set('Content-Type', 'application/json')
+ .expect(204);
+
+ return appWithMaintenanceMode.request
+ .post('/api/admin/features')
+ .send({
+ name: 'maintenance-feature1',
+ })
+ .set('Content-Type', 'application/json')
+ .expect(503);
+});
diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
index 181558d6e6..90876aec76 100644
--- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
+++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
@@ -1763,6 +1763,18 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
+ "maintenanceSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ },
+ },
+ "required": [
+ "enabled",
+ ],
+ "type": "object",
+ },
"meSchema": {
"additionalProperties": false,
"properties": {
@@ -3321,6 +3333,9 @@ exports[`should serve the OpenAPI spec 1`] = `
},
"type": "array",
},
+ "maintenanceMode": {
+ "type": "boolean",
+ },
"name": {
"type": "string",
},
@@ -4999,6 +5014,48 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
+ "/api/admin/maintenance": {
+ "get": {
+ "operationId": "getMaintenance",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/maintenanceSchema",
+ },
+ },
+ },
+ "description": "maintenanceSchema",
+ },
+ },
+ "tags": [
+ "Maintenance",
+ ],
+ },
+ "post": {
+ "operationId": "toggleMaintenance",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/maintenanceSchema",
+ },
+ },
+ },
+ "description": "maintenanceSchema",
+ "required": true,
+ },
+ "responses": {
+ "204": {
+ "description": "This response has no body.",
+ },
+ },
+ "tags": [
+ "Maintenance",
+ ],
+ },
+ },
"/api/admin/metrics/applications": {
"get": {
"operationId": "getApplications",
@@ -8008,6 +8065,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "Instance admin endpoints used to manage the Unleash instance itself.",
"name": "Instance Admin",
},
+ {
+ "description": "Enable/disable the maintenance mode of Unleash.",
+ "name": "Maintenance",
+ },
{
"description": "Register, read, or delete metrics recorded by Unleash.",
"name": "Metrics",