diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a246b3abab..063db5a6a3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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}`, diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts new file mode 100644 index 0000000000..662be5d3a6 --- /dev/null +++ b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts @@ -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 = { + process: jest.fn(), + }; + const store: jest.Mocked = { + 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'); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts index 3935f6e4d0..e628b1d77d 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.ts @@ -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 { 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, + }; + } } diff --git a/plugins/catalog-backend/src/next/NextRouter.ts b/plugins/catalog-backend/src/next/NextRouter.ts index 433abd97e9..b4660edea0 100644 --- a/plugins/catalog-backend/src/next/NextRouter.ts +++ b/plugins/catalog-backend/src/next/NextRouter.ts @@ -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) => { diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index 410066eafd..fbc243ce69 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -59,7 +59,6 @@ export interface EntityProvider { export type EntityProcessingRequest = { entity: Entity; - eager?: boolean; state: Map; // Versions for multiple deployments etc };