1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Maintenance mode for users (#2716)

This commit is contained in:
sjaanus 2022-12-21 13:23:44 +02:00 committed by GitHub
parent 1ef84da688
commit a0619e963d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 714 additions and 40 deletions

View File

@ -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",

View File

@ -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={
<>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.maintenance
)}
show={<Maintenance />}
condition={
Boolean(
uiConfig?.flags?.maintenance
) &&
Boolean(uiConfig?.maintenanceMode)
}
show={<MaintenanceBanner />}
/>
<div className={styles.container}>
<ToastRenderer />

View File

@ -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 (
<StyledCard>
<CardContent>
<CardTitleRow>
<b>Maintenance Mode</b>
<FormControlLabel
sx={{ fontSize: '10px' }}
control={
<Switch
onChange={updateEnabled}
value={enabled}
name="enabled"
checked={enabled}
/>
}
label={
<SwitchLabel>
{enabled ? 'Enabled' : 'Disabled'}
</SwitchLabel>
}
/>
</CardTitleRow>
<CardDescription>
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.
</CardDescription>
</CardContent>
</StyledCard>
);
};

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Alert } from '@mui/material';
export const MaintenanceTooltip = () => {
return (
<Alert severity="warning">
<p>
<b>Heads up!</b> 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.
</p>
</Alert>
);
};

View File

@ -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 (
<div>
<ConditionallyRender
condition={showAdminMenu}
show={<AdminMenu />}
/>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<MaintenancePage />}
elseShow={<AdminAlert />}
/>
</div>
);
};
const StyledBox = styled(Box)(({ theme }) => ({
display: 'grid',
gap: theme.spacing(2),
}));
const MaintenancePage = () => {
const { uiConfig, loading } = useUiConfig();
if (loading) {
return null;
}
return (
<PageContent header={<PageHeader title="Maintenance" />}>
<StyledBox>
<MaintenanceTooltip />
<MaintenanceToggle />
</StyledBox>
</PageContent>
);
};

View File

@ -98,6 +98,17 @@ function AdminMenu() {
}
/>
)}
{flags.maintenance && (
<Tab
value="maintenance"
label={
<CenteredNavLink to="/admin/maintenance">
Maintenance
</CenteredNavLink>
}
/>
)}
{isBilling && (
<Tab
value="billing"

View File

@ -19,7 +19,7 @@ const StyledDiv = styled('div')(({ theme }) => ({
whiteSpace: 'pre-wrap',
}));
const Maintenance = () => {
const MaintenanceBanner = () => {
return (
<StyledDiv>
<StyledErrorRoundedIcon />
@ -33,4 +33,4 @@ const Maintenance = () => {
);
};
export default Maintenance;
export default MaintenanceBanner;

View File

@ -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",

View File

@ -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',

View File

@ -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,
};
};

View File

@ -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());
};

View File

@ -13,6 +13,8 @@ export interface IUiConfig {
links: ILinks[];
disablePasswordAuth?: boolean;
emailEnabled?: boolean;
maintenanceMode?: boolean;
toast?: IProclamationToast;
segmentValuesLimit?: number;
strategySegmentsLimit?: number;

View File

@ -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,

View File

@ -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);

View File

@ -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<IUnleashConfig, 'getLogger' | 'flagResolver'>): any => {
const maintenanceMiddleware = (
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
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();

View File

@ -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,

View File

@ -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<typeof maintenanceSchema>;

View File

@ -31,6 +31,9 @@ export const uiConfigSchema = {
emailEnabled: {
type: 'boolean',
},
maintenanceMode: {
type: 'boolean',
},
segmentValuesLimit: {
type: 'number',
},

View File

@ -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

View File

@ -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<UiConfigSchema>,
): Promise<void> {
const [frontendSettings, simpleAuthSettings] = await Promise.all([
this.proxyService.getFrontendSettings(false),
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
]);
const [frontendSettings, simpleAuthSettings, maintenanceMode] =
await Promise.all([
this.proxyService.getFrontendSettings(false),
this.settingService.get<SimpleAuthSettings>(
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(

View File

@ -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,
);
}
}

View File

@ -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<IUnleashServices, 'maintenanceService' | 'openApiService'>,
) {
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<unknown, unknown, MaintenanceSchema>,
res: Response,
): Promise<void> {
this.verifyMaintenanceEnabled();
await this.maintenanceService.toggleMaintenanceMode(
req.body,
extractUsername(req),
);
res.status(204).end();
}
async getMaintenance(req: Request, res: Response): Promise<void> {
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;

View File

@ -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,
};
};

View File

@ -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<IUnleashStores, 'patStore' | 'eventStore'>,
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<boolean> {
return (
this.config.flagResolver.isEnabled('maintenanceMode') ||
(await this.getMaintenanceSetting()).enabled
);
}
async getMaintenanceSetting(): Promise<MaintenanceSchema> {
return (
(await this.settingService.get(maintenanceSettingsKey)) || {
enabled: false,
}
);
}
async toggleMaintenanceMode(
setting: MaintenanceSchema,
user: string,
): Promise<void> {
return this.settingService.insert(
maintenanceSettingsKey,
setting,
user,
);
}
}
module.exports = MaintenanceService;

View File

@ -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 = {

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
export const maintenanceSettingsKey = 'maintenance.mode';
export interface MaintenanceSettings {
enabled: boolean;
}

View File

@ -42,7 +42,6 @@ process.nextTick(async () => {
changeRequests: true,
favorites: true,
variantsPerEnvironment: true,
maintenance: false,
},
},
authentication: {

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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",