Merge pull request #5034 from lunarway/feature/catalog-mode

Add readonly mode to catalog-backend
This commit is contained in:
Ben Lambert
2021-03-26 15:13:56 +01:00
committed by GitHub
9 changed files with 231 additions and 3 deletions
+23
View File
@@ -0,0 +1,23 @@
---
'@backstage/plugin-catalog-backend': minor
---
Add `readonly` mode to catalog backend
This change adds a `catalog.readonly` field in `app-config.yaml` that can be used to configure the catalog in readonly mode which effectively disables the possibility 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 `readonly` is disabled 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,
});
```
+1
View File
@@ -70,6 +70,7 @@ Protobuf
Proxying
Raghunandan
Readme
readonly
rebase
Recharts
Redash
+1
View File
@@ -45,5 +45,6 @@ export default async function createPlugin(
higherOrderOperation,
locationAnalyzer,
logger: env.logger,
config: env.config,
});
}
@@ -27,5 +27,6 @@ export default async function createPlugin(env: PluginEnvironment): Promise<Rout
higherOrderOperation,
locationAnalyzer,
logger: env.logger,
config: env.config,
});
}
+14
View File
@@ -42,6 +42,20 @@ export interface Config {
allow: Array<string>;
}>;
/**
* Readonly defines whether the catalog allows writes after startup.
*
* Setting 'readonly=false' allows users to register their own components.
* This is the default value.
*
* Setting 'readonly=true' 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.
*
*/
readonly?: boolean;
/**
* 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 readonly disabled', () => {
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,157 @@ describe('createRouter', () => {
});
});
});
describe('createRouter readonly enabled', () => {
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: {
readonly: true,
},
}),
});
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(/not allowed in readonly/);
});
});
describe('DELETE /entities/by-uid/:uid', () => {
// this delete is allowed as there is no other way to remove entities
it('is allowed', async () => {
const response = await request(app).delete('/entities/by-uid/apa');
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(204);
});
});
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(/not allowed in readonly/);
});
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 },
}),
);
});
});
});
+26 -1
View File
@@ -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';
@@ -33,7 +34,11 @@ import {
parseEntityPaginationParams,
parseEntityTransformParams,
} from './request';
import { requireRequestBody, validateRequestBody } from './util';
import {
disallowReadonlyMode,
requireRequestBody,
validateRequestBody,
} from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
@@ -41,6 +46,7 @@ export interface RouterOptions {
higherOrderOperation?: HigherOrderOperation;
locationAnalyzer?: LocationAnalyzer;
logger: Logger;
config: Config;
}
export async function createRouter(
@@ -51,11 +57,19 @@ export async function createRouter(
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
config,
logger,
} = options;
const router = Router();
router.use(express.json());
const readonlyEnabled =
config.getOptionalBoolean('catalog.readonly') || false;
if (readonlyEnabled) {
logger.info('Catalog is running in readonly mode');
}
if (entitiesCatalog) {
router
.get('/entities', async (req, res) => {
@@ -87,6 +101,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.
*/
disallowReadonlyMode(readonlyEnabled);
const body = await requireRequestBody(req);
const [result] = await entitiesCatalog.batchAddOrUpdateEntities([
{ entity: body as Entity, relations: [] },
@@ -133,6 +149,13 @@ 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 we don't
// need to disallow readonly
if (!dryRun) {
disallowReadonlyMode(readonlyEnabled);
}
const output = await higherOrderOperation.addLocation(input, { dryRun });
res.status(201).json(output);
});
@@ -155,6 +178,8 @@ export async function createRouter(
res.status(200).json(output);
})
.delete('/locations/:id', async (req, res) => {
disallowReadonlyMode(readonlyEnabled);
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' })
+7 -1
View File
@@ -14,7 +14,7 @@
* 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';
@@ -54,3 +54,9 @@ export async function validateRequestBody<T>(
return (body as unknown) as T;
}
export function disallowReadonlyMode(readonly: boolean) {
if (readonly) {
throw new NotAllowedError('This operation not allowed in readonly mode');
}
}