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:
parent
1ef84da688
commit
a0619e963d
@ -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",
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
56
frontend/src/component/admin/maintenance/index.tsx
Normal file
56
frontend/src/component/admin/maintenance/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -98,6 +98,17 @@ function AdminMenu() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{flags.maintenance && (
|
||||
<Tab
|
||||
value="maintenance"
|
||||
label={
|
||||
<CenteredNavLink to="/admin/maintenance">
|
||||
Maintenance
|
||||
</CenteredNavLink>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBilling && (
|
||||
<Tab
|
||||
value="billing"
|
||||
|
@ -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;
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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());
|
||||
};
|
@ -13,6 +13,8 @@ export interface IUiConfig {
|
||||
links: ILinks[];
|
||||
disablePasswordAuth?: boolean;
|
||||
emailEnabled?: boolean;
|
||||
|
||||
maintenanceMode?: boolean;
|
||||
toast?: IProclamationToast;
|
||||
segmentValuesLimit?: number;
|
||||
strategySegmentsLimit?: number;
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
16
src/lib/openapi/spec/maintenance-schema.ts
Normal file
16
src/lib/openapi/spec/maintenance-schema.ts
Normal 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>;
|
@ -31,6 +31,9 @@ export const uiConfigSchema = {
|
||||
emailEnabled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
maintenanceMode: {
|
||||
type: 'boolean',
|
||||
},
|
||||
segmentValuesLimit: {
|
||||
type: 'number',
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
100
src/lib/routes/admin-api/maintenance.ts
Normal file
100
src/lib/routes/admin-api/maintenance.ts
Normal 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;
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
62
src/lib/services/maintenance-service.ts
Normal file
62
src/lib/services/maintenance-service.ts
Normal 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;
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
|
5
src/lib/types/settings/maintenance-settings.ts
Normal file
5
src/lib/types/settings/maintenance-settings.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const maintenanceSettingsKey = 'maintenance.mode';
|
||||
|
||||
export interface MaintenanceSettings {
|
||||
enabled: boolean;
|
||||
}
|
@ -42,7 +42,6 @@ process.nextTick(async () => {
|
||||
changeRequests: true,
|
||||
favorites: true,
|
||||
variantsPerEnvironment: true,
|
||||
maintenance: false,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
@ -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);
|
||||
});
|
||||
|
144
src/test/e2e/api/admin/maintenance.e2e.test.ts
Normal file
144
src/test/e2e/api/admin/maintenance.e2e.test.ts
Normal 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);
|
||||
});
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user