From a0619e963dc71445f6e18b326ae08712cd7fe930 Mon Sep 17 00:00:00 2001 From: sjaanus Date: Wed, 21 Dec 2022 13:23:44 +0200 Subject: [PATCH] Maintenance mode for users (#2716) --- frontend/package.json | 1 + frontend/src/component/App.tsx | 13 +- .../admin/maintenance/MaintenanceToggle.tsx | 77 ++++++++++ .../admin/maintenance/MaintenanceTooltip.tsx | 15 ++ .../src/component/admin/maintenance/index.tsx | 56 +++++++ .../src/component/admin/menu/AdminMenu.tsx | 11 ++ ...{Maintenance.tsx => MaintenanceBanner.tsx} | 4 +- .../__snapshots__/routes.test.tsx.snap | 11 ++ frontend/src/component/menu/routes.ts | 10 ++ .../useMaintenanceApi/useMaintenanceApi.ts | 29 ++++ .../getters/useMaintenance/useMaintenance.ts | 36 +++++ frontend/src/interfaces/uiConfig.ts | 2 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/app.ts | 8 +- src/lib/middleware/maintenance-middleware.ts | 20 ++- src/lib/openapi/index.ts | 2 + src/lib/openapi/spec/maintenance-schema.ts | 16 ++ src/lib/openapi/spec/ui-config-schema.ts | 3 + src/lib/openapi/util/openapi-tags.ts | 4 + src/lib/routes/admin-api/config.ts | 23 ++- src/lib/routes/admin-api/index.ts | 6 + src/lib/routes/admin-api/maintenance.ts | 100 ++++++++++++ src/lib/services/index.ts | 8 + src/lib/services/maintenance-service.ts | 62 ++++++++ src/lib/types/experimental.ts | 4 + src/lib/types/services.ts | 2 + .../types/settings/maintenance-settings.ts | 5 + src/server-dev.ts | 1 - src/test/e2e/api/admin/feature.e2e.test.ts | 18 --- .../e2e/api/admin/maintenance.e2e.test.ts | 144 ++++++++++++++++++ .../__snapshots__/openapi.e2e.test.ts.snap | 61 ++++++++ 31 files changed, 714 insertions(+), 40 deletions(-) create mode 100644 frontend/src/component/admin/maintenance/MaintenanceToggle.tsx create mode 100644 frontend/src/component/admin/maintenance/MaintenanceTooltip.tsx create mode 100644 frontend/src/component/admin/maintenance/index.tsx rename frontend/src/component/maintenance/{Maintenance.tsx => MaintenanceBanner.tsx} (93%) create mode 100644 frontend/src/hooks/api/actions/useMaintenanceApi/useMaintenanceApi.ts create mode 100644 frontend/src/hooks/api/getters/useMaintenance/useMaintenance.ts create mode 100644 src/lib/openapi/spec/maintenance-schema.ts create mode 100644 src/lib/routes/admin-api/maintenance.ts create mode 100644 src/lib/services/maintenance-service.ts create mode 100644 src/lib/types/settings/maintenance-settings.ts create mode 100644 src/test/e2e/api/admin/maintenance.e2e.test.ts 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",