Merge pull request #5601 from backstage/mob/dryrun

Catalog/next: Add support for dryRun
This commit is contained in:
Johan Haals
2021-05-11 11:36:26 +02:00
committed by GitHub
5 changed files with 213 additions and 32 deletions
@@ -134,6 +134,7 @@ export class CatalogClient implements CatalogApi {
throw new Error(`Location wasn't added: ${target}`);
}
// TODO(jhaals): This will throw using the experimental catalog since all discovered entities are deferred.
if (entities.length === 0) {
throw new Error(
`Location was added but has no entities specified yet: ${target}`,
@@ -0,0 +1,159 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultLocationService } from './DefaultLocationService';
import { CatalogProcessingOrchestrator, LocationStore } from './types';
describe('DefaultLocationServiceTest', () => {
const orchestrator: jest.Mocked<CatalogProcessingOrchestrator> = {
process: jest.fn(),
};
const store: jest.Mocked<LocationStore> = {
deleteLocation: jest.fn(),
createLocation: jest.fn(),
listLocations: jest.fn(),
getLocation: jest.fn(),
};
beforeEach(() => jest.resetAllMocks());
const locationService = new DefaultLocationService(store, orchestrator);
describe('createLocation', () => {
it('should support dry run', async () => {
orchestrator.process.mockResolvedValueOnce({
ok: true,
state: new Map(),
completedEntity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
name: 'foo',
},
},
deferredEntities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'bar',
},
},
],
relations: [],
errors: [],
});
orchestrator.process.mockResolvedValueOnce({
ok: true,
state: new Map(),
completedEntity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'bar',
},
},
deferredEntities: [],
relations: [],
errors: [],
});
await locationService.createLocation(
{ type: 'url', target: 'https://backstage.io/catalog-info.yaml' },
true,
);
expect(orchestrator.process).toBeCalledWith({
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'url:https://backstage.io/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://backstage.io/catalog-info.yaml',
},
name: 'generated-bbad4f61e08f24e25d5c5e68e13e164f760aff06',
namespace: 'default',
},
spec: {
target: 'https://backstage.io/catalog-info.yaml',
type: 'url',
},
},
state: expect.anything(),
});
expect(orchestrator.process).toBeCalledWith({
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'bar' },
},
state: expect.anything(),
});
expect(orchestrator.process).toBeCalledTimes(2);
expect(store.createLocation).not.toBeCalled();
});
it('should create location', async () => {
const locationSpec = {
type: 'url',
target: 'https://backstage.io/catalog-info.yaml',
};
store.createLocation.mockResolvedValue({
...locationSpec,
id: '123',
});
await expect(
locationService.createLocation(locationSpec, false),
).resolves.toEqual({
entities: [],
location: {
id: '123',
target: 'https://backstage.io/catalog-info.yaml',
type: 'url',
},
});
expect(store.createLocation).toBeCalledWith({
target: 'https://backstage.io/catalog-info.yaml',
type: 'url',
});
});
});
describe('listLocations', () => {
it('should call locationStore.deleteLocation', async () => {
await locationService.listLocations();
expect(store.listLocations).toBeCalled();
});
});
describe('deleteLocation', () => {
it('should call locationStore.deleteLocation', async () => {
await locationService.deleteLocation('123');
expect(store.deleteLocation).toBeCalledWith('123');
});
});
describe('getLocation', () => {
it('should call locationStore.getLocation', async () => {
await locationService.getLocation('123');
expect(store.getLocation).toBeCalledWith('123');
});
});
});
@@ -25,6 +25,7 @@ import {
LocationStore,
CatalogProcessingOrchestrator,
} from './types';
import { locationSpecToMetadataName } from './util';
export class DefaultLocationService implements LocationService {
constructor(
@@ -37,36 +38,8 @@ export class DefaultLocationService implements LocationService {
dryRun: boolean,
): Promise<{ location: Location; entities: Entity[] }> {
if (dryRun) {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
name: `${spec.type}:${spec.target}`,
namespace: 'default',
annotations: {
[LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`,
[ORIGIN_LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`,
},
},
spec: {
location: { type: spec.type, target: spec.target },
},
};
const processed = await this.orchestrator.process({
entity,
eager: true,
state: new Map(),
});
if (processed.ok) {
return {
location: { ...spec, id: `${spec.type}:${spec.target}` },
entities: [processed.completedEntity],
};
}
throw Error('error handling not implemented.');
return this.dryRunCreateLocation(spec);
}
const location = await this.store.createLocation(spec);
return { location, entities: [] };
}
@@ -80,4 +53,53 @@ export class DefaultLocationService implements LocationService {
deleteLocation(id: string): Promise<void> {
return this.store.deleteLocation(id);
}
private async dryRunCreateLocation(
spec: LocationSpec,
): Promise<{ location: Location; entities: Entity[] }> {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
name: locationSpecToMetadataName({
type: spec.type,
target: spec.target,
}),
namespace: 'default',
annotations: {
[LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`,
[ORIGIN_LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`,
},
},
spec: {
type: spec.type,
target: spec.target,
},
};
const unprocessedEntities: Entity[] = [entity];
const entities: Entity[] = [];
const state = new Map(); // ignored
while (unprocessedEntities.length) {
const currentEntity = unprocessedEntities.pop();
if (!currentEntity) {
continue;
}
const processed = await this.orchestrator.process({
entity: currentEntity,
state,
});
if (processed.ok) {
unprocessedEntities.push(...processed.deferredEntities);
entities.push(processed.completedEntity);
} else {
throw Error(processed.errors.map(String).join(', '));
}
}
return {
location: { ...spec, id: `${spec.type}:${spec.target}` },
entities,
};
}
}
@@ -133,8 +133,8 @@ export async function createNextRouter(
res.status(201).json(output);
})
.get('/locations', async (_req, res) => {
const output = await locationService.listLocations();
res.status(200).json(output);
const locations = await locationService.listLocations();
res.status(200).json(locations.map(l => ({ data: l })));
})
.get('/locations/:id', async (req, res) => {
@@ -59,7 +59,6 @@ export interface EntityProvider {
export type EntityProcessingRequest = {
entity: Entity;
eager?: boolean;
state: Map<string, JsonObject>; // Versions for multiple deployments etc
};