diff --git a/.changeset/fuzzy-ladybugs-drive.md b/.changeset/fuzzy-ladybugs-drive.md new file mode 100644 index 0000000000..3cdc521e19 --- /dev/null +++ b/.changeset/fuzzy-ladybugs-drive.md @@ -0,0 +1,11 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Add Owner field in template card and new data distribution +Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema +Add relations ownedBy and ownerOf into Template entity +Template documentation updated diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 699574b986..aab2af3145 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -706,6 +706,25 @@ You can find out more about the `parameters` key You can find out more about the `steps` key [here](../software-templates/writing-templates.md) +### `spec.owner` [optional] + +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a Template is the singular entity (commonly a team) +that bears ultimate responsibility for the Template, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this Template belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +Template, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + ## Kind: API Describes the following entity kind: diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 00eaf8410e..890a45b6e7 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -487,6 +487,7 @@ interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } @@ -520,6 +521,7 @@ export interface TemplateEntityV1beta2 extends Entity { output?: { [name: string]: string; }; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index e3d29aad4f..bfb27b4ed6 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => { }, }, }, + owner: 'team-a@example.com', }, }; }); @@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => { (entity as any).spec.templater = ''; await expect(validator.check(entity)).rejects.toThrow(/templater/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 72479c7efa..64721e3739 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -33,6 +33,7 @@ export interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 958a486687..458bdcd0aa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -59,6 +59,7 @@ describe('templateEntityV1beta2Validator', () => { output: { fetchUrl: '{{ steps.fetch.output.targetUrl }}', }, + owner: 'team-b@example.com', }, }; }); @@ -121,4 +122,16 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps[0].action; await expect(validator.check(entity)).rejects.toThrow(/action/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 1f4e3bcd90..7a100de4bb 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -41,6 +41,7 @@ export interface TemplateEntityV1beta2 extends Entity { input?: JsonObject; }>; output?: { [name: string]: string }; + owner?: string; }; } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 53109ac7ee..5a0c0efa73 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -85,6 +85,11 @@ "schema": { "type": "object", "description": "The JSONSchema describing the inputs for the template." + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index b88b344626..a3d8e6ba86 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -168,6 +168,11 @@ "additionalProperties": { "type": "string" } + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index cb60c119b6..3a8c061892 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -21,6 +21,7 @@ import { GroupEntity, ResourceEntity, SystemEntity, + TemplateEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -520,5 +521,47 @@ describe('BuiltinKindsEntityProcessor', () => { }, }); }); + it('generates relations for template entities', async () => { + const entity: TemplateEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { name: 'n' }, + spec: { + schema: { + properties: { + description: { + title: 'd', + type: 'string', + description: 'des', + }, + }, + }, + templater: 'cookiecutter', + path: '.', + type: 'service', + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Template', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Template', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 404d7fd30d..f9acae6b4f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -46,6 +46,7 @@ import { resourceEntityV1alpha1Validator, SystemEntity, systemEntityV1alpha1Validator, + TemplateEntity, templateEntityV1alpha1Validator, templateEntityV1beta2Validator, UserEntity, @@ -131,6 +132,19 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { } } + /* + * Emit relations for the Template kind + */ + if (entity.kind === 'Template') { + const template = entity as TemplateEntity; + doEmit( + template.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + /* * Emit relations for the Component kind */ diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index c782037ec8..c1da51c40e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -60,6 +60,7 @@ describe('JobProcessor', () => { }, }, }, + owner: 'example@email.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index fc36eb64e5..c314f7c727 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -55,6 +55,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-d@example.com', }, }; @@ -103,6 +104,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -151,6 +153,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-a@example.com', }, }; @@ -198,6 +201,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -243,6 +247,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-c@example.com', }, }; diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 8c64d981ad..6da0e3eaa2 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -23,29 +23,48 @@ import { CardMedia, Chip, makeStyles, + Typography, useTheme, } from '@material-ui/core'; import React from 'react'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + TemplateEntityV1alpha1, + Entity, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; +import { + getEntityRelations, + EntityRefLinks, +} from '@backstage/plugin-catalog-react'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ cardHeader: { position: 'relative', }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, - description: { + box: { overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', '-webkit-line-clamp': 10, '-webkit-box-orient': 'vertical', + paddingBottom: '0.8em', }, -}); + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '0.65rem', + fontWeight: 'bold', + letterSpacing: 0.5, + lineHeight: 1, + paddingBottom: '0.2rem', + }, +})); export type TemplateCardProps = { template: TemplateEntityV1alpha1; @@ -76,7 +95,10 @@ export const TemplateCard = ({ template }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); const templateProps = getTemplateCardProps(template); - + const ownedByRelations = getEntityRelations( + template as Entity, + RELATION_OWNED_BY, + ); const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); @@ -94,13 +116,27 @@ export const TemplateCard = ({ template }: TemplateCardProps) => { classes={{ root: classes.title }} /> - + + + + Description + + {templateProps.description} + + + + Owner + + + + + Tags + {templateProps.tags?.map(tag => ( ))} - {templateProps.description}