Add readonly mode to catalog
This change includes a new `mode` concept to the catalog backend. This gives us the handle to configure catalog in a `readonly` mode as discussed in #3348. The new `catalog.mode` field configures that catalog in either `readwrite` or `readonly`. - `readwrite` is the current mode that allows users to register components at startup through `catalog.locations` and with eg. plugin `catalog-import` - `readonly` is the new mode which, for now, disables the mutating `location` APIs Signed-off-by: Crevil <bjoern.soerensen@gmail.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
---
|
||||
'example-backend': minor
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Add `readonly` mode to catalog backend
|
||||
|
||||
This change adds a `catalog.mode` field in `app-config.yaml` that can be used to configure the catalog in readonly mode which effectively disables the possibilty of adding new components to the catalog after startup.
|
||||
|
||||
When in `readonly` mode only locations configured in `catalog.locations` are loaded and served.
|
||||
By default the mode is `readwrite` which represents the current functionality where locations can be added at run-time.
|
||||
|
||||
This change requires the config API in the router which requires a change to `createRouter`.
|
||||
|
||||
```diff
|
||||
return await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
logger: env.logger,
|
||||
+ config: env.config,
|
||||
});
|
||||
```
|
||||
@@ -45,5 +45,6 @@ export default async function createPlugin(
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
logger: env.logger,
|
||||
config: env.config,
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -16,6 +16,8 @@
|
||||
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type Mode = 'readonly' | 'readwrite';
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Configuration options for the catalog plugin.
|
||||
@@ -42,6 +44,20 @@ export interface Config {
|
||||
allow: Array<string>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Mode defines the overall behaviour mode of the catalog.
|
||||
*
|
||||
* Setting the mode to 'readwrite' you allow users to register their own
|
||||
* components. This is the default value.
|
||||
*
|
||||
* Setting the mode to 'readonly' configures catalog to only allow reads.
|
||||
* This can be used in combination with static locations to only serve
|
||||
* operator provided locations. Effectively this removes the ability to
|
||||
* register new components to a running backstage instance.
|
||||
*
|
||||
*/
|
||||
mode?: Mode;
|
||||
|
||||
/**
|
||||
* A set of static locations that the catalog shall always keep itself
|
||||
* up-to-date with. This is commonly used for large, permanent integrations
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import type { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
@@ -25,7 +26,7 @@ import { HigherOrderOperation } from '../ingestion/types';
|
||||
import { createRouter } from './router';
|
||||
import { basicEntityFilter } from './request';
|
||||
|
||||
describe('createRouter', () => {
|
||||
describe('createRouter readwrite mode', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
let locationsCatalog: jest.Mocked<LocationsCatalog>;
|
||||
let higherOrderOperation: jest.Mocked<HigherOrderOperation>;
|
||||
@@ -55,6 +56,7 @@ describe('createRouter', () => {
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger: getVoidLogger(),
|
||||
config: new ConfigReader(undefined),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
@@ -353,3 +355,155 @@ describe('createRouter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRouter readonly mode', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
let locationsCatalog: jest.Mocked<LocationsCatalog>;
|
||||
let higherOrderOperation: jest.Mocked<HigherOrderOperation>;
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
batchAddOrUpdateEntities: jest.fn(),
|
||||
};
|
||||
locationsCatalog = {
|
||||
addLocation: jest.fn(),
|
||||
removeLocation: jest.fn(),
|
||||
locations: jest.fn(),
|
||||
location: jest.fn(),
|
||||
locationHistory: jest.fn(),
|
||||
logUpdateSuccess: jest.fn(),
|
||||
logUpdateFailure: jest.fn(),
|
||||
};
|
||||
higherOrderOperation = {
|
||||
addLocation: jest.fn(),
|
||||
refreshAllLocations: jest.fn(),
|
||||
};
|
||||
const router = await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger: getVoidLogger(),
|
||||
config: new ConfigReader({
|
||||
catalog: {
|
||||
mode: 'readonly',
|
||||
},
|
||||
}),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /entities', () => {
|
||||
it('happy path: lists entities', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
|
||||
entitiesCatalog.entities.mockResolvedValueOnce({
|
||||
entities: [entities[0]],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
const response = await request(app).get('/entities');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(entities);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /entities', () => {
|
||||
it('is not allowed', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(entity);
|
||||
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
expect(response.text).toMatch(/readwrite/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /entities/by-uid/:uid', () => {
|
||||
it('is not allowed', async () => {
|
||||
const response = await request(app).delete('/entities/by-uid/apa');
|
||||
|
||||
expect(response.status).toEqual(403);
|
||||
expect(response.text).toMatch(/readwrite/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: LocationResponse[] = [
|
||||
{
|
||||
currentStatus: { timestamp: '', status: '', message: '' },
|
||||
data: { id: 'a', type: 'b', target: 'c' },
|
||||
},
|
||||
];
|
||||
locationsCatalog.locations.mockResolvedValueOnce(locations);
|
||||
|
||||
const response = await request(app).get('/locations');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(locations);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /locations', () => {
|
||||
it('is not allowed', async () => {
|
||||
const spec: LocationSpec = {
|
||||
type: 'b',
|
||||
target: 'c',
|
||||
};
|
||||
|
||||
const response = await request(app).post('/locations').send(spec);
|
||||
|
||||
expect(higherOrderOperation.addLocation).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
expect(response.text).toMatch(/readwrite/);
|
||||
});
|
||||
|
||||
it('supports dry run', async () => {
|
||||
const spec: LocationSpec = {
|
||||
type: 'b',
|
||||
target: 'c',
|
||||
};
|
||||
|
||||
higherOrderOperation.addLocation.mockResolvedValue({
|
||||
location: { id: 'a', ...spec },
|
||||
entities: [],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/locations?dryRun=true')
|
||||
.send(spec);
|
||||
|
||||
expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1);
|
||||
expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec, {
|
||||
dryRun: true,
|
||||
});
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
location: { id: 'a', ...spec },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
analyzeLocationSchema,
|
||||
locationSpecSchema,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
@@ -27,13 +28,18 @@ import { Logger } from 'winston';
|
||||
import yn from 'yn';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { HigherOrderOperation, LocationAnalyzer } from '../ingestion/types';
|
||||
import { Mode } from '../../config';
|
||||
import {
|
||||
basicEntityFilter,
|
||||
parseEntityFilterParams,
|
||||
parseEntityPaginationParams,
|
||||
parseEntityTransformParams,
|
||||
} from './request';
|
||||
import { requireRequestBody, validateRequestBody } from './util';
|
||||
import {
|
||||
requireReadWriteMode,
|
||||
requireRequestBody,
|
||||
validateRequestBody,
|
||||
} from './util';
|
||||
|
||||
export interface RouterOptions {
|
||||
entitiesCatalog?: EntitiesCatalog;
|
||||
@@ -41,6 +47,7 @@ export interface RouterOptions {
|
||||
higherOrderOperation?: HigherOrderOperation;
|
||||
locationAnalyzer?: LocationAnalyzer;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
@@ -51,11 +58,17 @@ export async function createRouter(
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
locationAnalyzer,
|
||||
config,
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
const mode: Mode =
|
||||
(config.getOptionalString('catalog.mode') as Mode) || 'readwrite';
|
||||
logger.info(`Catalog is running in ${mode} mode`);
|
||||
|
||||
if (entitiesCatalog) {
|
||||
router
|
||||
.get('/entities', async (req, res) => {
|
||||
@@ -87,6 +100,8 @@ export async function createRouter(
|
||||
* It stays around in the service for the time being, but may be
|
||||
* removed or change semantics at any time without prior notice.
|
||||
*/
|
||||
requireReadWriteMode(mode);
|
||||
|
||||
const body = await requireRequestBody(req);
|
||||
const [result] = await entitiesCatalog.batchAddOrUpdateEntities([
|
||||
{ entity: body as Entity, relations: [] },
|
||||
@@ -107,6 +122,8 @@ export async function createRouter(
|
||||
res.status(200).json(entities[0]);
|
||||
})
|
||||
.delete('/entities/by-uid/:uid', async (req, res) => {
|
||||
requireReadWriteMode(mode);
|
||||
|
||||
const { uid } = req.params;
|
||||
await entitiesCatalog.removeEntityByUid(uid);
|
||||
res.status(204).end();
|
||||
@@ -133,6 +150,11 @@ export async function createRouter(
|
||||
router.post('/locations', async (req, res) => {
|
||||
const input = await validateRequestBody(req, locationSpecSchema);
|
||||
const dryRun = yn(req.query.dryRun, { default: false });
|
||||
|
||||
// when in dryRun addLocation is effectively a read operation so when in
|
||||
// dryRun we override mode to readwrite to allow the operation
|
||||
requireReadWriteMode(dryRun ? 'readwrite' : mode);
|
||||
|
||||
const output = await higherOrderOperation.addLocation(input, { dryRun });
|
||||
res.status(201).json(output);
|
||||
});
|
||||
@@ -155,6 +177,8 @@ export async function createRouter(
|
||||
res.status(200).json(output);
|
||||
})
|
||||
.delete('/locations/:id', async (req, res) => {
|
||||
requireReadWriteMode(mode);
|
||||
|
||||
const { id } = req.params;
|
||||
await locationsCatalog.removeLocation(id);
|
||||
res.status(204).end();
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function startStandaloneServer(
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger,
|
||||
config,
|
||||
});
|
||||
const service = createServiceBuilder(module)
|
||||
.enableCors({ origin: 'http://localhost:3000' })
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { InputError, NotAllowedError } from '@backstage/errors';
|
||||
import { Request } from 'express';
|
||||
import lodash from 'lodash';
|
||||
import yup from 'yup';
|
||||
import { Mode } from '../../config';
|
||||
|
||||
export async function requireRequestBody(req: Request): Promise<unknown> {
|
||||
const contentType = req.header('content-type');
|
||||
@@ -54,3 +55,9 @@ export async function validateRequestBody<T>(
|
||||
|
||||
return (body as unknown) as T;
|
||||
}
|
||||
|
||||
export function requireReadWriteMode(mode: Mode) {
|
||||
if (mode !== 'readwrite') {
|
||||
throw new NotAllowedError('This operation requires readwrite mode');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user