Merge pull request #4147 from SDA-SE/feat/origin-location

Block the deletion of entities from the bootstrap location in the unregister dialog and list the correct delete preview
This commit is contained in:
Dominik Henneke
2021-01-20 10:50:35 +01:00
committed by GitHub
10 changed files with 198 additions and 27 deletions
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/catalog-model': patch
'@backstage/plugin-catalog-backend': patch
---
Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the
location that was registered to the catalog and which emitted this entity. It has a different
semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct
parent location that created this entity.
Consider this example: The Backstage operator adds a location of type `github-org` in the
`app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor
discovers the entities in the following branch
`Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be:
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: xyz
annotations:
# This entity was added by the 'github-org:…' location
backstage.io/managed-by-location: github-org:…
# The entity was added because the 'bootstrap:boostrap' was added to the catalog
backstage.io/managed-by-origin-location: bootstrap:bootstrap
# ...
spec:
# ...
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Derive the list of to-delete entities in the `UnregisterEntityDialog` from the `backstage.io/managed-by-origin-location` annotation.
The dialog also rejects deleting entities that are created by the `bootstrap:bootstrap` location.
@@ -22,7 +22,7 @@ use.
# Example:
metadata:
annotations:
backstage.io/managed-by-location: github:http://github.com/backstage/backstage/catalog-info.yaml
backstage.io/managed-by-location: url:http://github.com/backstage/backstage/catalog-info.yaml
```
The value of this annotation is a so called location reference string, that
@@ -30,8 +30,8 @@ points to the source from which the entity was originally fetched. This
annotation is added automatically by the catalog as it fetches the data from a
registered location, and is not meant to normally be written by humans. The
annotation may point to any type of generic location that the catalog supports,
so it cannot be relied on to always be specifically of type `github`, nor that
it even represents a single file. Note also that a single location can be the
so it cannot be relied on to always be specifically of type `url`, nor that it
even represents a single file. Note also that a single location can be the
source of many entities, so it represents a many-to-one relationship.
The format of the value is `<type>:<target>`. Note that the target may also
@@ -40,13 +40,30 @@ expecting a two-item array out of it. The format of the target part is
type-dependent and could conceivably even be an empty string, but the separator
colon is always present.
### backstage.io/managed-by-origin-location
```yaml
# Example:
metadata:
annotations:
backstage.io/managed-by-origin-location: url:http://github.com/backstage/backstage/catalog-info.yaml
```
The value of this annotation is a location reference string (see above). It
points to the location, whose registration lead to the creation of the entity.
In most cases, the `backstage.io/managed-by-location` and
`backstage.io/managed-by-origin-location` will be equal. They will be different
if the original location delegates to another location. A common case is, that a
location is registered as `bootstrap:boostrap` which means that it is part of
the `app-config.yaml` of a Backstage installation.
### backstage.io/techdocs-ref
```yaml
# Example:
metadata:
annotations:
backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git
backstage.io/techdocs-ref: url:https://github.com/backstage/backstage.git
```
The value of this annotation is a location reference string (see above). If this
@@ -15,3 +15,5 @@
*/
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
export const ORIGIN_LOCATION_ANNOTATION =
'backstage.io/managed-by-origin-location';
+1 -1
View File
@@ -20,4 +20,4 @@ export {
locationSpecSchema,
analyzeLocationSchema,
} from './validation';
export { LOCATION_ANNOTATION } from './annotation';
export { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION } from './annotation';
@@ -78,13 +78,17 @@ export class LocationReaders implements LocationReader {
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
const relations = Array<EntityRelationSpec>();
const entity = await this.handleEntity(item, emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
});
const entity = await this.handleEntity(
item,
emitResult => {
if (emitResult.type === 'relation') {
relations.push(emitResult.relation);
return;
}
emit(emitResult);
},
location,
);
if (entity) {
output.entities.push({
@@ -165,6 +169,7 @@ export class LocationReaders implements LocationReader {
private async handleEntity(
item: CatalogProcessorEntityResult,
emit: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity | undefined> {
const { processors, logger } = this.options;
@@ -185,6 +190,7 @@ export class LocationReaders implements LocationReader {
current,
item.location,
emit,
originLocation,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
@@ -0,0 +1,62 @@
/*
* Copyright 2020 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 { Entity, LocationSpec } from '@backstage/catalog-model';
import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
describe('AnnotateLocationEntityProcessor', () => {
describe('preProcessEntity', () => {
it('adds annotations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target: 'my-location',
};
const originLocation: LocationSpec = {
type: 'url',
target: 'my-origin-location',
};
const processor = new AnnotateLocationEntityProcessor();
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'url:my-location',
'backstage.io/managed-by-origin-location': 'url:my-origin-location',
},
},
});
});
});
});
@@ -14,20 +14,28 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Entity,
LOCATION_ANNOTATION,
LocationSpec,
ORIGIN_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { CatalogProcessor } from './types';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
_: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity> {
return lodash.merge(
{
metadata: {
annotations: {
'backstage.io/managed-by-location': `${location.type}:${location.target}`,
[LOCATION_ANNOTATION]: `${location.type}:${location.target}`,
[ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`,
},
},
},
@@ -46,12 +46,16 @@ export type CatalogProcessor = {
* @param entity The (possibly partial) entity to process
* @param location The location that the entity came from
* @param emit A sink for auxiliary items resulting from the processing
* @param originLocation The location that the entity originally came from.
* While location resolves to the direct parent location, originLocation
* tells which location was used to start the ingestion loop.
* @returns The same entity or a modified version of it
*/
preProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity>;
/**
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { alertApiRef, Progress, useApi } from '@backstage/core';
import { Entity, ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { alertApiRef, configApiRef, Progress, useApi } from '@backstage/core';
import {
Button,
Dialog,
@@ -32,6 +32,7 @@ import React from 'react';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../plugin';
import { formatEntityRefTitle } from '../EntityRefLink';
type Props = {
open: boolean;
@@ -40,15 +41,30 @@ type Props = {
entity: Entity;
};
class DeniedLocationException extends Error {
constructor(public readonly locationName: string) {
super(`You may not remove the location ${locationName}`);
this.name = 'DeniedLocationException';
}
}
function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
const catalogApi = useApi(catalogApiRef);
return useAsync(async () => {
const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const myLocation =
entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION];
if (!myLocation) {
return [];
}
if (myLocation === 'bootstrap:bootstrap') {
throw new DeniedLocationException(myLocation);
}
const response = await catalogApi.getEntities({
filter: { [LOCATION_ANNOTATION]: myLocation },
filter: {
[`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: myLocation,
},
});
return response.items;
}, [catalogApi, entity]);
@@ -65,6 +81,7 @@ export const UnregisterEntityDialog = ({
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const configApi = useApi(configApiRef);
const removeEntity = async () => {
const uid = entity.metadata.uid;
@@ -82,13 +99,27 @@ export const UnregisterEntityDialog = ({
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this entity?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
{error ? (
<Alert severity="error" style={{ wordBreak: 'break-word' }}>
{error.toString()}
{error.name === 'DeniedLocationException' ? (
<>
You cannot unregister this entity, since it originates from a
protected Backstage configuration (location
{`"${(error as DeniedLocationException).locationName}"`}). If
you believe this is in error, please contact the{' '}
{configApi.getOptionalString('app.title') ?? 'Backstage'}{' '}
integrator.
</>
) : (
error.toString()
)}
</Alert>
) : null}
{entities?.length ? (
<>
<DialogContentText>
@@ -96,9 +127,10 @@ export const UnregisterEntityDialog = ({
</DialogContentText>
<Typography component="div">
<ul>
{entities.map(e => (
<li key={e.metadata.name}>{e.metadata.name}</li>
))}
{entities.map(e => {
const fullName = formatEntityRefTitle(e);
return <li key={fullName}>{fullName}</li>;
})}
</ul>
</Typography>
<DialogContentText>
@@ -107,16 +139,21 @@ export const UnregisterEntityDialog = ({
<Typography component="div">
<ul style={{ wordBreak: 'break-word' }}>
<li>
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
{
entities[0]?.metadata.annotations?.[
ORIGIN_LOCATION_ANNOTATION
]
}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the entity in Backstage.
</DialogContentText>
</>
) : null}
<DialogContentText>
To undo, just re-register the entity in Backstage.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
Cancel