From eeb2c90a38159adccb25de83b292954c45b1350e Mon Sep 17 00:00:00 2001 From: Bret Hubbard Date: Wed, 30 Mar 2022 10:19:42 -0600 Subject: [PATCH 001/144] catalog-backend: extract getDocumentText to improve testability Co-authored-by: Jason Nguyen Signed-off-by: Bret Hubbard --- .../search/DefaultCatalogCollatorFactory.ts | 27 +----- .../catalog-backend/src/search/util.test.ts | 95 +++++++++++++++++++ plugins/catalog-backend/src/search/util.ts | 35 +++++++ 3 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 plugins/catalog-backend/src/search/util.test.ts create mode 100644 plugins/catalog-backend/src/search/util.ts diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts index 98bcfcd8d4..cf082c271c 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts @@ -23,11 +23,7 @@ import { CatalogClient, GetEntitiesRequest, } from '@backstage/catalog-client'; -import { - Entity, - stringifyEntityRef, - UserEntity, -} from '@backstage/catalog-model'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { @@ -36,6 +32,7 @@ import { } from '@backstage/plugin-catalog-common'; import { Permission } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; +import { getDocumentText } from './util'; /** @public */ export type DefaultCatalogCollatorFactoryOptions = { @@ -100,24 +97,6 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { return formatted.toLowerCase(); } - private isUserEntity(entity: Entity): entity is UserEntity { - return entity.kind.toLocaleUpperCase('en-US') === 'USER'; - } - - private getDocumentText(entity: Entity): string { - let documentText = entity.metadata.description || ''; - if (this.isUserEntity(entity)) { - if (entity.spec?.profile?.displayName && documentText) { - // combine displayName and description - const displayName = entity.spec?.profile?.displayName; - documentText = displayName.concat(' : ', documentText); - } else { - documentText = entity.spec?.profile?.displayName || documentText; - } - } - return documentText; - } - private async *execute(): AsyncGenerator { const { token } = await this.tokenManager.getToken(); let entitiesRetrieved = 0; @@ -150,7 +129,7 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { kind: entity.kind, name: entity.metadata.name, }), - text: this.getDocumentText(entity), + text: getDocumentText(entity), componentType: entity.spec?.type?.toString() || 'other', type: entity.spec?.type?.toString() || 'other', namespace: entity.metadata.namespace || 'default', diff --git a/plugins/catalog-backend/src/search/util.test.ts b/plugins/catalog-backend/src/search/util.test.ts new file mode 100644 index 0000000000..0444dc2209 --- /dev/null +++ b/plugins/catalog-backend/src/search/util.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { ComponentEntity, UserEntity } from '@backstage/catalog-model'; +import { getDocumentText } from './util'; + +describe('getDocumentText', () => { + describe('kind is not User or Group', () => { + test('contains description if set', () => { + const entity = createComponent(); + entity.metadata.description = 'The expected description'; + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description is not set', () => { + const entity = createComponent(); + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); + + describe('kind is User', () => { + test('contains display name if set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + }); + + test('contains description if set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('contains both description and display name if both are set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description and display name are not set', () => { + const entity = createUser(); + delete entity.metadata.description; + delete entity.spec.profile?.displayName; + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); +}); + +function createUser(): UserEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user-1', + description: 'The expected description', + }, + spec: { + profile: { + displayName: 'User 1', + }, + }, + }; +} + +function createComponent(): ComponentEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-1', + }, + spec: { + lifecycle: 'experimental', + owner: 'someone', + type: 'service', + }, + }; +} diff --git a/plugins/catalog-backend/src/search/util.ts b/plugins/catalog-backend/src/search/util.ts new file mode 100644 index 0000000000..4e6bb8aea0 --- /dev/null +++ b/plugins/catalog-backend/src/search/util.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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, UserEntity } from '@backstage/catalog-model'; + +function isUserEntity(entity: Entity): entity is UserEntity { + return entity.kind.toLocaleUpperCase('en-US') === 'USER'; +} + +export function getDocumentText(entity: Entity): string { + let documentText = entity.metadata.description || ''; + if (isUserEntity(entity)) { + if (entity.spec?.profile?.displayName && documentText) { + // combine displayName and description + const displayName = entity.spec.profile.displayName; + documentText = displayName.concat(' : ', documentText); + } else { + documentText = entity.spec?.profile?.displayName || documentText; + } + } + return documentText; +} From 22394f6e42d358ae90303c11c181852cc680e598 Mon Sep 17 00:00:00 2001 From: Bret Hubbard Date: Wed, 30 Mar 2022 10:30:11 -0600 Subject: [PATCH 002/144] catalog-backend: include group displayName in search index Co-authored-by: Jason Nguyen Signed-off-by: Bret Hubbard --- .../catalog-backend/src/search/util.test.ts | 53 ++++++++++++++++++- plugins/catalog-backend/src/search/util.ts | 6 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/search/util.test.ts b/plugins/catalog-backend/src/search/util.test.ts index 0444dc2209..d2d1776338 100644 --- a/plugins/catalog-backend/src/search/util.test.ts +++ b/plugins/catalog-backend/src/search/util.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ComponentEntity, UserEntity } from '@backstage/catalog-model'; +import { + ComponentEntity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; import { getDocumentText } from './util'; describe('getDocumentText', () => { @@ -61,8 +65,55 @@ describe('getDocumentText', () => { expect(actual).toEqual(''); }); }); + + describe('kind is Group', () => { + test('contains display name if set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + }); + + test('contains description if set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('contains both description and display name if both are set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description and display name are not set', () => { + const entity = createGroup(); + delete entity.metadata.description; + delete entity.spec.profile?.displayName; + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); }); +function createGroup(): GroupEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'group-1', + description: 'The expected description', + }, + spec: { + type: 'team', + profile: { + displayName: 'Group 1', + }, + children: [], + }, + }; +} + function createUser(): UserEntity { return { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/catalog-backend/src/search/util.ts b/plugins/catalog-backend/src/search/util.ts index 4e6bb8aea0..47a5570899 100644 --- a/plugins/catalog-backend/src/search/util.ts +++ b/plugins/catalog-backend/src/search/util.ts @@ -20,9 +20,13 @@ function isUserEntity(entity: Entity): entity is UserEntity { return entity.kind.toLocaleUpperCase('en-US') === 'USER'; } +function isGroupEntity(entity: Entity): entity is UserEntity { + return entity.kind.toLocaleUpperCase('en-US') === 'GROUP'; +} + export function getDocumentText(entity: Entity): string { let documentText = entity.metadata.description || ''; - if (isUserEntity(entity)) { + if (isUserEntity(entity) || isGroupEntity(entity)) { if (entity.spec?.profile?.displayName && documentText) { // combine displayName and description const displayName = entity.spec.profile.displayName; From 48405ed232722d041b6123b066f6992eacdfaef9 Mon Sep 17 00:00:00 2001 From: Bret Hubbard Date: Wed, 30 Mar 2022 10:37:45 -0600 Subject: [PATCH 003/144] add changeset Signed-off-by: Bret Hubbard --- .changeset/many-cameras-search.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/many-cameras-search.md diff --git a/.changeset/many-cameras-search.md b/.changeset/many-cameras-search.md new file mode 100644 index 0000000000..6998dfd713 --- /dev/null +++ b/.changeset/many-cameras-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added `spec.profile.displayName` to search index for Group kinds From 4c6c2b2918bc1587a9eb944840064ffc64e679c3 Mon Sep 17 00:00:00 2001 From: Julio Zynger Date: Wed, 30 Mar 2022 18:04:02 +0200 Subject: [PATCH 004/144] Add DORA metrics insights to GoCD builds page Signed-off-by: Julio Zynger --- .changeset/weak-buttons-play.md | 5 + plugins/gocd/docs/gocd-plugin-screenshot.png | Bin 56249 -> 196202 bytes plugins/gocd/src/api/gocdApi.model.ts | 17 + .../GoCdBuildsComponent.tsx | 6 + .../GoCdBuildInsights.test.tsx | 417 ++++++++++++++++++ .../GoCdBuildsInsights/GoCdBuildsInsights.tsx | 255 +++++++++++ .../GoCdBuildsTable/GoCdBuildsTable.tsx | 20 +- 7 files changed, 702 insertions(+), 18 deletions(-) create mode 100644 .changeset/weak-buttons-play.md create mode 100644 plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildInsights.test.tsx create mode 100644 plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx diff --git a/.changeset/weak-buttons-play.md b/.changeset/weak-buttons-play.md new file mode 100644 index 0000000000..491b510f77 --- /dev/null +++ b/.changeset/weak-buttons-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gocd': patch +--- + +Add DORA metrics insights to GoCD builds page diff --git a/plugins/gocd/docs/gocd-plugin-screenshot.png b/plugins/gocd/docs/gocd-plugin-screenshot.png index 8ddc786fa5605313223fdaa75bb93f74e5d0a741..d1e2d28c4e6dde1258f3e99603760b6de79b5e3f 100644 GIT binary patch literal 196202 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

e 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ॆ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 diff --git a/plugins/gocd/src/api/gocdApi.model.ts b/plugins/gocd/src/api/gocdApi.model.ts index 8bb9a481e6..d76a6fdc19 100644 --- a/plugins/gocd/src/api/gocdApi.model.ts +++ b/plugins/gocd/src/api/gocdApi.model.ts @@ -101,6 +101,23 @@ export enum GoCdBuildResultStatus { pending, } +export const toBuildResultStatus = (status: string): GoCdBuildResultStatus => { + switch (status.toLocaleLowerCase('en-US')) { + case 'passed': + return GoCdBuildResultStatus.successful; + case 'failed': + return GoCdBuildResultStatus.error; + case 'aborted': + return GoCdBuildResultStatus.aborted; + case 'building': + return GoCdBuildResultStatus.running; + case 'pending': + return GoCdBuildResultStatus.pending; + default: + return GoCdBuildResultStatus.aborted; + } +}; + export interface GoCdBuildStageResult { status: GoCdBuildResultStatus; text: string; diff --git a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx index eb8b311896..abaaad8506 100644 --- a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx +++ b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx @@ -27,6 +27,7 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; import { gocdApiRef } from '../../plugin'; import { GoCdBuildsTable } from '../GoCdBuildsTable/GoCdBuildsTable'; +import { GoCdBuildsInsights } from '../GoCdBuildsInsights/GoCdBuildsInsights'; import { GoCdApiError, PipelineHistory } from '../../api/gocdApi.model'; import { Item, Select } from '../Select'; @@ -120,6 +121,11 @@ export const GoCdBuildsComponent = (): JSX.Element => { items={getSelectionItems(rawPipelines)} /> + ', () => { + it('renders without exploding', () => { + const { getByText } = render(); + expect(getByText('Run Frequency')).toBeInTheDocument(); + expect(getByText('Mean Time to Recovery')).toBeInTheDocument(); + expect(getByText('Mean Time Between Failures')).toBeInTheDocument(); + expect(getByText('Failure Rate')).toBeInTheDocument(); + }); + + it('renders mean time to recovery', () => { + const { getByText } = render(); + expect(getByText('-1d -3h -21m -40s')).toBeInTheDocument(); + }); + + it('renders mean time between failures', () => { + const { getByText } = render(); + expect(getByText('2d 8h 23m 20s')).toBeInTheDocument(); + }); + + it('renders failure rate', () => { + const { getByText } = render(); + expect(getByText('11.11%')).toBeInTheDocument(); + expect(getByText('(2 out of 18 jobs)')).toBeInTheDocument(); + }); + + it('renders nothing when loading', () => { + const props = { + loading: true, + pipelineHistory: undefined, + error: undefined, + }; + const { getByTestId } = render(); + + expect(() => getByTestId('GoCdBuildsInsightsBox')).toThrow(); + }); +}); diff --git a/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx new file mode 100644 index 0000000000..20336fe6ac --- /dev/null +++ b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 React from 'react'; +import { groupBy, mean } from 'lodash'; +import { + PipelineHistory, + Job, + toBuildResultStatus, + GoCdBuildResultStatus, +} from '../../api/gocdApi.model'; +import { + Box, + Grid, + Card, + CardContent, + Tooltip, + Typography, +} from '@material-ui/core'; +import { DateTime, Duration } from 'luxon'; + +export type GoCdBuildsInsightsProps = { + pipelineHistory: PipelineHistory | undefined; + loading: boolean; + error: Error | undefined; +}; + +function runFrequency(pipelineHistory: PipelineHistory): string { + const lastMonth = DateTime.now().minus({ month: 1 }); + const buildLastMonth = pipelineHistory.pipelines + .map(p => p.scheduled_date) + .filter((d): d is number => !!d) + .map(d => DateTime.fromMillis(d)) + .filter(d => d > lastMonth).length; + return `${buildLastMonth} last month`; +} + +function meanTimeBetweenFailures(jobs: Job[]): string { + const timeBetweenFailures: Duration[] = []; + for (let index = 1; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let previousFailedJob: Job | null = null; + for (let j = index - 1; j >= 0; j--) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.error + ) { + previousFailedJob = candidateJob; + break; + } + } + if ( + !previousFailedJob || + !job.scheduled_date || + !previousFailedJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const previousFailedJobDate = DateTime.fromMillis( + previousFailedJob.scheduled_date, + ); + const timeBetweenFailure = failedJobDate.diff(previousFailedJobDate); + timeBetweenFailures.push(timeBetweenFailure); + } + } + return formatMean(timeBetweenFailures); +} + +/** + * Imagine a sequence like: + * S - S - S - F - S - F - F - S - S + * + * We iterate until finding a failure, and then iterate forward + * until we find the first immediate success to then + * calculate the time difference between the scheduling of the jobs. + */ +function meanTimeToRecovery(jobs: Job[]): string { + const timeToRecoverIntervals: Duration[] = []; + for (let index = 0; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let nextSuccessfulJob: Job | null = null; + for (let j = index + 1; j < jobs.length; j++) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.successful + ) { + nextSuccessfulJob = candidateJob; + break; + } + } + if ( + !nextSuccessfulJob || + !job.scheduled_date || + !nextSuccessfulJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const successfulJobDate = DateTime.fromMillis( + nextSuccessfulJob.scheduled_date, + ); + const timeToRecovery = successfulJobDate.diff(failedJobDate); + timeToRecoverIntervals.push(timeToRecovery); + } + } + + return formatMean(timeToRecoverIntervals); +} + +function formatMean(durations: Duration[]): string { + if (durations.length === 0) { + return 'N/A'; + } + + const mttr: Duration = Duration.fromMillis( + mean(durations.map(i => i.milliseconds)), + ); + return mttr.toFormat("d'd' h'h' m'm' s's'"); +} + +function failureRate(jobs: Job[]): { + title: string; + subtitle: string; +} { + const resultGroups = new Map( + Object.entries(groupBy(jobs, 'result')).map(([key, value]) => [ + toBuildResultStatus(key), + value.flat(), + ]), + ); + const failedJobs = resultGroups.get(GoCdBuildResultStatus.error); + if (!failedJobs) { + return { + title: '0', + subtitle: '(no failed jobs found)', + }; + } + + resultGroups.delete(GoCdBuildResultStatus.error); + const nonFailedJobs = Array.from(resultGroups.values()).flat(); + + const totalJobs = failedJobs.length + nonFailedJobs.length; + const percentage = (failedJobs.length / totalJobs) * 100; + const decimalPercentage = (Math.round(percentage * 100) / 100).toFixed(2); + return { + title: `${decimalPercentage}%`, + subtitle: `(${failedJobs.length} out of ${totalJobs} jobs)`, + }; +} + +export const GoCdBuildsInsights = ( + props: GoCdBuildsInsightsProps, +): JSX.Element => { + const { pipelineHistory, loading, error } = props; + + if (loading || error || !pipelineHistory) { + return <>; + } + + // We reverse the array to calculate insights to make sure jobs are ordered + // by their schedule date. + const stages = pipelineHistory.pipelines + .slice() + .reverse() + .map(p => p.stages) + .flat(); + const jobs = stages.map(s => s.jobs).flat(); + + const failureRateObj: { title: string; subtitle: string } = failureRate(jobs); + + return ( + + + + + + + Run Frequency + + {runFrequency(pipelineHistory)} + + + + + + + + + + Mean Time to Recovery + {meanTimeToRecovery(jobs)} + + + + + + + + + + Mean Time Between Failures + + + {meanTimeBetweenFailures(jobs)} + + + + + + + + + + Failure Rate + {failureRateObj.title} + + {failureRateObj.subtitle} + + + + + + + + ); +}; diff --git a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx index 58dfbcca39..498cdc26bf 100644 --- a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx +++ b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx @@ -22,6 +22,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; import { GoCdBuildResult, GoCdBuildResultStatus, + toBuildResultStatus, PipelineHistory, Pipeline, } from '../../api/gocdApi.model'; @@ -157,23 +158,6 @@ const renderError = (error: Error): JSX.Element => { return {error.message}; }; -const toStageStatus = (status: string): GoCdBuildResultStatus => { - switch (status.toLocaleLowerCase('en-US')) { - case 'passed': - return GoCdBuildResultStatus.successful; - case 'failed': - return GoCdBuildResultStatus.error; - case 'aborted': - return GoCdBuildResultStatus.aborted; - case 'building': - return GoCdBuildResultStatus.running; - case 'pending': - return GoCdBuildResultStatus.pending; - default: - return GoCdBuildResultStatus.aborted; - } -}; - const toBuildResults = ( entity: Entity, builds: Pipeline[], @@ -185,7 +169,7 @@ const toBuildResults = ( id: build.counter, source: `${entitySourceLocation}/commit/${build.build_cause?.material_revisions[0]?.modifications[0].revision}`, stages: build.stages.map(s => ({ - status: toStageStatus(s.status), + status: toBuildResultStatus(s.status), text: s.name, })), buildSlug: `${build.name}/${build.counter}`, From 698c5a7cfe4090ffbaa680de4bd816f89b18cc8a Mon Sep 17 00:00:00 2001 From: Pedro Parra Ortega Date: Mon, 4 Apr 2022 15:17:44 +0200 Subject: [PATCH 005/144] allow to select group fields Signed-off-by: Pedro Parra Ortega --- plugins/catalog-backend-module-msgraph/config.d.ts | 8 ++++++++ .../src/microsoftGraph/config.test.ts | 2 ++ .../src/microsoftGraph/config.ts | 10 ++++++++++ .../src/microsoftGraph/read.ts | 4 ++++ .../src/processors/MicrosoftGraphOrgEntityProvider.ts | 2 +- .../src/processors/MicrosoftGraphOrgReaderProcessor.ts | 1 + 6 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index db5ab81c4b..cf00713e72 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -79,6 +79,14 @@ export interface Config { * E.g. "\"displayName:-team\"" would only match groups which contain '-team' */ groupSearch?: string; + + /** + * The fields to be fetched on query. + * + * E.g. ["id", "displayName", "description"] + */ + groupSelect?: string[]; + /** * The filter to apply to extract users by groups memberships. * diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index 34796fb29a..e310fa544f 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -56,6 +56,7 @@ describe('readMicrosoftGraphConfig', () => { userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', + groupSelect: ['id', 'displayName', 'description'], groupFilter: 'securityEnabled eq false', }, ], @@ -71,6 +72,7 @@ describe('readMicrosoftGraphConfig', () => { userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', + groupSelect: ['id', 'displayName', 'description'], groupFilter: 'securityEnabled eq false', }, ]; diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 441892531a..aa2f2d6ee2 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -88,6 +88,14 @@ export type MicrosoftGraphProviderConfig = { * E.g. "\"displayName:-team\"" would only match groups which contain '-team' */ groupSearch?: string; + + /** + * The fields to be fetched on query. + * + * E.g. ["id", "displayName", "description"] + */ + groupSelect?: string[]; + /** * By default, the Microsoft Graph API only provides the basic feature set * for querying. Certain features are limited to advanced query capabilities @@ -145,6 +153,7 @@ export function readMicrosoftGraphConfig( ); } + const groupSelect = providerConfig.getOptionalStringArray('groupSelect'); const queryMode = providerConfig.getOptionalString('queryMode'); if ( queryMode !== undefined && @@ -167,6 +176,7 @@ export function readMicrosoftGraphConfig( groupExpand, groupFilter, groupSearch, + groupSelect, queryMode, }); } diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index e9760f7698..de7f3e49e0 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -343,6 +343,7 @@ export async function readMicrosoftGraphGroups( groupExpand?: string; groupFilter?: string; groupSearch?: string; + groupSelect?: string[]; groupTransformer?: GroupTransformer; organizationTransformer?: OrganizationTransformer; }, @@ -373,6 +374,7 @@ export async function readMicrosoftGraphGroups( expand: options?.groupExpand, search: options?.groupSearch, filter: options?.groupFilter, + select: options?.groupSelect, }, options?.queryMode, )) { @@ -535,6 +537,7 @@ export async function readMicrosoftGraphOrg( groupExpand?: string; groupSearch?: string; groupFilter?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; @@ -571,6 +574,7 @@ export async function readMicrosoftGraphOrg( queryMode: options.queryMode, groupSearch: options.groupSearch, groupFilter: options.groupFilter, + groupSelect: options.groupSelect, groupTransformer: options.groupTransformer, organizationTransformer: options.organizationTransformer, }); diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts index dfd0a7782e..d96faf8e03 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts @@ -176,7 +176,6 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider { const provider = this.options.provider; const { markReadComplete } = trackProgress(logger); const client = MicrosoftGraphClient.create(this.options.provider); - const { users, groups } = await readMicrosoftGraphOrg( client, provider.tenantId, @@ -186,6 +185,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider { userGroupMemberSearch: provider.userGroupMemberSearch, groupFilter: provider.groupFilter, groupSearch: provider.groupSearch, + groupSelect: provider.groupSelect, queryMode: provider.queryMode, groupTransformer: this.options.groupTransformer, userTransformer: this.options.userTransformer, diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts index 5d8619e874..5d0e6b2ec7 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts @@ -113,6 +113,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { groupExpand: provider.groupExpand, groupFilter: provider.groupFilter, groupSearch: provider.groupSearch, + groupSelect: provider.groupSelect, queryMode: provider.queryMode, userTransformer: this.userTransformer, groupTransformer: this.groupTransformer, From cc6a3b5d40162087f85d1668ad68ff3a6b9562be Mon Sep 17 00:00:00 2001 From: Pedro Parra Ortega Date: Mon, 4 Apr 2022 15:40:08 +0200 Subject: [PATCH 006/144] added change set Signed-off-by: Pedro Parra Ortega --- .../.changeset/README.md | 8 ++++++++ .../.changeset/config.json | 11 +++++++++++ .../.changeset/forty-pumpkins-marry.md | 5 +++++ 3 files changed, 24 insertions(+) create mode 100644 plugins/catalog-backend-module-msgraph/.changeset/README.md create mode 100644 plugins/catalog-backend-module-msgraph/.changeset/config.json create mode 100644 plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md diff --git a/plugins/catalog-backend-module-msgraph/.changeset/README.md b/plugins/catalog-backend-module-msgraph/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/plugins/catalog-backend-module-msgraph/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/plugins/catalog-backend-module-msgraph/.changeset/config.json b/plugins/catalog-backend-module-msgraph/.changeset/config.json new file mode 100644 index 0000000000..82376bbf3b --- /dev/null +++ b/plugins/catalog-backend-module-msgraph/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@1.7.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "master", + "updateInternalDependencies": "patch", + "ignore": [] +} \ No newline at end of file diff --git a/plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md b/plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md new file mode 100644 index 0000000000..782fb5ae91 --- /dev/null +++ b/plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +--- + +Now plugin configuration accept a new optional parameter `groupSelect` which allow the client to fetch defined fields from the ms-graph api. From b6930c78f25dfb5b152c5a3d43051ad4722644e7 Mon Sep 17 00:00:00 2001 From: Pedro Parra Ortega Date: Mon, 4 Apr 2022 15:45:12 +0200 Subject: [PATCH 007/144] added documentation Signed-off-by: Pedro Parra Ortega --- plugins/catalog-backend-module-msgraph/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index 1fd5fc3f19..c2fae37d7d 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -72,6 +72,9 @@ catalog: # Optional search for groups, see Microsoft Graph API for the syntax # See https://docs.microsoft.com/en-us/graph/search-query-parameter groupSearch: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' + # Optional select for groups, this will allow you work with schemaExtensions in order to add extra information to your groups + # See https://docs.microsoft.com/en-us/graph/api/resources/schemaextension?view=graph-rest-1.0 + groupSelect: ['id', 'displayName', 'description'] ``` `userFilter` and `userGroupMemberFilter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown. From eeff6a73bf8db7705a795f261f66c6a631d98932 Mon Sep 17 00:00:00 2001 From: Pedro Parra Ortega Date: Mon, 4 Apr 2022 15:45:49 +0200 Subject: [PATCH 008/144] fix documentation Signed-off-by: Pedro Parra Ortega --- plugins/catalog-backend-module-msgraph/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index c2fae37d7d..e6429152e4 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -72,7 +72,7 @@ catalog: # Optional search for groups, see Microsoft Graph API for the syntax # See https://docs.microsoft.com/en-us/graph/search-query-parameter groupSearch: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' - # Optional select for groups, this will allow you work with schemaExtensions in order to add extra information to your groups + # Optional select for groups, this will allow you work with schemaExtensions in order to add extra information to your groups that can be used on you custom groupTransformers # See https://docs.microsoft.com/en-us/graph/api/resources/schemaextension?view=graph-rest-1.0 groupSelect: ['id', 'displayName', 'description'] ``` From 85fc53df95db10f31f9b08aeb3303a09ced5dd51 Mon Sep 17 00:00:00 2001 From: Pedro Parra Ortega Date: Mon, 4 Apr 2022 16:29:00 +0200 Subject: [PATCH 009/144] fixed changeset definition Signed-off-by: Pedro Parra Ortega --- .../.changeset => .changeset}/forty-pumpkins-marry.md | 0 .../.changeset/README.md | 8 -------- .../.changeset/config.json | 11 ----------- 3 files changed, 19 deletions(-) rename {plugins/catalog-backend-module-msgraph/.changeset => .changeset}/forty-pumpkins-marry.md (100%) delete mode 100644 plugins/catalog-backend-module-msgraph/.changeset/README.md delete mode 100644 plugins/catalog-backend-module-msgraph/.changeset/config.json diff --git a/plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md b/.changeset/forty-pumpkins-marry.md similarity index 100% rename from plugins/catalog-backend-module-msgraph/.changeset/forty-pumpkins-marry.md rename to .changeset/forty-pumpkins-marry.md diff --git a/plugins/catalog-backend-module-msgraph/.changeset/README.md b/plugins/catalog-backend-module-msgraph/.changeset/README.md deleted file mode 100644 index e5b6d8d6a6..0000000000 --- a/plugins/catalog-backend-module-msgraph/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets) - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/plugins/catalog-backend-module-msgraph/.changeset/config.json b/plugins/catalog-backend-module-msgraph/.changeset/config.json deleted file mode 100644 index 82376bbf3b..0000000000 --- a/plugins/catalog-backend-module-msgraph/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@1.7.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "restricted", - "baseBranch": "master", - "updateInternalDependencies": "patch", - "ignore": [] -} \ No newline at end of file From b013de3f502ac420163db1aa3ff6bfc350f56cad Mon Sep 17 00:00:00 2001 From: Hasan Oezdemir <21654050+nodify-at@users.noreply.github.com> Date: Wed, 6 Apr 2022 13:25:05 +0200 Subject: [PATCH 010/144] feature: provide access token to JenkinsInstanceConfig. It can be passed to other backend calls if authentication enabled. DefaultJenkinsInfoProvider sends always this token to catalog api if access token exists. Signed-off-by: Hasan Oezdemir <21654050+nodify-at@users.noreply.github.com> --- .changeset/rare-emus-agree.md | 5 +++ plugins/jenkins-backend/api-report.md | 2 ++ .../src/service/jenkinsInfoProvider.test.ts | 32 ++++++++++++++----- .../src/service/jenkinsInfoProvider.ts | 7 +++- plugins/jenkins-backend/src/service/router.ts | 8 +++++ 5 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-emus-agree.md diff --git a/.changeset/rare-emus-agree.md b/.changeset/rare-emus-agree.md new file mode 100644 index 0000000000..423d4c6259 --- /dev/null +++ b/.changeset/rare-emus-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +feature: provide access token to JenkinsInstanceConfig. It can be passed to other backend calls if authentication enabled. DefaultJenkinsInfoProvider sends always this token to catalog api if access token exists. diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index fb155bafe2..fd20cd79cf 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -28,6 +28,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; + token?: string; }): Promise; // (undocumented) static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; @@ -68,6 +69,7 @@ export interface JenkinsInfoProvider { getInstance(options: { entityRef: CompoundEntityRef; jobFullName?: string; + token?: string; }): Promise; } diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index a3a5ba069d..e2d34f59b6 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -185,7 +185,9 @@ describe('DefaultJenkinsInfoProvider', () => { const provider = configureProvider({ jenkins: {} }, undefined); await expect(provider.getInstance({ entityRef })).rejects.toThrowError(); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); }); it('Reads simple config and annotation', async () => { @@ -207,7 +209,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', crumbIssuer: undefined, @@ -243,7 +247,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -280,7 +286,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -317,7 +325,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', @@ -343,7 +353,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -369,7 +381,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -400,7 +414,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + token: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index d9a97e2f2d..221acb3c51 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -32,6 +32,8 @@ export interface JenkinsInfoProvider { * A specific job to get. This is only passed in when we know about a job name we are interested in. */ jobFullName?: string; + + token?: string; }): Promise; } @@ -184,9 +186,12 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { async getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; + token?: string; }): Promise { // load entity - const entity = await this.catalog.getEntityByRef(opt.entityRef); + const entity = await this.catalog.getEntityByRef(opt.entityRef, { + token: opt.token, + }); if (!entity) { throw new Error( `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 08712215e4..3e53f27e4b 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -44,6 +44,9 @@ export async function createRouter( '/v1/entity/:namespace/:kind/:name/projects', async (request, response) => { const { namespace, kind, name } = request.params; + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); const branch = request.query.branch; let branchStr: string | undefined; @@ -67,6 +70,7 @@ export async function createRouter( namespace, name, }, + token, }); const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); @@ -79,6 +83,9 @@ export async function createRouter( router.get( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); const { namespace, kind, name, jobFullName, buildNumber } = request.params; @@ -89,6 +96,7 @@ export async function createRouter( name, }, jobFullName, + token, }); const build = await jenkinsApi.getBuild( From cc8f0076d81b347d4ee749144b3490dd7212b331 Mon Sep 17 00:00:00 2001 From: Hasan Oezdemir <21654050+nodify-at@users.noreply.github.com> Date: Wed, 6 Apr 2022 16:18:43 +0200 Subject: [PATCH 011/144] feature: rename token to backstageToken and update the documentation Signed-off-by: Hasan Oezdemir <21654050+nodify-at@users.noreply.github.com> --- plugins/jenkins-backend/api-report.md | 4 ++-- .../src/service/jenkinsInfoProvider.test.ts | 16 ++++++++-------- .../src/service/jenkinsInfoProvider.ts | 6 +++--- plugins/jenkins-backend/src/service/router.ts | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index fd20cd79cf..37e8f80a80 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -28,7 +28,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - token?: string; + backstageToken?: string; }): Promise; // (undocumented) static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; @@ -69,7 +69,7 @@ export interface JenkinsInfoProvider { getInstance(options: { entityRef: CompoundEntityRef; jobFullName?: string; - token?: string; + backstageToken?: string; }): Promise; } diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index e2d34f59b6..5e6198e1cd 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -186,7 +186,7 @@ describe('DefaultJenkinsInfoProvider', () => { await expect(provider.getInstance({ entityRef })).rejects.toThrowError(); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); }); @@ -210,7 +210,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', @@ -248,7 +248,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', @@ -287,7 +287,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', @@ -326,7 +326,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', @@ -354,7 +354,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', @@ -382,7 +382,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', @@ -415,7 +415,7 @@ describe('DefaultJenkinsInfoProvider', () => { const info: JenkinsInfo = await provider.getInstance({ entityRef }); expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { - token: undefined, + backstageToken: undefined, }); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 221acb3c51..fcce944dc8 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -33,7 +33,7 @@ export interface JenkinsInfoProvider { */ jobFullName?: string; - token?: string; + backstageToken?: string; }): Promise; } @@ -186,11 +186,11 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { async getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - token?: string; + backstageToken?: string; }): Promise { // load entity const entity = await this.catalog.getEntityByRef(opt.entityRef, { - token: opt.token, + token: opt.backstageToken, }); if (!entity) { throw new Error( diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 3e53f27e4b..03462b923f 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -70,7 +70,7 @@ export async function createRouter( namespace, name, }, - token, + backstageToken: token, }); const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); @@ -96,7 +96,7 @@ export async function createRouter( name, }, jobFullName, - token, + backstageToken: token, }); const build = await jenkinsApi.getBuild( From 0a63e99a2643aafdda23053222fdd02669bac6e7 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Wed, 23 Feb 2022 16:11:50 -0500 Subject: [PATCH 012/144] feat(search): handle search indexing coordination among nodes Signed-off-by: Phil Kuang --- .changeset/ninety-eggs-argue.md | 84 ++++++++++ docs/features/search/concepts.md | 4 +- docs/features/search/getting-started.md | 82 ++++++++-- packages/backend/package.json | 4 +- packages/backend/src/plugins/search.ts | 17 +- .../packages/backend/package.json.hbs | 4 +- .../backend/src/plugins/search.ts.hbs | 17 +- plugins/search-backend-node/api-report.md | 21 ++- plugins/search-backend-node/package.json | 2 + .../src/IndexBuilder.test.ts | 22 +-- .../search-backend-node/src/IndexBuilder.ts | 108 +++++++------ .../search-backend-node/src/Scheduler.test.ts | 145 ++++++++++++++++-- plugins/search-backend-node/src/Scheduler.ts | 50 ++++-- plugins/search-backend-node/src/index.ts | 2 + .../src/runPeriodically.test.ts | 84 ---------- .../src/runPeriodically.ts | 54 ------- plugins/search-backend-node/src/types.ts | 7 +- 17 files changed, 438 insertions(+), 269 deletions(-) create mode 100644 .changeset/ninety-eggs-argue.md delete mode 100644 plugins/search-backend-node/src/runPeriodically.test.ts delete mode 100644 plugins/search-backend-node/src/runPeriodically.ts diff --git a/.changeset/ninety-eggs-argue.md b/.changeset/ninety-eggs-argue.md new file mode 100644 index 0000000000..01f861fe4b --- /dev/null +++ b/.changeset/ninety-eggs-argue.md @@ -0,0 +1,84 @@ +--- +'@backstage/plugin-search-backend-node': minor +'@backstage/create-app': patch +--- + +**BREAKING**: `IndexBuilder.addCollator()` now requires a `schedule` parameter (replacing `defaultRefreshIntervalSeconds`) which is expected to be a `TaskRunner` that is configured with the desired search indexing schedule for the given collator. + +`Scheduler.addToSchedule()` now takes a new parameter object (`ScheduleTaskParameters`) with two new options `id` and `scheduledRunner` in addition to the migrated `task` argument. + +NOTE: The search backend plugin now creates a dedicated database for coordinating indexing tasks. + +To make this change to an existing app, make the following changes to `packages/backend/src/plugins/search.ts`: + +```diff ++import { Duration } from 'luxon'; + +/* ... */ + ++ const schedule = env.scheduler.createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ seconds: 600 }), ++ timeout: Duration.fromObject({ seconds: 900 }), ++ initialDelay: Duration.fromObject({ seconds: 3 }), ++ }); + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + + const { scheduler } = await indexBuilder.build(); +- setTimeout(() => scheduler.start(), 3000); ++ scheduler.start(); +/* ... */ +``` + +NOTE: For scenarios where the `lunr` search engine is used in a multi-node configuration, a non-distributed `TaskRunner` like the following should be implemented to ensure consistency across nodes (alternatively, you can configure +the search plugin to use a non-distributed DB such as [SQLite](https://backstage.io/docs/tutorials/configuring-plugin-databases#postgresql-and-sqlite-3)): + +```diff ++import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; + +/* ... */ + ++ const schedule: TaskRunner = { ++ run: async (task: TaskInvocationDefinition) => { ++ const startRefresh = async () => { ++ while (!task.signal?.aborted) { ++ try { ++ await task.fn(task.signal); ++ } catch { ++ // ignore intentionally ++ } ++ ++ await new Promise(resolve => setTimeout(resolve, 600 * 1000)); ++ } ++ }; ++ startRefresh(); ++ }, ++ }; + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + +/* ... */ +``` diff --git a/docs/features/search/concepts.md b/docs/features/search/concepts.md index 052f71376f..57ab061974 100644 --- a/docs/features/search/concepts.md +++ b/docs/features/search/concepts.md @@ -84,7 +84,9 @@ index-time. There are many ways a search index could be built and maintained, but Backstage Search chooses to completely rebuild indices on a schedule. Different collators can be configured to refresh at different intervals, depending on how often the -source information is updated. +source information is updated. When search indexing is distributed among multiple +backend nodes, coordination to prevent clashes is typically handled by a +distributed `TaskRunner`. ### The Search Page diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index f38d02a3d7..bc17b1eea2 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -149,6 +149,7 @@ import { import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; export default async function createPlugin( env: PluginEnvironment, @@ -161,9 +162,15 @@ export default async function createPlugin( searchEngine, }); + const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 600 }), + timeout: Duration.fromObject({ seconds: 900 }), + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), @@ -287,32 +294,87 @@ which are responsible for providing documents number of collators with the `IndexBuilder` like this: ```typescript +import { Duration } from 'luxon'; + const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine }); +const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 600 }), + timeout: Duration.fromObject({ seconds: 900 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + +const everyHourSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 3600 }), + timeout: Duration.fromObject({ seconds: 5400 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), }); indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 3600, - collator: new MyCustomCollator(), + schedule: everyHourSchedule, + factory: new MyCustomCollatorFactory(), }); ``` Backstage Search builds and maintains its index [on a schedule](./concepts.md#the-scheduler). You can change how often the indexes are rebuilt for a given type of document. You may want to do this if -your documents are updated more or less frequently. You can do so by modifying -its `defaultRefreshIntervalSeconds` value, like this: +your documents are updated more or less frequently. You can do so by configuring +a scheduled `TaskRunner` to pass into the `schedule` value, like this: ```typescript {3} +const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 600 }), + timeout: Duration.fromObject({ seconds: 900 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), +}); +``` + +Note: if you are using the in-memory Lunr search engine, you probably want to +implement a non-distributed `TaskRunner` like the following to ensure consistency +if you're running multiple search backend nodes (alternatively, you can configure +the search plugin to use a non-distributed database such as +[SQLite](../../tutorials/configuring-plugin-databases.md#postgresql-and-sqlite-3)): + +```typescript +import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; + +const schedule: TaskRunner = { + run: async (task: TaskInvocationDefinition) => { + const startRefresh = async () => { + while (!task.signal?.aborted) { + try { + await task.fn(task.signal); + } catch { + // ignore intentionally + } + + await new Promise(resolve => setTimeout(resolve, 600 * 1000)); + } + }; + startRefresh(); + }, +}; + +indexBuilder.addCollator({ + schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), diff --git a/packages/backend/package.json b/packages/backend/package.json index 3fde1cdc11..aed934a028 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -68,6 +68,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", + "luxon": "^2.0.2", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", "prom-client": "^14.0.1", @@ -77,7 +78,8 @@ "@backstage/cli": "^0.17.0-next.1", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5" + "@types/express-serve-static-core": "^4.17.5", + "@types/luxon": "^2.0.4" }, "files": [ "dist" diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index cb675a842c..052b7380c3 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -26,6 +26,7 @@ import { } from '@backstage/plugin-search-backend-node'; import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; import { PluginEnvironment } from '../types'; async function createSearchEngine( @@ -55,10 +56,18 @@ export default async function createPlugin( searchEngine, }); + const schedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 600 }), + timeout: Duration.fromObject({ seconds: 900 }), + // A 3 second delay gives the backend server a chance to initialize before + // any collators are executed, which may attempt requests against the API. + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + // Collators are responsible for gathering documents known to plugins. This // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, @@ -66,7 +75,7 @@ export default async function createPlugin( }); indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { discovery: env.discovery, logger: env.logger, @@ -77,10 +86,8 @@ export default async function createPlugin( // The scheduler controls when documents are gathered from collators and sent // to the search engine for indexing. const { scheduler } = await indexBuilder.build(); + scheduler.start(); - // A 3 second delay gives the backend server a chance to initialize before - // any collators are executed, which may attempt requests against the API. - setTimeout(() => scheduler.start(), 3000); useHotCleanup(module, () => scheduler.stop()); return await createRouter({ diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index cd4d3e1222..b91366dee8 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -40,6 +40,7 @@ "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "luxon": "^2.0.2", {{#if dbTypePG}} "pg": "^8.3.0", {{/if}} @@ -52,7 +53,8 @@ "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5" + "@types/express-serve-static-core": "^4.17.5", + "@types/luxon": "^2.0.4" }, "files": [ "dist" diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs index 8f44a35b16..bda10ae21b 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs @@ -11,6 +11,7 @@ import { PluginEnvironment } from '../types'; import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend'; import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; export default async function createPlugin( env: PluginEnvironment, @@ -31,10 +32,18 @@ export default async function createPlugin( searchEngine, }); + const schedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ seconds: 600 }), + timeout: Duration.fromObject({ seconds: 900 }), + // A 3 second delay gives the backend server a chance to initialize before + // any collators are executed, which may attempt requests against the API. + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + // Collators are responsible for gathering documents known to plugins. This // collator gathers entities from the software catalog. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, @@ -43,7 +52,7 @@ export default async function createPlugin( // collator gathers entities from techdocs. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { discovery: env.discovery, logger: env.logger, @@ -54,10 +63,8 @@ export default async function createPlugin( // The scheduler controls when documents are gathered from collators and sent // to the search engine for indexing. const { scheduler } = await indexBuilder.build(); + scheduler.start(); - // A 3 second delay gives the backend server a chance to initialize before - // any collators are executed, which may attempt requests against the API. - setTimeout(() => scheduler.start(), 3000); useHotCleanup(module, () => scheduler.stop()); return await createRouter({ diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md index ebc49df280..61b8a124f7 100644 --- a/plugins/search-backend-node/api-report.md +++ b/plugins/search-backend-node/api-report.md @@ -16,6 +16,8 @@ import { QueryTranslator } from '@backstage/plugin-search-common'; import { Readable } from 'stream'; import { SearchEngine } from '@backstage/plugin-search-common'; import { SearchQuery } from '@backstage/plugin-search-common'; +import { TaskFunction } from '@backstage/backend-tasks'; +import { TaskRunner } from '@backstage/backend-tasks'; import { Transform } from 'stream'; import { Writable } from 'stream'; @@ -52,10 +54,7 @@ export abstract class DecoratorBase extends Transform { // @beta (undocumented) export class IndexBuilder { constructor({ logger, searchEngine }: IndexBuilderOptions); - addCollator({ - factory, - defaultRefreshIntervalSeconds, - }: RegisterCollatorParameters): void; + addCollator({ factory, schedule }: RegisterCollatorParameters): void; addDecorator({ factory }: RegisterDecoratorParameters): void; build(): Promise<{ scheduler: Scheduler; @@ -111,8 +110,8 @@ export class LunrSearchEngineIndexer extends BatchSearchEngineIndexer { // @beta export interface RegisterCollatorParameters { - defaultRefreshIntervalSeconds: number; factory: DocumentCollatorFactory; + schedule: TaskRunner; } // @beta @@ -123,11 +122,21 @@ export interface RegisterDecoratorParameters { // @beta (undocumented) export class Scheduler { constructor({ logger }: { logger: Logger }); - addToSchedule(task: Function, interval: number): void; + addToSchedule({ id, task, scheduledRunner }: ScheduleTaskParameters): void; start(): void; stop(): void; } +// @public +export interface ScheduleTaskParameters { + // (undocumented) + id: string; + // (undocumented) + scheduledRunner: TaskRunner; + // (undocumented) + task: TaskFunction; +} + export { SearchEngine }; // @beta diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index e6fcac1398..3eea462c20 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -23,11 +23,13 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@backstage/backend-tasks": "^0.3.0-next.2", "@backstage/errors": "^1.0.0", "@backstage/plugin-search-common": "^0.3.3-next.1", "@types/lunr": "^2.3.3", "lodash": "^4.17.21", "lunr": "^2.3.9", + "node-abort-controller": "^3.0.1", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/search-backend-node/src/IndexBuilder.test.ts b/plugins/search-backend-node/src/IndexBuilder.test.ts index 6a32343205..70d00f6974 100644 --- a/plugins/search-backend-node/src/IndexBuilder.test.ts +++ b/plugins/search-backend-node/src/IndexBuilder.test.ts @@ -15,6 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; import { DocumentCollatorFactory, DocumentDecoratorFactory, @@ -53,9 +54,15 @@ class DifferentlyTypedDocumentDecoratorFactory extends TestDocumentDecoratorFact describe('IndexBuilder', () => { let testSearchEngine: SearchEngine; let testIndexBuilder: IndexBuilder; + let testScheduledTaskRunner: TaskRunner; beforeEach(() => { const logger = getVoidLogger(); + testScheduledTaskRunner = { + run: async (task: TaskInvocationDefinition & { fn: () => void }) => { + task.fn(); + }, + }; testSearchEngine = new LunrSearchEngine({ logger }); testIndexBuilder = new IndexBuilder({ logger, @@ -65,35 +72,32 @@ describe('IndexBuilder', () => { describe('addCollator', () => { it('adds a collator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TestDocumentCollatorFactory(); const collatorSpy = jest.spyOn(testCollatorFactory, 'getCollator'); // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Build the index and ensure the collator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); }); }); describe('addDecorator', () => { it('adds a decorator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TestDocumentCollatorFactory(); const testDecoratorFactory = new TestDocumentDecoratorFactory(); const decoratorSpy = jest.spyOn(testDecoratorFactory, 'getDecorator'); // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator. @@ -104,14 +108,12 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); }); it('adds a type-specific decorator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TypedDocumentCollatorFactory(); const testDecoratorFactory = new TypedDocumentDecoratorFactory(); jest.spyOn(testCollatorFactory, 'getCollator'); @@ -119,8 +121,8 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator for the same type. @@ -131,7 +133,6 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); @@ -146,8 +147,8 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator for a different type. @@ -158,7 +159,6 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was not invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); expect(decoratorSpy).not.toHaveBeenCalled(); }); diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 69c1b2e6fc..235bac905a 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -13,32 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { - DocumentCollatorFactory, DocumentDecoratorFactory, DocumentTypeInfo, SearchEngine, } from '@backstage/plugin-search-common'; import { Transform, pipeline } from 'stream'; import { Logger } from 'winston'; -import { Scheduler } from './index'; +import { Scheduler } from './Scheduler'; import { IndexBuilderOptions, RegisterCollatorParameters, RegisterDecoratorParameters, } from './types'; -interface CollatorEnvelope { - factory: DocumentCollatorFactory; - refreshInterval: number; -} - /** * @beta */ export class IndexBuilder { - private collators: Record; + private collators: Record; private decorators: Record; private documentTypes: Record; private searchEngine: SearchEngine; @@ -64,16 +57,13 @@ export class IndexBuilder { * Makes the index builder aware of a collator that should be executed at the * given refresh interval. */ - addCollator({ - factory, - defaultRefreshIntervalSeconds, - }: RegisterCollatorParameters): void { + addCollator({ factory, schedule }: RegisterCollatorParameters): void { this.logger.info( `Added ${factory.constructor.name} collator factory for type ${factory.type}`, ); this.collators[factory.type] = { - refreshInterval: defaultRefreshIntervalSeconds, factory, + schedule, }; this.documentTypes[factory.type] = { visibilityPermission: factory.visibilityPermission, @@ -106,51 +96,57 @@ export class IndexBuilder { * scheduler returned to the caller. */ async build(): Promise<{ scheduler: Scheduler }> { - const scheduler = new Scheduler({ logger: this.logger }); + const scheduler = new Scheduler({ + logger: this.logger, + }); Object.keys(this.collators).forEach(type => { - scheduler.addToSchedule(async () => { - // Instantiate the collator. - const collator = await this.collators[type].factory.getCollator(); - this.logger.info( - `Collating documents for ${type} via ${this.collators[type].factory.constructor.name}`, - ); - - // Instantiate all relevant decorators. - const decorators: Transform[] = await Promise.all( - (this.decorators['*'] || []) - .concat(this.decorators[type] || []) - .map(async factory => { - const decorator = await factory.getDecorator(); - this.logger.info( - `Attached decorator via ${factory.constructor.name} to ${type} index pipeline.`, - ); - return decorator; - }), - ); - - // Instantiate the indexer. - const indexer = await this.searchEngine.getIndexer(type); - - // Compose collator/decorators/indexer into a pipeline - return new Promise(done => { - pipeline( - [collator, ...decorators, indexer], - (error: NodeJS.ErrnoException | null) => { - if (error) { - this.logger.error( - `Collating documents for ${type} failed: ${error}`, - ); - } else { - this.logger.info(`Collating documents for ${type} succeeded`); - } - - // Signal index pipeline completion! - done(); - }, + scheduler.addToSchedule({ + id: `search_index_${type.replace('-', '_').toLocaleLowerCase('en-US')}`, + scheduledRunner: this.collators[type].schedule, + task: async () => { + // Instantiate the collator. + const collator = await this.collators[type].factory.getCollator(); + this.logger.info( + `Collating documents for ${type} via ${this.collators[type].factory.constructor.name}`, ); - }); - }, this.collators[type].refreshInterval * 1000); + + // Instantiate all relevant decorators. + const decorators: Transform[] = await Promise.all( + (this.decorators['*'] || []) + .concat(this.decorators[type] || []) + .map(async factory => { + const decorator = await factory.getDecorator(); + this.logger.info( + `Attached decorator via ${factory.constructor.name} to ${type} index pipeline.`, + ); + return decorator; + }), + ); + + // Instantiate the indexer. + const indexer = await this.searchEngine.getIndexer(type); + + // Compose collator/decorators/indexer into a pipeline + return new Promise(done => { + pipeline( + [collator, ...decorators, indexer], + (error: NodeJS.ErrnoException | null) => { + if (error) { + this.logger.error( + `Collating documents for ${type} failed: ${error}`, + ); + } else { + this.logger.info(`Collating documents for ${type} succeeded`); + } + + // Signal index pipeline completion! + done(); + }, + ); + }); + }, + }); }); return { diff --git a/plugins/search-backend-node/src/Scheduler.test.ts b/plugins/search-backend-node/src/Scheduler.test.ts index de6add13fe..7eaa6e8c9d 100644 --- a/plugins/search-backend-node/src/Scheduler.test.ts +++ b/plugins/search-backend-node/src/Scheduler.test.ts @@ -29,31 +29,107 @@ describe('Scheduler', () => { describe('addToSchedule', () => { it('should not add a task and interval to schedule, if already started', async () => { - jest.useFakeTimers(); const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add a task and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); // Starts scheduling process testScheduler.start(); // Throws Error if task and interval is added to a already started schedule - expect(() => testScheduler.addToSchedule(mockTask2, 2)).toThrowError(); + expect(() => + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), + ).toThrowError(); - jest.runOnlyPendingTimers(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).not.toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); + }); + + it('should not add a task to schedule, if it already exists', async () => { + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; + + // Add a task and interval to schedule + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); + + // Throws Error if task and interval is added to a already started schedule + expect(() => + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), + ).toThrowError(); + + // Starts scheduling process + testScheduler.start(); + + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); it('should be possible to add a task and interval to schedule, if already started, but stopped in between', async () => { - jest.useFakeTimers(); const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add a task and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); // Starts scheduling process testScheduler.start(); @@ -63,15 +139,28 @@ describe('Scheduler', () => { // Shouldn't throw error, as it is stopped. expect(() => - testScheduler.addToSchedule(mockTask2, 4), + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), ).not.toThrowError(); // Starts scheduling process testScheduler.start(); - jest.runOnlyPendingTimers(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); }); @@ -79,16 +168,40 @@ describe('Scheduler', () => { it('should execute tasks on start', () => { const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add tasks and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); - testScheduler.addToSchedule(mockTask2, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }); // Starts scheduling process testScheduler.start(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); }); }); diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts index 6debaa3dcd..9f3d95b112 100644 --- a/plugins/search-backend-node/src/Scheduler.ts +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -14,29 +14,38 @@ * limitations under the License. */ +import { AbortController } from 'node-abort-controller'; import { Logger } from 'winston'; -import { runPeriodically } from './runPeriodically'; +import { TaskFunction, TaskRunner } from '@backstage/backend-tasks'; type TaskEnvelope = { - task: Function; - interval: number; + task: TaskFunction; + scheduledRunner: TaskRunner; }; /** - * TODO: coordination, error handling + * @public ScheduleTaskParameters */ +export interface ScheduleTaskParameters { + id: string; + task: TaskFunction; + scheduledRunner: TaskRunner; +} /** * @beta */ export class Scheduler { private logger: Logger; - private schedule: TaskEnvelope[]; - private runningTasks: Function[] = []; + private schedule: { [id: string]: TaskEnvelope }; + private abortController: AbortController; + private isRunning: boolean; constructor({ logger }: { logger: Logger }) { this.logger = logger; - this.schedule = []; + this.schedule = {}; + this.abortController = new AbortController(); + this.isRunning = false; } /** @@ -44,13 +53,18 @@ export class Scheduler { * When running the tasks, the scheduler waits at least for the time specified * in the interval once the task was completed, before running it again. */ - addToSchedule(task: Function, interval: number) { - if (this.runningTasks.length) { + addToSchedule({ id, task, scheduledRunner }: ScheduleTaskParameters) { + if (this.isRunning) { throw new Error( 'Cannot add task to schedule that has already been started.', ); } - this.schedule.push({ task, interval }); + + if (this.schedule[id]) { + throw new Error(`Task with id ${id} already exists.`); + } + + this.schedule[id] = { task, scheduledRunner }; } /** @@ -58,8 +72,14 @@ export class Scheduler { */ start() { this.logger.info('Starting all scheduled search tasks.'); - this.schedule.forEach(({ task, interval }) => { - this.runningTasks.push(runPeriodically(() => task(), interval)); + this.isRunning = true; + Object.keys(this.schedule).forEach(id => { + const { task, scheduledRunner } = this.schedule[id]; + scheduledRunner.run({ + id, + fn: task, + signal: this.abortController.signal, + }); }); } @@ -68,9 +88,7 @@ export class Scheduler { */ stop() { this.logger.info('Stopping all scheduled search tasks.'); - this.runningTasks.forEach(cancel => { - cancel(); - }); - this.runningTasks = []; + this.abortController.abort(); + this.isRunning = false; } } diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index e84c5d6a4d..e5202ac640 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -36,6 +36,8 @@ export type { export * from './indexing'; export * from './test-utils'; +export type { ScheduleTaskParameters } from './Scheduler'; + /** * @deprecated Import from @backstage/plugin-search-common instead */ diff --git a/plugins/search-backend-node/src/runPeriodically.test.ts b/plugins/search-backend-node/src/runPeriodically.test.ts deleted file mode 100644 index 0f2ee44b8d..0000000000 --- a/plugins/search-backend-node/src/runPeriodically.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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 { runPeriodically } from './runPeriodically'; - -jest.useFakeTimers(); - -describe('runPeriodically', () => { - const flushPromises = async () => { - const promise = new Promise(resolve => process.nextTick(resolve)); - jest.runAllTicks(); - await promise; - }; - const advanceTimersByTime = async (time: number) => { - jest.advanceTimersByTime(time); - // Advancing the time with jest doesn't run all promises, but only sync code - await flushPromises(); - }; - - it('runs task initially', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - expect(task).toHaveBeenCalledTimes(1); - cancel(); - }); - - it('runs at requested interval', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(3); - cancel(); - }); - - it('stops after being canceled', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - cancel(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(1); - }); - - it('continues running after failures', async () => { - const task = jest.fn(async () => { - throw new Error(); - }); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(3); - cancel(); - }); - - it('waits till a long running task is completed', async () => { - const task = jest.fn( - () => new Promise(resolve => setTimeout(resolve, 10000)), - ); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(1); - await advanceTimersByTime(9000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(2); - cancel(); - }); -}); diff --git a/plugins/search-backend-node/src/runPeriodically.ts b/plugins/search-backend-node/src/runPeriodically.ts deleted file mode 100644 index 2f3104e221..0000000000 --- a/plugins/search-backend-node/src/runPeriodically.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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. - */ - -/** - * Runs a function repeatedly, with a fixed wait between invocations. - * - * Supports async functions, and silently ignores exceptions and rejections. - * - * @param fn - The function to run. May return a Promise. - * @param delayMs - The delay between a completed function invocation and the - * next. - * @returns A function that, when called, stops the invocation loop. - */ -export function runPeriodically(fn: () => any, delayMs: number): () => void { - let cancel: () => void; - let cancelled = false; - const cancellationPromise = new Promise(resolve => { - cancel = () => { - resolve(); - cancelled = true; - }; - }); - - const startRefresh = async () => { - while (!cancelled) { - try { - await fn(); - } catch { - // ignore intentionally - } - - await Promise.race([ - new Promise(resolve => setTimeout(resolve, delayMs)), - cancellationPromise, - ]); - } - }; - startRefresh(); - - return cancel!; -} diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts index d21bfaf453..a92398e8c8 100644 --- a/plugins/search-backend-node/src/types.ts +++ b/plugins/search-backend-node/src/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { TaskRunner } from '@backstage/backend-tasks'; import { DocumentCollatorFactory, DocumentDecoratorFactory, @@ -35,10 +36,10 @@ export type IndexBuilderOptions = { */ export interface RegisterCollatorParameters { /** - * The default interval (in seconds) that the provided collator will be called (can be overridden in config). + * The schedule for which the provided collator will be called, commonly the result of + * {@link @backstage/backend-tasks#PluginTaskScheduler.createScheduledTaskRunner} */ - defaultRefreshIntervalSeconds: number; - + schedule: TaskRunner; /** * The class responsible for returning the document collator of the given type. */ From 863e7bcb7bb3625f373296d3f60a1c51ba202e11 Mon Sep 17 00:00:00 2001 From: Joe Porpeglia Date: Wed, 6 Apr 2022 15:42:45 -0400 Subject: [PATCH 013/144] Remove explicit entity deletion from UnregisterEntityDialog Signed-off-by: Joe Porpeglia --- .changeset/proud-teachers-draw.md | 5 +++++ .../useUnregisterEntityDialogState.ts | 13 ++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 .changeset/proud-teachers-draw.md diff --git a/.changeset/proud-teachers-draw.md b/.changeset/proud-teachers-draw.md new file mode 100644 index 0000000000..dbce78c3c2 --- /dev/null +++ b/.changeset/proud-teachers-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Updated the "unregister location" behavior in `UnregisterEntityDialog`. Removed unnecessary entity deletion requests that were sent after successfully deleting a location. diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts index c14b6523c0..28e4863e8a 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts @@ -95,18 +95,13 @@ export function useUnregisterEntityDialogState( ); }, [catalogApi, entity]); - // Unregisters the underlying location and removes all of the entities that - // are spawned from it. Can only ever be called when the prerequisites have - // finished loading successfully, and if there was a matching location. + // Unregisters the underlying location which will remove all of the entities that are spawned from + // it. Can only ever be called when the prerequisites have finished loading successfully, and if + // there was a matching location. const unregisterLocation = useCallback( async function unregisterLocationFn() { - const { location, colocatedEntities } = prerequisites.value!; + const { location } = prerequisites.value!; await catalogApi.removeLocationById(location!.id); - await Promise.allSettled( - colocatedEntities.map(e => - catalogApi.removeEntityByUid(e.metadata.uid!), - ), - ); }, [catalogApi, prerequisites], ); From 0f8bb7e3515878bad3ecf8cd16be72caefe00124 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Mon, 4 Apr 2022 19:47:05 +0200 Subject: [PATCH 014/144] ci(prettier): ignore `ADOPTERS.md` using `.prettierignore` Ignore `ADOPTERS.md` using `.prettierignore` instead of ignoring it at the GitHub action config. This will cause the same rules to be applied locally as these checks. Signed-off-by: Patrick Jungermann --- .github/workflows/ci.yml | 2 +- .prettierignore | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f0c6e7602..31705a146b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: continue-on-error: true - name: prettier - run: yarn prettier:check '!ADOPTERS.md' + run: yarn prettier:check - name: lock run: yarn lock:check diff --git a/.prettierignore b/.prettierignore index e60d54a466..393c35fdbe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ api-report.md plugins/scaffolder-backend/sample-templates .vscode dist-types + +# reduce the barrier for adopters to add themselves +ADOPTERS.md From c28dc218b2404ed347e28f3d1b03b548dbe729d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:09:55 +0000 Subject: [PATCH 015/144] build(deps): bump zod from 3.14.2 to 3.14.4 Bumps [zod](https://github.com/colinhacks/zod) from 3.14.2 to 3.14.4. - [Release notes](https://github.com/colinhacks/zod/releases) - [Changelog](https://github.com/colinhacks/zod/blob/master/CHANGELOG.md) - [Commits](https://github.com/colinhacks/zod/compare/v3.14.2...v3.14.4) --- updated-dependencies: - dependency-name: zod dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..e28c291161 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25895,9 +25895,9 @@ zip-stream@^4.1.0: readable-stream "^3.6.0" zod@^3.11.6, zod@^3.9.5: - version "3.14.2" - resolved "https://registry.npmjs.org/zod/-/zod-3.14.2.tgz#0b4ed79085c471adce0e7f2c0a4fbb5ddc516ba2" - integrity sha512-iF+wrtzz7fQfkmn60PG6XFxaWBhYYKzp2i+nv24WbLUWb2JjymdkHlzBwP0erpc78WotwP5g9AAu7Sk8GWVVNw== + version "3.14.4" + resolved "https://registry.npmjs.org/zod/-/zod-3.14.4.tgz#e678fe9e5469f4663165a5c35c8f3dc66334a5d6" + integrity sha512-U9BFLb2GO34Sfo9IUYp0w3wJLlmcyGoMd75qU9yf+DrdGA4kEx6e+l9KOkAlyUO0PSQzZCa3TR4qVlcmwqSDuw== zustand@3.6.9: version "3.6.9" From 0d2f31f5f76edf9982e2901b28ef22b3cef0d8d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:11:39 +0000 Subject: [PATCH 016/144] build(deps): bump @codemirror/stream-parser from 0.19.7 to 0.19.9 Bumps [@codemirror/stream-parser](https://github.com/codemirror/stream-parser) from 0.19.7 to 0.19.9. - [Release notes](https://github.com/codemirror/stream-parser/releases) - [Changelog](https://github.com/codemirror/stream-parser/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/stream-parser/compare/0.19.7...0.19.9) --- updated-dependencies: - dependency-name: "@codemirror/stream-parser" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..a4c89b2801 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,9 +2012,9 @@ "@codemirror/text" "^0.19.0" "@codemirror/stream-parser@^0.19.0", "@codemirror/stream-parser@^0.19.2", "@codemirror/stream-parser@^0.19.6": - version "0.19.7" - resolved "https://registry.npmjs.org/@codemirror/stream-parser/-/stream-parser-0.19.7.tgz#99a9696307b90677f6e3abe29ea6d77c1c0a8db5" - integrity sha512-4ExcbKksmU4PIT8s11/dPESgMNLQqlMlbQzRQ1CyMcmygcuQk+58zQ84nv/X17OCUd2ephZ1DKEaHHACifzCiA== + version "0.19.9" + resolved "https://registry.npmjs.org/@codemirror/stream-parser/-/stream-parser-0.19.9.tgz#34955ea91a8047cf72abebd5ce28f0d332aeca48" + integrity sha512-WTmkEFSRCetpk8xIOvV2yyXdZs3DgYckM0IP7eFi4ewlxWnJO/H4BeJZLs4wQaydWsAqTQoDyIwNH1BCzK5LUQ== dependencies: "@codemirror/highlight" "^0.19.0" "@codemirror/language" "^0.19.0" From b6ce3084afc16763447657ca78a97a6292ce5071 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:14:25 +0000 Subject: [PATCH 017/144] build(deps): bump @types/luxon from 2.3.0 to 2.3.1 Bumps [@types/luxon](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/luxon) from 2.3.0 to 2.3.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/luxon) --- updated-dependencies: - dependency-name: "@types/luxon" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..5fa16c61e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6353,9 +6353,9 @@ integrity sha512-j4x4XJwZvorEUbA519VdQ5b9AOU9TSvfi8tvxMAfP8XzNLtFex7A8vFQwqOx3WACbV0KMXbACV3cZl4/gynQ7g== "@types/luxon@^2.0.4", "@types/luxon@^2.0.5", "@types/luxon@^2.0.9": - version "2.3.0" - resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.0.tgz#0f4d912c385e47890374cb694da9bc93bacbe2b0" - integrity sha512-mWXdRlg+5dWvxU+uaijB2RY5NrJtMEXR6j+D6W66hPuezSVXrQqQvWa/JNHntgEYgjzeoVRrQVmMWAbKjUJiFQ== + version "2.3.1" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz#e34763178b46232e4c5f079f1706e18692415519" + integrity sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ== "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.3" From 23efc119b3407517aefcdf3e7f1c54143eeb892b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:15:33 +0000 Subject: [PATCH 018/144] build(deps-dev): bump @types/react-dom from 17.0.11 to 17.0.14 Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 17.0.11 to 17.0.14. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom) --- updated-dependencies: - dependency-name: "@types/react-dom" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..abda67a10f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6575,9 +6575,9 @@ integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== "@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.11" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" - integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== + version "17.0.14" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" + integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ== dependencies: "@types/react" "*" From 508a1a59dfcd4d058c130fb905b8e7825d56f75f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:16:22 +0000 Subject: [PATCH 019/144] build(deps): bump rollup-plugin-esbuild from 4.7.2 to 4.9.1 Bumps [rollup-plugin-esbuild](https://github.com/egoist/rollup-plugin-esbuild) from 4.7.2 to 4.9.1. - [Release notes](https://github.com/egoist/rollup-plugin-esbuild/releases) - [Changelog](https://github.com/egoist/rollup-plugin-esbuild/blob/dev/.releaserc.json) - [Commits](https://github.com/egoist/rollup-plugin-esbuild/compare/v4.7.2...v4.9.1) --- updated-dependencies: - dependency-name: rollup-plugin-esbuild dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..381338b99f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11610,7 +11610,7 @@ es-abstract@^1.17.0-next.1, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.1" -es-module-lexer@^0.9.0: +es-module-lexer@^0.9.0, es-module-lexer@^0.9.3: version "0.9.3" resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== @@ -22035,11 +22035,13 @@ rollup-plugin-dts@^4.0.1: "@babel/code-frame" "^7.16.7" rollup-plugin-esbuild@^4.7.2: - version "4.7.2" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-4.7.2.tgz#1a496a9f96257cdf5ed800e818932859232471f8" - integrity sha512-rBS2hTedtG+wL/yyIWQ84zju5rtfF15gkaCLN0vsWGmBdRd0UPm52meAwkmrsPQf3mB/H2o+k9Q8Ce8A66SE5A== + version "4.9.1" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-4.9.1.tgz#369d137e2b1542c8ee459495fd4f10de812666aa" + integrity sha512-qn/x7Wz9p3Xnva99qcb+nopH0d2VJwVnsxJTGEg+Sh2Z3tqQl33MhOwzekVo1YTKgv+yAmosjcBRJygMfGrtLw== dependencies: "@rollup/pluginutils" "^4.1.1" + debug "^4.3.3" + es-module-lexer "^0.9.3" joycon "^3.0.1" jsonc-parser "^3.0.0" From 82e2ae6ff6f79abc1c07dec38ea8a0b1658e8624 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:17:27 +0000 Subject: [PATCH 020/144] build(deps): bump cronstrue from 1.125.0 to 2.2.0 Bumps [cronstrue](https://github.com/bradymholt/cronstrue) from 1.125.0 to 2.2.0. - [Release notes](https://github.com/bradymholt/cronstrue/releases) - [Commits](https://github.com/bradymholt/cronstrue/compare/v1.125.0...v2.2.0) --- updated-dependencies: - dependency-name: cronstrue dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .changeset/dependabot-456f3f4.md | 5 +++++ plugins/kubernetes/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/dependabot-456f3f4.md diff --git a/.changeset/dependabot-456f3f4.md b/.changeset/dependabot-456f3f4.md new file mode 100644 index 0000000000..1ccd243715 --- /dev/null +++ b/.changeset/dependabot-456f3f4.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +build(deps): bump `cronstrue` from 1.125.0 to 2.2.0 diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index c5496226ee..433ba5538e 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -46,7 +46,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@types/react": "^16.13.1 || ^17.0.0", - "cronstrue": "^1.122.0", + "cronstrue": "^2.2.0", "js-yaml": "^4.0.0", "lodash": "^4.17.21", "luxon": "^2.0.2", diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..2e2e12cca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10069,10 +10069,10 @@ cron@^1.8.2: dependencies: moment-timezone "^0.5.x" -cronstrue@^1.122.0: - version "1.125.0" - resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.125.0.tgz#8030816d033d00caade9b2a9f9b71e69175bcf42" - integrity sha512-qkC5mVbVGuuyBVXmam5anaRtbLcgfBUKajoyZqCdf/XBdgF43PsLSEm8eEi2dsI3YbqDPbLSH2mWNzM1dVqHgQ== +cronstrue@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-2.2.0.tgz#8e02b8ef0fa70a9eab9999f1f838df4bd378b471" + integrity sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw== cross-env@^7.0.0: version "7.0.3" From df6f8741a2d0d60e2af1d219ec857b6973974ea4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:33:58 +0000 Subject: [PATCH 021/144] build(deps): bump keyv from 4.1.1 to 4.2.2 Bumps [keyv](https://github.com/jaredwray/keyv) from 4.1.1 to 4.2.2. - [Release notes](https://github.com/jaredwray/keyv/releases) - [Commits](https://github.com/jaredwray/keyv/commits) --- updated-dependencies: - dependency-name: keyv dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..f22367ce69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6282,6 +6282,11 @@ ast-types "^0.14.1" recast "^0.20.3" +"@types/json-buffer@~3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64" + integrity sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ== + "@types/json-schema-merge-allof@^0.6.0": version "0.6.1" resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.1.tgz#c476c2aeed04d8a14882ff872de9ddeca5608e0b" @@ -9641,6 +9646,14 @@ component-inherit@0.0.3: resolved "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM= +compress-brotli@^1.3.6: + version "1.3.6" + resolved "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.6.tgz#64bd6f21f4f3e9841dbac392f4c29218caf5e9d9" + integrity sha512-au99/GqZtUtiCBliqLFbWlhnCxn+XSYjwZ77q6mKN4La4qOXDoLVPZ50iXr0WmAyMxl8yqoq3Yq4OeQNPPkyeQ== + dependencies: + "@types/json-buffer" "~3.0.0" + json-buffer "~3.0.1" + compress-commons@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.0.tgz#25ec7a4528852ccd1d441a7d4353cd0ece11371b" @@ -15940,7 +15953,7 @@ json-buffer@3.0.0: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= -json-buffer@3.0.1, json-buffer@^3.0.1: +json-buffer@3.0.1, json-buffer@^3.0.1, json-buffer@~3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== @@ -16309,10 +16322,11 @@ keyv@^3.0.0: json-buffer "3.0.0" keyv@^4.0.0, keyv@^4.0.3: - version "4.1.1" - resolved "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz#02c538bfdbd2a9308cc932d4096f05ae42bfa06a" - integrity sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ== + version "4.2.2" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.2.2.tgz#4b6f602c0228ef4d8214c03c520bef469ed6b768" + integrity sha512-uYS0vKTlBIjNCAUqrjlxmruxOEiZxZIHXyp32sdcGmP+ukFrmWUnE//RcPXJH3Vxrni1H2gsQbjHE0bH7MtMQQ== dependencies: + compress-brotli "^1.3.6" json-buffer "3.0.1" kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: From 9c31eef71f211c2f36ab1c2718691c00f1108fd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 04:50:10 +0000 Subject: [PATCH 022/144] build(deps): bump sucrase from 3.20.3 to 3.21.0 in /storybook Bumps [sucrase](https://github.com/alangpierce/sucrase) from 3.20.3 to 3.21.0. - [Release notes](https://github.com/alangpierce/sucrase/releases) - [Changelog](https://github.com/alangpierce/sucrase/blob/main/CHANGELOG.md) - [Commits](https://github.com/alangpierce/sucrase/commits) --- updated-dependencies: - dependency-name: sucrase dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- storybook/package.json | 2 +- storybook/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/storybook/package.json b/storybook/package.json index 0aeb541955..d5b54d193d 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@sucrase/webpack-loader": "^2.0.0", - "sucrase": "^3.20.3", + "sucrase": "^3.21.0", "react": "^17.0.2", "react-hot-loader": "^4.13.0", "react-dom": "^17.0.2" diff --git a/storybook/yarn.lock b/storybook/yarn.lock index a8ce5d1ff7..82520ab5f5 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -8001,10 +8001,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -sucrase@^3.20.3: - version "3.20.3" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.20.3.tgz#424f1e75b77f955724b06060f1ae708f5f0935cf" - integrity sha512-azqwq0/Bs6RzLAdb4dXxsCgMtAaD2hzmUr4UhSfsxO46JFPAwMnnb441B/qsudZiS6Ylea3JXZe3Q497lsgXzQ== +sucrase@^3.21.0: + version "3.21.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.21.0.tgz#6a5affdbe716b22e4dc99c57d366ad0d216444b9" + integrity sha512-FjAhMJjDcifARI7bZej0Bi1yekjWQHoEvWIXhLPwDhC6O4iZ5PtGb86WV56riW87hzpgB13wwBKO9vKAiWu5VQ== dependencies: commander "^4.0.0" glob "7.1.6" From 5969c4b65c94052f044ca8b089defd97c4c065f7 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Fri, 25 Mar 2022 22:47:51 +0100 Subject: [PATCH 023/144] feat: add `AwsS3EntityProvider` as replacement for `AwsS3DiscoveryProcessor` Add a new provider `AwsS3EntityProvider` as a replacement for the now deprecated `AwsS3DiscoveryProcessor`. The new provider will scan configured S3 buckets (with optional) prefix and add `Location` entities for all discovered catalog files. These `Location` entities will then be processed as usual. At each execution, the provider will apply a full mutation, replacing all previous entities with the new entities/state. Relates-to: #10183 Signed-off-by: Patrick Jungermann --- .changeset/friendly-hairs-happen.md | 76 +++++++ docs/integrations/aws-s3/discovery.md | 81 +++++-- .../catalog-backend-module-aws/api-report.md | 21 ++ .../catalog-backend-module-aws/config.d.ts | 32 +++ .../catalog-backend-module-aws/package.json | 3 + .../src/credentials/AwsCredentials.ts | 61 +++++ .../catalog-backend-module-aws/src/index.ts | 1 + .../src/providers/AwsS3EntityProvider.test.ts | 196 +++++++++++++++++ .../src/providers/AwsS3EntityProvider.ts | 208 ++++++++++++++++++ .../src/providers/config.test.ts | 98 +++++++++ .../src/providers/config.ts | 55 +++++ .../src/providers/index.ts | 17 ++ .../src/providers/types.ts | 22 ++ 13 files changed, 858 insertions(+), 13 deletions(-) create mode 100644 .changeset/friendly-hairs-happen.md create mode 100644 plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/config.test.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/config.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/index.ts create mode 100644 plugins/catalog-backend-module-aws/src/providers/types.ts diff --git a/.changeset/friendly-hairs-happen.md b/.changeset/friendly-hairs-happen.md new file mode 100644 index 0000000000..219d5efbcd --- /dev/null +++ b/.changeset/friendly-hairs-happen.md @@ -0,0 +1,76 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +Add a new provider `AwsS3EntityProvider` as replacement for `AwsS3DiscoveryProcessor`. + +In order to migrate from the `AwsS3DiscoveryProcessor` you need to apply +the following changes: + +**Before:** + +```yaml +# app-config.yaml + +catalog: + locations: + - type: s3-discovery + target: https://sample-bucket.s3.us-east-2.amazonaws.com/prefix/ +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + +import { AwsS3DiscoveryProcessor } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors ... */ +builder.addProcessor(new AwsS3DiscoveryProcessor(env.reader)); +``` + +**After:** + +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + yourProviderId: # identifies your dataset / provider independent of config changes + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + +import { AwsS3EntityProvider } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors and/or providers ... */ +builder.addEntityProvider( + ...AwsS3EntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 30 }), + timeout: Duration.fromObject({ minutes: 3 }), + }), + }), +); +``` + +For simple setups, you can omit the provider ID at the config +which has the same effect as using `default` for it. + +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + # uses "default" as provider ID + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` diff --git a/docs/integrations/aws-s3/discovery.md b/docs/integrations/aws-s3/discovery.md index 2c25aaaf05..316b5c5014 100644 --- a/docs/integrations/aws-s3/discovery.md +++ b/docs/integrations/aws-s3/discovery.md @@ -6,32 +6,55 @@ sidebar_label: Discovery description: Automatically discovering catalog entities from an AWS S3 Bucket --- -The AWS S3 integration has a special discovery processor for discovering catalog +The AWS S3 integration has a special entity provider for discovering catalog entities located in an S3 Bucket. If you have a bucket that contains multiple -catalog-info files and want to automatically discover them, you can use this -processor. The processor will crawl your S3 bucket and register entities +catalog files, and you want to automatically discover them, you can use this +provider. The provider will crawl your S3 bucket and register entities matching the configured path. This can be useful as an alternative to static locations or manually adding things to the catalog. -To use the discovery processor, you'll need an AWS S3 integration -[set up](locations.md) with an `AWS_ACCESS_KEY`, `AWS_SECRET_ACCESS_KEY`, and -optionally a `roleArn`. Then you can add a location target to the catalog -configuration: +To use the entity provider, you'll need an AWS S3 integration +[set up](locations.md) with `accessKeyId` and `secretAccessKey`, and/or +a `roleArn` or none of these (e.g., profile- or instance-based credentials). + +At production deployments, you likely manage these with the permissions attached +to your instance. + +At your configuration, you add a provider config per bucket: ```yaml +# app-config.yaml + catalog: - locations: - - type: s3-discovery - target: https://sample-bucket.s3.us-east-2.amazonaws.com/ + providers: + awsS3: + yourProviderId: # identifies your dataset / provider independent of config changes + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise ``` -Note the `s3-discovery` type, as this is not a regular `url` processor. +For simple setups, you can omit the provider ID at the config +which has the same effect as using `default` for it. -As this processor is not one of the default providers, you will first need to install the AWS catalog plugin: +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + # uses "default" as provider ID + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` + +As this provider is not one of the default providers, you will first need to install +the AWS catalog plugin: ```bash # From the Backstage root directory -yarn install --cwd packages/backend @backstage/plugin-catalog-backend-module-aws +yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-aws ``` Once you've done that, you'll also need to add the segment below to `packages/backend/src/plugins/catalog.ts`: @@ -39,6 +62,38 @@ Once you've done that, you'll also need to add the segment below to `packages/ba ```ts /* packages/backend/src/plugins/catalog.ts */ +import { AwsS3EntityProvider } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors and/or providers ... */ +builder.addEntityProvider( + ...AwsS3EntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 30 }), + timeout: Duration.fromObject({ minutes: 3 }), + }), + }), +); +``` + +## Alternative Processor + +As alternative to the entity provider `AwsS3EntityProvider` +you can still use the `AwsS3DiscoveryProcessor`. + +```yaml +# app-config.yaml + +catalog: + locations: + - type: s3-discovery + target: https://sample-bucket.s3.us-east-2.amazonaws.com/prefix/ +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + import { AwsS3DiscoveryProcessor } from '@backstage/plugin-catalog-backend-module-aws'; const builder = await CatalogBuilder.create(env); diff --git a/plugins/catalog-backend-module-aws/api-report.md b/plugins/catalog-backend-module-aws/api-report.md index 876562fc8d..c2c18431b9 100644 --- a/plugins/catalog-backend-module-aws/api-report.md +++ b/plugins/catalog-backend-module-aws/api-report.md @@ -7,8 +7,11 @@ import { CatalogProcessor } from '@backstage/plugin-catalog-backend'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; +import { EntityProvider } from '@backstage/plugin-catalog-backend'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-backend'; import { LocationSpec } from '@backstage/plugin-catalog-backend'; import { Logger } from 'winston'; +import { TaskRunner } from '@backstage/backend-tasks'; import { UrlReader } from '@backstage/backend-common'; // @public @@ -43,4 +46,22 @@ export class AwsS3DiscoveryProcessor implements CatalogProcessor { parser: CatalogProcessorParser, ): Promise; } + +// @public +export class AwsS3EntityProvider implements EntityProvider { + // (undocumented) + connect(connection: EntityProviderConnection): Promise; + // (undocumented) + static fromConfig( + configRoot: Config, + options: { + logger: Logger; + schedule: TaskRunner; + }, + ): AwsS3EntityProvider[]; + // (undocumented) + getProviderName(): string; + // (undocumented) + refresh(logger: Logger): Promise; +} ``` diff --git a/plugins/catalog-backend-module-aws/config.d.ts b/plugins/catalog-backend-module-aws/config.d.ts index bb416a576e..2e41d60aac 100644 --- a/plugins/catalog-backend-module-aws/config.d.ts +++ b/plugins/catalog-backend-module-aws/config.d.ts @@ -14,6 +14,27 @@ * limitations under the License. */ +interface AwsS3Config { + /** + * (Required) AWS S3 Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) AWS S3 Object key prefix + * If not set, all keys will be accepted, no filtering will be applied. + * @visibility backend + */ + prefix?: string; + /** + * (Optional) AWS Region. + * If not set, AWS_REGION environment variable or aws config file will be used. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + * @visibility backend + */ + region?: string; +} + export interface Config { catalog?: { /** @@ -32,5 +53,16 @@ export interface Config { }; }; }; + /** + * List of provider-specific options and attributes + */ + providers?: { + /** + * AwsS3EntityProvider configuration + * + * Uses "default" as default id for the single config variant. + */ + awsS3?: AwsS3Config | Record; + }; }; } diff --git a/plugins/catalog-backend-module-aws/package.json b/plugins/catalog-backend-module-aws/package.json index 23b5055e6a..a115efeacb 100644 --- a/plugins/catalog-backend-module-aws/package.json +++ b/plugins/catalog-backend-module-aws/package.json @@ -34,14 +34,17 @@ }, "dependencies": { "@backstage/backend-common": "^0.13.2-next.1", + "@backstage/backend-tasks": "^0.3.0-next.1", "@backstage/catalog-model": "^1.0.1-next.0", "@backstage/config": "^1.0.0", "@backstage/errors": "^1.0.0", + "@backstage/integration": "^1.1.0-next.1", "@backstage/plugin-catalog-backend": "^1.1.0-next.1", "@backstage/types": "^1.0.0", "aws-sdk": "^2.840.0", "lodash": "^4.17.21", "p-limit": "^3.0.2", + "uuid": "^8.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts new file mode 100644 index 0000000000..ffb84d1c34 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 aws, { Credentials } from 'aws-sdk'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; + +export class AwsCredentials { + /** + * If accessKeyId and secretAccessKey are missing, the DefaultAWSCredentialsProviderChain will be used: + * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html + */ + static create( + config: { + accessKeyId?: string; + secretAccessKey?: string; + roleArn?: string; + }, + roleSessionName: string, + ): Credentials | CredentialsOptions | undefined { + if (!config) { + return undefined; + } + + const accessKeyId = config.accessKeyId; + const secretAccessKey = config.secretAccessKey; + let explicitCredentials: Credentials | undefined; + + if (accessKeyId && secretAccessKey) { + explicitCredentials = new Credentials({ + accessKeyId, + secretAccessKey, + }); + } + + const roleArn = config.roleArn; + if (roleArn) { + return new aws.ChainableTemporaryCredentials({ + masterCredentials: explicitCredentials, + params: { + RoleArn: roleArn, + RoleSessionName: roleSessionName, + }, + }); + } + + return explicitCredentials; + } +} diff --git a/plugins/catalog-backend-module-aws/src/index.ts b/plugins/catalog-backend-module-aws/src/index.ts index 62c10a0f6d..1d8e844895 100644 --- a/plugins/catalog-backend-module-aws/src/index.ts +++ b/plugins/catalog-backend-module-aws/src/index.ts @@ -21,3 +21,4 @@ */ export * from './processors'; +export * from './providers'; diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts new file mode 100644 index 0000000000..5e5901a770 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; +import { ConfigReader } from '@backstage/config'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-backend'; +import { AwsS3EntityProvider } from './AwsS3EntityProvider'; +import aws from 'aws-sdk'; +import AWSMock from 'aws-sdk-mock'; + +class PersistingTaskRunner implements TaskRunner { + private tasks: TaskInvocationDefinition[] = []; + + getTasks() { + return this.tasks; + } + + run(task: TaskInvocationDefinition): Promise { + this.tasks.push(task); + return Promise.resolve(undefined); + } +} + +const logger = getVoidLogger(); + +describe('AwsS3EntityProvider', () => { + const config = new ConfigReader({ + catalog: { + providers: { + awsS3: { + anyProviderId: { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }, + }, + }, + }, + }); + + const schedule = new PersistingTaskRunner(); + + AWSMock.setSDKInstance(aws); + const createObjectList = (...keys: string[]): aws.S3.ObjectList => { + const objects = keys.map(key => { + return { + Key: key, + } as aws.S3.Types.Object; + }); + + return objects as aws.S3.ObjectList; + }; + + AWSMock.mock('S3', 'listObjectsV2', async req => { + const prefix = req.Prefix ?? ''; + + if (!req.ContinuationToken) { + return { + Contents: createObjectList(`${prefix}key1.yaml`, `${prefix}key2.yaml`), + NextContinuationToken: 'next-token', + } as aws.S3.Types.ListObjectsV2Output; + } + + return { + Contents: createObjectList(`${prefix}key3.yaml`, `${prefix}key4.yaml`), + } as aws.S3.Types.ListObjectsV2Output; + }); + + afterEach(() => jest.resetAllMocks()); + + it('apply full update on scheduled execution', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + }; + + const provider = AwsS3EntityProvider.fromConfig(config, { + logger, + schedule, + })[0]; + expect(provider.getProviderName()).toEqual('awsS3-provider:anyProviderId'); + + await provider.connect(entityProviderConnection); + + const taskDef = schedule.getTasks()[0]; + expect(taskDef.id).toEqual('awsS3-provider:anyProviderId:refresh'); + await (taskDef.fn as () => Promise)(); + + expect(entityProviderConnection.applyMutation).toBeCalledWith({ + type: 'full', + entities: [ + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + }, + name: 'generated-980e6ad47fbfbfeead708a9c7c87331b7540296a', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + }, + name: 'generated-266794d8e789089dddba2b42cd79e70b149aa61c', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + }, + name: 'generated-96f0cdcd7e33aa687c19d160ec7d5b1975cb9ea1', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + }, + name: 'generated-cd1a799b5ecfc055a0c672654420af3afeb648d3', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + ], + }); + }); +}); diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts new file mode 100644 index 0000000000..f068e40130 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { TaskRunner } from '@backstage/backend-tasks'; +import { Config } from '@backstage/config'; +import { AwsS3Integration, ScmIntegrations } from '@backstage/integration'; +import { + EntityProvider, + EntityProviderConnection, + LocationSpec, + locationSpecToLocationEntity, +} from '@backstage/plugin-catalog-backend'; +import { AwsCredentials } from '../credentials/AwsCredentials'; +import { readAwsS3Configs } from './config'; +import { AwsS3Config } from './types'; +import { S3 } from 'aws-sdk'; +import { ListObjectsV2Output } from 'aws-sdk/clients/s3'; +import * as uuid from 'uuid'; +import { Logger } from 'winston'; + +// TODO: event-based updates using S3 events (+ queue like SQS)? +/** + * Provider which discovers catalog files (any name) within an S3 bucket. + * + * Use `AwsS3EntityProvider.fromConfig(...)` to create instances. + * + * @public + */ +export class AwsS3EntityProvider implements EntityProvider { + private readonly logger: Logger; + private readonly s3: S3; + private readonly scheduleFn: () => Promise; + private connection?: EntityProviderConnection; + + static fromConfig( + configRoot: Config, + options: { + logger: Logger; + schedule: TaskRunner; + }, + ): AwsS3EntityProvider[] { + const providerConfigs = readAwsS3Configs(configRoot); + + // Even though the awsS3 integration allows a config array + // there is no *real* support for multiple configs. + // Usually, there will be just the integration for the default host. + // In case, a config custom endpoint is used, the host from this endpoint + // will be extracted and used as host (e.g., localhost when used with LocalStack) + // and the default integration will be added as second integration. + // In this case, we still want the first one though, but have no means to select it + // just from the bucket name (and region). + const integration = ScmIntegrations.fromConfig(configRoot).awsS3.list()[0]; + + return providerConfigs.map( + providerConfig => + new AwsS3EntityProvider( + providerConfig, + integration, + options.logger, + options.schedule, + ), + ); + } + + private constructor( + private readonly config: AwsS3Config, + private readonly integration: AwsS3Integration, + logger: Logger, + schedule: TaskRunner, + ) { + this.logger = logger.child({ + target: this.getProviderName(), + }); + + this.s3 = new S3({ + apiVersion: '2006-03-01', + credentials: AwsCredentials.create( + integration.config, + 'backstage-aws-s3-provider', + ), + endpoint: integration.config.endpoint, + region: this.config.region, + s3ForcePathStyle: integration.config.s3ForcePathStyle, + }); + + this.scheduleFn = this.createScheduleFn(schedule); + } + + private createScheduleFn(schedule: TaskRunner): () => Promise { + return async () => { + const taskId = `${this.getProviderName()}:refresh`; + return schedule.run({ + id: taskId, + fn: async () => { + const logger = this.logger.child({ + class: AwsS3EntityProvider.prototype.constructor.name, + taskId, + taskInstanceId: uuid.v4(), + }); + + try { + await this.refresh(logger); + } catch (error) { + logger.error(error); + } + }, + }); + }; + } + + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.getProviderName} */ + getProviderName(): string { + return `awsS3-provider:${this.config.id}`; + } + + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */ + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + await this.scheduleFn(); + } + + async refresh(logger: Logger) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + logger.info('Discovering AWS S3 objects'); + + const keys = await this.listAllObjectKeys(); + logger.info(`Discovered ${keys.length} AWS S3 objects`); + + const locations = keys.map(key => this.createLocationSpec(key)); + + await this.connection.applyMutation({ + type: 'full', + entities: locations.map(location => { + return { + locationKey: this.getProviderName(), + entity: locationSpecToLocationEntity({ location }), + }; + }), + }); + + logger.info(`Committed ${locations.length} Locations for AWS S3 objects`); + } + + private async listAllObjectKeys(): Promise { + const keys: string[] = []; + + let continuationToken: string | undefined = undefined; + let output: ListObjectsV2Output; + do { + const request = this.s3.listObjectsV2({ + Bucket: this.config.bucketName, + ContinuationToken: continuationToken, + Prefix: this.config.prefix, + }); + + output = await request.promise(); + if (output.Contents) { + output.Contents.forEach(item => { + if (item.Key && !item.Key.endsWith('/')) { + keys.push(item.Key); + } + }); + } + continuationToken = output.NextContinuationToken; + } while (continuationToken); + + return keys; + } + + private createLocationSpec(key: string): LocationSpec { + return { + type: 'url', + target: this.createObjectUrl(key), + presence: 'required', + }; + } + + private createObjectUrl(key: string): string { + const bucketName = this.config.bucketName; + const endpoint = this.integration.config.endpoint; + + if (endpoint) { + if (endpoint.startsWith(`https://${bucketName}.`)) { + return `${endpoint}/${key}`; + } + + return `${endpoint}/${bucketName}/${key}`; + } + + return `https://${bucketName}.s3.${this.config.region}.amazonaws.com/${key}`; + } +} diff --git a/plugins/catalog-backend-module-aws/src/providers/config.test.ts b/plugins/catalog-backend-module-aws/src/providers/config.test.ts new file mode 100644 index 0000000000..534d4da35e --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/config.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/config'; +import { readAwsS3Configs } from './config'; + +describe('readAwsS3Configs', () => { + it('reads single provider config', () => { + const provider = { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }; + const config = { + catalog: { + providers: { + awsS3: provider, + }, + }, + }; + + const actual = readAwsS3Configs(new ConfigReader(config)); + + expect(actual).toHaveLength(1); + expect(actual[0]).toEqual({ + ...provider, + id: 'default', + }); + }); + + it('reads all provider configs', () => { + const provider1 = { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }; + const provider2 = { + bucketName: 'bucket-2', + region: 'eu-west-1', + }; + const provider3 = { + bucketName: 'bucket-3', + }; + const config = { + catalog: { + providers: { + awsS3: { provider1, provider2, provider3 }, + }, + }, + }; + + const actual = readAwsS3Configs(new ConfigReader(config)); + + expect(actual).toHaveLength(3); + expect(actual[0]).toEqual({ + ...provider1, + id: 'provider1', + }); + expect(actual[1]).toEqual({ + ...provider2, + id: 'provider2', + }); + expect(actual[2]).toEqual({ + ...provider3, + id: 'provider3', + }); + }); + + it('fails if bucketName is missing', () => { + const provider = { + region: 'us-east-1', + }; + const config = { + catalog: { + providers: { + awsS3: { provider }, + }, + }, + }; + + expect(() => readAwsS3Configs(new ConfigReader(config))).toThrow( + "Missing required config value at 'catalog.providers.awsS3.provider.bucketName'", + ); + }); +}); diff --git a/plugins/catalog-backend-module-aws/src/providers/config.ts b/plugins/catalog-backend-module-aws/src/providers/config.ts new file mode 100644 index 0000000000..5fdf2351c3 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/config.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { Config } from '@backstage/config'; +import { AwsS3Config } from './types'; + +const DEFAULT_PROVIDER_ID = 'default'; + +export function readAwsS3Configs(config: Config): AwsS3Config[] { + const configs: AwsS3Config[] = []; + + const providerConfigs = config.getOptionalConfig('catalog.providers.awsS3'); + if (!providerConfigs) { + return configs; + } + + if (providerConfigs.has('bucketName')) { + // simple/single config variant + configs.push(readAwsS3Config(DEFAULT_PROVIDER_ID, providerConfigs)); + + return configs; + } + + for (const id of providerConfigs.keys()) { + configs.push(readAwsS3Config(id, providerConfigs.getConfig(id))); + } + + return configs; +} + +function readAwsS3Config(id: string, config: Config): AwsS3Config { + const bucketName = config.getString('bucketName'); + const region = config.getOptionalString('region'); + const prefix = config.getOptionalString('prefix'); + + return { + id, + bucketName, + region, + prefix, + }; +} diff --git a/plugins/catalog-backend-module-aws/src/providers/index.ts b/plugins/catalog-backend-module-aws/src/providers/index.ts new file mode 100644 index 0000000000..7bc8b4f265 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { AwsS3EntityProvider } from './AwsS3EntityProvider'; diff --git a/plugins/catalog-backend-module-aws/src/providers/types.ts b/plugins/catalog-backend-module-aws/src/providers/types.ts new file mode 100644 index 0000000000..3508bf919c --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export type AwsS3Config = { + id: string; + bucketName: string; + prefix?: string; + region?: string; +}; From 49280ffc3340857bf2cb697616abe7cdf083d824 Mon Sep 17 00:00:00 2001 From: Gary Niemen <65337273+garyniemen@users.noreply.github.com> Date: Fri, 1 Apr 2022 17:58:13 +0200 Subject: [PATCH 024/144] Update TechDocs roadmap page Signed-off-by: Gary Niemen <65337273+garyniemen@users.noreply.github.com> --- docs/features/techdocs/README.md | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 4417e62c67..b3cb8025ce 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -10,29 +10,20 @@ description: TechDocs is Spotify’s homegrown docs-like-code solution built dir -TechDocs is Spotify’s homegrown docs-like-code solution built directly into -Backstage. This means engineers write their documentation in Markdown files -which live together with their code. +TechDocs is Spotify’s homegrown docs-like-code solution built directly into Backstage. Engineers write their documentation in Markdown files which live together with their code - and with little configuration get a nice-looking doc site in Backstage. -Today, it is one of the core products in Spotify’s developer experience offering -with 2,400+ documentation sites and 1,000+ engineers using it daily. Read more -about TechDocs and the philosophy in its +Today, it is one of the core products in Spotify’s developer experience offering with 5000+ documentation sites and around 10000 average daily hits. Read more about TechDocs in its [announcement blog post](https://backstage.io/blog/2020/09/08/announcing-tech-docs). 🎉 ## Features - Deploy TechDocs no matter how your software environment is set up. -- Discover your Service's technical documentation from the Service's page in - Backstage Catalog. +- Discover your Service's technical documentation from the Service's page in Backstage Catalog. - Create documentation-only sites for any purpose by just writing Markdown. - Explore and take advantage of the large ecosystem of - [MkDocs plugins](https://www.mkdocs.org/user-guide/plugins/) to create a rich - reading experience. + [MkDocs plugins](https://www.mkdocs.org/user-guide/plugins/) to create a rich reading experience. - Search for and find docs. -- Highlight text and raise an Issue to create feedback loop to drive quality - documentation (future). -- Contribute to and deploy from a marketplace of TechDocs widgets (future). ## Platforms supported @@ -83,17 +74,22 @@ TechDocs packages: - '@backstage/plugin-techdocs-node' - '@techdocs/cli' -was promoted to v1.0! To understand how this change affects the package, please check out our [versioning policy](https://backstage.io/docs/overview/versioning-policy). +TechDocs promoted to v1.0! To understand how this change affects the package, please check out our [versioning policy](https://backstage.io/docs/overview/versioning-policy). ### **Future work 🔮** +Some of the following items are coming soon and some are potential ideas. + +- [TechDocs Addon Framework](https://github.com/backstage/backstage/issues/9636) +- Contribute to and deploy from a marketplace of TechDocs Addons +- Addon: Highlight text and raise an Issue to create a feedback loop to drive up documentation quality +- Addon: MDX (allows you to use JSX in your Markdown content) - Better integration with - [Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy - to choose and plug documentation template with Software Templates). + [Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy to choose and plug documentation template with Software Templates) - Static site generator agnostic - Possible to configure several aspects about TechDocs (e.g. URL, homepage, - theme). -- [TechDocs Addon Framework](https://github.com/backstage/backstage/issues/9636) + theme) + ## Tech stack From 1b44c18d2922871006c4947180ad1a71533ea825 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 7 Apr 2022 10:25:22 +0200 Subject: [PATCH 025/144] Prettier Signed-off-by: Eric Peterson --- docs/features/techdocs/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index b3cb8025ce..340c0d9396 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -10,7 +10,7 @@ description: TechDocs is Spotify’s homegrown docs-like-code solution built dir -TechDocs is Spotify’s homegrown docs-like-code solution built directly into Backstage. Engineers write their documentation in Markdown files which live together with their code - and with little configuration get a nice-looking doc site in Backstage. +TechDocs is Spotify’s homegrown docs-like-code solution built directly into Backstage. Engineers write their documentation in Markdown files which live together with their code - and with little configuration get a nice-looking doc site in Backstage. Today, it is one of the core products in Spotify’s developer experience offering with 5000+ documentation sites and around 10000 average daily hits. Read more about TechDocs in its [announcement blog post](https://backstage.io/blog/2020/09/08/announcing-tech-docs). @@ -90,7 +90,6 @@ Some of the following items are coming soon and some are potential ideas. - Possible to configure several aspects about TechDocs (e.g. URL, homepage, theme) - ## Tech stack | Stack | Location | From 612a361715d430efb5df7e728bb901289fd09610 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 7 Apr 2022 11:38:10 +0200 Subject: [PATCH 026/144] Sync issues in repo only Signed-off-by: Johan Haals --- .github/workflows/sync_issue-labels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sync_issue-labels.yml b/.github/workflows/sync_issue-labels.yml index 6a1c33348a..91b5f79f5d 100644 --- a/.github/workflows/sync_issue-labels.yml +++ b/.github/workflows/sync_issue-labels.yml @@ -5,6 +5,7 @@ on: jobs: label-issue: runs-on: ubuntu-latest + if: github.repository == 'backstage/backstage' steps: - name: View context attributes uses: actions/github-script@v6 From 04575786b0a8868b42fd5accbad4eaad3f41367a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Apr 2022 12:06:53 +0200 Subject: [PATCH 027/144] scripts: add script to verify local dependency ranges Signed-off-by: Patrik Oldsberg --- .github/workflows/ci.yml | 3 + scripts/verify-local-dependencies.js | 101 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100755 scripts/verify-local-dependencies.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f0c6e7602..5ca77b96bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,9 @@ jobs: - name: verify doc links run: node scripts/verify-links.js + - name: verify local dependency ranges + run: node scripts/verify-local-dependencies.js + - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn backstage-cli repo build --all --since origin/master diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js new file mode 100755 index 0000000000..e11ae2e55d --- /dev/null +++ b/scripts/verify-local-dependencies.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +const fs = require('fs-extra'); +const semver = require('semver'); +const { getPackages } = require('@manypkg/get-packages'); +const { + resolve: resolvePath, + relative: relativePath, + join: joinPath, +} = require('path'); + +/** + * This script checks that all local package dependencies within the repo + * point to the correct version ranges. + * + * It can be run with a `--fix` flag to a automatically fix any issues. + */ + +const depTypes = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +async function main(args) { + const shouldFix = args.includes('--fix'); + const rootPath = resolvePath(__dirname, '..'); + const { packages } = await getPackages(rootPath); + + let hadErrors = false; + + const pkgMap = new Map(packages.map(pkg => [pkg.packageJson.name, pkg])); + + for (const pkg of packages) { + let fixed = false; + + for (const depType of depTypes) { + const deps = pkg.packageJson[depType]; + + for (const [dep, range] of Object.entries(deps || {})) { + if (range === '' || range.startsWith('link:')) { + continue; + } + const localPackage = pkgMap.get(dep); + if (localPackage) { + const localVersion = localPackage.packageJson.version; + if (!semver.satisfies(localVersion, range)) { + const path = joinPath( + relativePath(rootPath, pkg.dir), + 'package.json', + ); + console.log( + `${path} depends on the wrong version of ${dep}: ${range} does not satisfy ${localVersion}`, + ); + hadErrors = true; + + fixed = true; + pkg.packageJson[depType][dep] = `^${localVersion}`; + } + } + } + } + + if (shouldFix && fixed) { + await fs.writeJson(joinPath(pkg.dir, 'package.json'), pkg.packageJson, { + spaces: 2, + }); + } + } + + if (!shouldFix && hadErrors) { + console.error(); + console.error('At least one package has an invalid local dependency'); + console.error( + 'Run `node scripts/verify-local-dependencies.js --fix` to fix', + ); + + process.exit(2); + } +} + +main(process.argv.slice(2)).catch(error => { + console.error(error.stack || error); + process.exit(1); +}); From 9b5877584917bf9c9445f8338f17c8e85a648bcf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Apr 2022 12:07:11 +0200 Subject: [PATCH 028/144] home: fix outdated dependency on @backstage/config Signed-off-by: Patrik Oldsberg --- .changeset/silly-forks-study.md | 5 +++++ plugins/home/package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/silly-forks-study.md diff --git a/.changeset/silly-forks-study.md b/.changeset/silly-forks-study.md new file mode 100644 index 0000000000..d337aecec6 --- /dev/null +++ b/.changeset/silly-forks-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Updated the dependency on `@backstage/config` to `^1.0.0`. diff --git a/plugins/home/package.json b/plugins/home/package.json index 5358f83b55..43b474704a 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -41,7 +41,7 @@ "@backstage/plugin-search": "^0.7.5-next.0", "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/theme": "^0.2.15", - "@backstage/config": "^0.1.15", + "@backstage/config": "^1.0.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", From df7862cfa6c961256e4141177634eb75a2a8886f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Apr 2022 12:37:57 +0200 Subject: [PATCH 029/144] cli: do not apply react-hot-loader transforms for backend development Signed-off-by: Patrik Oldsberg --- .changeset/quick-avocados-sell.md | 5 +++++ packages/cli/src/lib/bundler/config.ts | 2 +- packages/cli/src/lib/bundler/transforms.ts | 5 +++-- 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .changeset/quick-avocados-sell.md diff --git a/.changeset/quick-avocados-sell.md b/.changeset/quick-avocados-sell.md new file mode 100644 index 0000000000..74014dca66 --- /dev/null +++ b/.changeset/quick-avocados-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed a bug were the `react-hot-loader` transform was being applied to backend development builds. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 575af4ea8b..fb073a25e3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -226,7 +226,7 @@ export async function createBackendConfig( // See frontend config const externalPkgs = packages.filter(p => !isChildPath(paths.root, p.dir)); - const { loaders } = transforms(options); + const { loaders } = transforms({ ...options, isBackend: true }); const runScriptNodeArgs = new Array(); if (options.inspectEnabled) { diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index d1d26f383d..a24c24a194 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -25,12 +25,13 @@ type Transforms = { type TransformOptions = { isDev: boolean; + isBackend?: boolean; }; export const transforms = (options: TransformOptions): Transforms => { - const { isDev } = options; + const { isDev, isBackend } = options; - const extraTransforms = isDev ? ['react-hot-loader'] : []; + const extraTransforms = isDev && !isBackend ? ['react-hot-loader'] : []; // This ensures that styles inserted from the style-loader and any // async style chunks are always given lower priority than JSS styles. From 8edc66257a6ab5f8694e08eb2dbd1076dc2cd6a7 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Thu, 7 Apr 2022 12:47:34 +0200 Subject: [PATCH 030/144] add dedicated grpc api widget Signed-off-by: Kiss Miklos --- .../ApiDefinitionCard/ApiDefinitionWidget.tsx | 8 ++++ .../GrpcApiDefinitionWidget.test.tsx | 32 ++++++++++++++++ .../GrpcApiDefinitionWidget.tsx | 38 +++++++++++++++++++ .../GrpcApiDefinitionWidget/index.ts | 18 +++++++++ 4 files changed, 96 insertions(+) create mode 100644 plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx create mode 100644 plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx create mode 100644 plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx index baccf09de9..3b7aa406d4 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget'; import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget'; import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; +import { GrpcApiDefinitionWidget } from '../GrpcApiDefinitionWidget'; export type ApiDefinitionWidget = { type: string; @@ -51,5 +52,12 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] { ), }, + { + type: 'grpc', + title: 'gRPC', + component: definition => ( + + ), + }, ]; } diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx new file mode 100644 index 0000000000..046614fc91 --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { GrpcApiDefinitionWidget } from './GrpcApiDefinitionWidget'; + +describe('', () => { + it('renders plain text', async () => { + const { getAllByText } = await renderInTestApp( + , + ); + + expect( + getAllByText((_text, element) => element?.textContent === 'Hello World') + .length, + ).toBeGreaterThan(0); + }); +}); diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx new file mode 100644 index 0000000000..8178f76073 --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 React from 'react'; +import { CodeSnippet } from '@backstage/core-components'; +import { useTheme } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; + +export type GrpcApiDefinitionWidgetProps = { + definition: any; +}; + +export const GrpcApiDefinitionWidget = ( + props: GrpcApiDefinitionWidgetProps, +) => { + const theme = useTheme(); + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts new file mode 100644 index 0000000000..f9ed12d111 --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +export { GrpcApiDefinitionWidget } from './GrpcApiDefinitionWidget'; +export type { GrpcApiDefinitionWidgetProps } from './GrpcApiDefinitionWidget'; From 9948aef03dd6d2afc4a7c81ba8fd017e0cb9ded3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 7 Apr 2022 13:45:16 +0200 Subject: [PATCH 031/144] api report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend-module-msgraph/api-report.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 2e741e65fe..2239ec1332 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -182,6 +182,7 @@ export type MicrosoftGraphProviderConfig = { groupExpand?: string; groupFilter?: string; groupSearch?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; }; @@ -219,6 +220,7 @@ export function readMicrosoftGraphOrg( groupExpand?: string; groupSearch?: string; groupFilter?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; From 89b0af9b4bc26f5c9f4455f0ba1d75f66f6d771e Mon Sep 17 00:00:00 2001 From: Niklas Aronsson Date: Thu, 7 Apr 2022 14:00:19 +0200 Subject: [PATCH 032/144] Gerrit: Added location docs Documentation have been added that describes how to configure Gerrit integrations. Signed-off-by: Niklas Aronsson --- docs/integrations/gerrit/locations.md | 43 +++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 45 insertions(+) create mode 100644 docs/integrations/gerrit/locations.md diff --git a/docs/integrations/gerrit/locations.md b/docs/integrations/gerrit/locations.md new file mode 100644 index 0000000000..45514948c6 --- /dev/null +++ b/docs/integrations/gerrit/locations.md @@ -0,0 +1,43 @@ +--- +id: locations +title: Gerrit Locations +sidebar_label: Locations +description: Integrating source code stored in Gerrit into the Backstage catalog +--- + +The Gerrit integration supports loading catalog entities from Gerrit hosted gits. Entities can +be added to [static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +## Configuration + +To use this integration, add at least one Gerrit configuration to your root `app-config.yaml`: + +```yaml +integrations: + gerrit: + - host: gerrit.company.com + apiBaseUrl: gerrit.company.com/gerrit + gitilesBaseUrl: gerrit.company.com/gitiles + username: ${GERRIT_USERNAME} + password: ${GERRIT_PASSWORD} +``` + +Directly under the `gerrit` key is a list of provider configurations, where +you can list the Gerrit instances you want to fetch data from. Each entry is +a structure with up to four elements: + +- `host`: The host of the Gerrit instance, e.g. `gerrit.company.com`. +- `apiBaseUrl` (optional): Needed if the Gerrit instance is not reachable at + the base of the `host` option (e.g. `https://gerrit.company.com`) set the + address here. This is the address that you would open in a browser. +- `gitilesBaseUrl` (optional): This is needed for creating a valid user-friendly url + that can be used for browsing the content of the provider. If not set a default + value will be created in the same way as the "baseUrl" option. There is no + requirement to have Gitiles for the Backstage Gerrit integration but without it + some links in the Backstage UI will be broken. +- `username` (optional): The Gerrit username to use in API requests. If + neither a username nor password are supplied, anonymous access will be used. +- `password` (optional): The password or http token for the Gerrit user. diff --git a/mkdocs.yml b/mkdocs.yml index 9fa5eaabd3..204a4768c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,6 +97,8 @@ nav: - Discovery: 'integrations/bitbucket/discovery.md' - Datadog: - Installation: 'integrations/datadog-rum/installation.md' + - Gerrit: + - Locations: 'integrations/gerrit/locations.md' - GitHub: - Locations: 'integrations/github/locations.md' - Discovery: 'integrations/github/discovery.md' From 567b13a84aaaa65c7017df1f87c61ff08a67fd12 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Thu, 7 Apr 2022 18:06:21 -0300 Subject: [PATCH 033/144] Add checksId option to EntityTechInsightsScorecardContent component Signed-off-by: Rogerio Angeliski --- .changeset/wild-emus-film.md | 5 +++++ plugins/tech-insights/api-report.md | 4 +++- plugins/tech-insights/src/api/TechInsightsApi.ts | 2 +- plugins/tech-insights/src/api/TechInsightsClient.ts | 5 ++--- .../src/components/ScorecardsOverview/ScorecardsOverview.tsx | 4 +++- 5 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 .changeset/wild-emus-film.md diff --git a/.changeset/wild-emus-film.md b/.changeset/wild-emus-film.md new file mode 100644 index 0000000000..abb77c5ddc --- /dev/null +++ b/.changeset/wild-emus-film.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-insights': minor +--- + +Add checksId option to EntityTechInsightsScorecardContent component diff --git a/plugins/tech-insights/api-report.md b/plugins/tech-insights/api-report.md index deadd06010..9574efca28 100644 --- a/plugins/tech-insights/api-report.md +++ b/plugins/tech-insights/api-report.md @@ -34,9 +34,11 @@ export type CheckResultRenderer = { export const EntityTechInsightsScorecardContent: ({ title, description, + checksId, }: { title?: string | undefined; description?: string | undefined; + checksId?: string[] | undefined; }) => JSX.Element; // @public @@ -58,7 +60,7 @@ export interface TechInsightsApi { // (undocumented) runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise; } diff --git a/plugins/tech-insights/src/api/TechInsightsApi.ts b/plugins/tech-insights/src/api/TechInsightsApi.ts index 0a6bb937a6..a24bf74a01 100644 --- a/plugins/tech-insights/src/api/TechInsightsApi.ts +++ b/plugins/tech-insights/src/api/TechInsightsApi.ts @@ -47,7 +47,7 @@ export interface TechInsightsApi { getAllChecks(): Promise; runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise; runBulkChecks( entities: CompoundEntityRef[], diff --git a/plugins/tech-insights/src/api/TechInsightsClient.ts b/plugins/tech-insights/src/api/TechInsightsClient.ts index 5b9af9557f..04de46dba9 100644 --- a/plugins/tech-insights/src/api/TechInsightsClient.ts +++ b/plugins/tech-insights/src/api/TechInsightsClient.ts @@ -75,13 +75,12 @@ export class TechInsightsClient implements TechInsightsApi { async runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise { const url = await this.discoveryApi.getBaseUrl('tech-insights'); const { token } = await this.identityApi.getCredentials(); const { namespace, kind, name } = entityParams; - const checkIds = checks ? checks.map(check => check.id) : []; - const requestBody = { checks: checkIds.length > 0 ? checkIds : undefined }; + const requestBody = { checks }; const response = await fetch( `${url}/checks/run/${encodeURIComponent(namespace)}/${encodeURIComponent( kind, diff --git a/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx index 73143a7ea8..53cf826e78 100644 --- a/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx +++ b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx @@ -26,14 +26,16 @@ import { techInsightsApiRef } from '../../api/TechInsightsApi'; export const ScorecardsOverview = ({ title, description, + checksId, }: { title?: string; description?: string; + checksId?: string[]; }) => { const api = useApi(techInsightsApiRef); const { namespace, kind, name } = useParams(); const { value, loading, error } = useAsync( - async () => await api.runChecks({ namespace, kind, name }), + async () => await api.runChecks({ namespace, kind, name }, checksId), ); if (loading) { From e838a7060acc29cd94ab4cf847192733b9aa1188 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Apr 2022 10:58:08 +0200 Subject: [PATCH 034/144] Add package resolution for @types/react Signed-off-by: Johan Haals --- .changeset/little-moles-pull.md | 5 +++++ package.json | 3 ++- .../templates/default-app/package.json.hbs | 3 +++ yarn.lock | 21 ++++--------------- 4 files changed, 14 insertions(+), 18 deletions(-) create mode 100644 .changeset/little-moles-pull.md diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md new file mode 100644 index 0000000000..8a8d6da972 --- /dev/null +++ b/.changeset/little-moles-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Add resolution for version 17 `@types/react` due to breaking changes introduced in version 18. diff --git a/package.json b/package.json index 13ba3c34ea..3154188414 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ ] }, "resolutions": { - "**/@graphql-codegen/cli/**/ws": "^7.4.6" + "**/@graphql-codegen/cli/**/ws": "^7.4.6", + "@types/react": "^17.0.39" }, "version": "1.1.0-next.2", "dependencies": { diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 884eaf23cf..55a1b63aa9 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -37,6 +37,9 @@ "prettier": "^2.3.2", "typescript": "~4.5.4" }, + "resolutions": { + "@types/react": "^17.0.39" + }, "prettier": "@spotify/prettier-config", "lint-staged": { "*.{js,jsx,ts,tsx,mjs,cjs}": [ diff --git a/yarn.lock b/yarn.lock index 63cd4710f5..21e7f59caa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,14 +1478,6 @@ lodash "^4.17.21" uuid "^8.0.0" -"@backstage/config@^0.1.15": - version "0.1.15" - resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.15.tgz#4bad122ad861be5bd61a60639f92d2494fa245c5" - integrity sha512-eNJEYYSEu9MkrkBYiMpUBWEc3Bu64YgB9pZZGCMW7/9350tV2wbylEdoBJHslilJlJhiUyTXBckn8Ua7DOH7rw== - dependencies: - "@backstage/types" "^0.1.3" - lodash "^4.17.21" - "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -1610,11 +1602,6 @@ react-use "^17.2.4" swr "^1.1.2" -"@backstage/types@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14" - integrity sha512-fJVi4oVrlO+G3PRv1fYSll9/X4pE11HLnkI//Geare9sP6wSfp/2zXpLYfKVsG0e24jOl7Swkc8lwLkQ90zMaQ== - "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -6652,10 +6639,10 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0": - version "17.0.43" - resolved "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55" - integrity sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A== +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17.0.39": + version "17.0.44" + resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" + integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" From 8b669400ea8340a1f2c7595b186e33837c3143cc Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Apr 2022 11:05:43 +0200 Subject: [PATCH 035/144] include react-dom types, update changeset Signed-off-by: Johan Haals --- .changeset/little-moles-pull.md | 20 ++++++++++++++++++- package.json | 3 ++- .../templates/default-app/package.json.hbs | 3 ++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md index 8a8d6da972..7fee7e5148 100644 --- a/.changeset/little-moles-pull.md +++ b/.changeset/little-moles-pull.md @@ -2,4 +2,22 @@ '@backstage/create-app': patch --- -Add resolution for version 17 `@types/react` due to breaking changes introduced in version 18. +Add resolution for version 17 `@types/react` and `types/react-dom` due to breaking changes introduced in version 18. + +To apply these changes to your existing installation. Add a resolutions block to your `package.json` + +```json + "resolutions": { + "@types/react": "^17", + "@types/react-dom": "^17" + }, +``` + +If your depending on react 16 in use this resolution block instead. + +```json + "resolutions": { + "@types/react": "^16", + "@types/react-dom": "^16" + }, +``` diff --git a/package.json b/package.json index 3154188414..dc72b8974b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ }, "resolutions": { "**/@graphql-codegen/cli/**/ws": "^7.4.6", - "@types/react": "^17.0.39" + "@types/react": "^17", + "@types/react-dom": "^17" }, "version": "1.1.0-next.2", "dependencies": { diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 55a1b63aa9..5bdbec7971 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -38,7 +38,8 @@ "typescript": "~4.5.4" }, "resolutions": { - "@types/react": "^17.0.39" + "@types/react": "^17", + "@types/react-dom": "^17" }, "prettier": "@spotify/prettier-config", "lint-staged": { From 7d0a1f0199751abac40735a016cf6bed39715a52 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Apr 2022 11:10:09 +0200 Subject: [PATCH 036/144] fix typo Signed-off-by: Johan Haals --- .changeset/little-moles-pull.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md index 7fee7e5148..8b9f3ca446 100644 --- a/.changeset/little-moles-pull.md +++ b/.changeset/little-moles-pull.md @@ -2,7 +2,7 @@ '@backstage/create-app': patch --- -Add resolution for version 17 `@types/react` and `types/react-dom` due to breaking changes introduced in version 18. +Add type resolutions for `@types/react` and `types/react-dom`. This is an To apply these changes to your existing installation. Add a resolutions block to your `package.json` @@ -13,7 +13,7 @@ To apply these changes to your existing installation. Add a resolutions block to }, ``` -If your depending on react 16 in use this resolution block instead. +If your existing app depend on react 16 use this resolution block instead. ```json "resolutions": { From 1d031b536797bb5ac16c556dc399b6bfa4789fb2 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Apr 2022 11:18:27 +0200 Subject: [PATCH 037/144] fix wording Signed-off-by: Johan Haals --- .changeset/little-moles-pull.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md index 8b9f3ca446..592fc13ab6 100644 --- a/.changeset/little-moles-pull.md +++ b/.changeset/little-moles-pull.md @@ -2,7 +2,9 @@ '@backstage/create-app': patch --- -Add type resolutions for `@types/react` and `types/react-dom`. This is an +Add type resolutions for `@types/react` and `types/react-dom`. + +The reason for this is the usage of `"@types/react": "*"` as a dependency which is very common practice in react packages. This recently resolves to react 18 which introduces several breaking changes in both internal and external packages. To apply these changes to your existing installation. Add a resolutions block to your `package.json` @@ -13,7 +15,7 @@ To apply these changes to your existing installation. Add a resolutions block to }, ``` -If your existing app depend on react 16 use this resolution block instead. +If your existing app depends on react 16, use this resolution block instead. ```json "resolutions": { From 48129cf82642e071089c0f18dd42880dd1dd7858 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Apr 2022 11:19:56 +0200 Subject: [PATCH 038/144] reformat Signed-off-by: Johan Haals --- .changeset/little-moles-pull.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md index 592fc13ab6..f52bc24bb2 100644 --- a/.changeset/little-moles-pull.md +++ b/.changeset/little-moles-pull.md @@ -6,7 +6,7 @@ Add type resolutions for `@types/react` and `types/react-dom`. The reason for this is the usage of `"@types/react": "*"` as a dependency which is very common practice in react packages. This recently resolves to react 18 which introduces several breaking changes in both internal and external packages. -To apply these changes to your existing installation. Add a resolutions block to your `package.json` +To apply these changes to your existing installation, add a resolutions block to your `package.json` ```json "resolutions": { From e79f9569659c306138244793313b720258be1cbd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 8 Apr 2022 12:14:41 +0200 Subject: [PATCH 039/144] scripts/prepare-release: fix patch bump process to work with prereleases Signed-off-by: Patrik Oldsberg --- scripts/prepare-release.js | 88 +++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js index ad783c4609..d5ad1842d4 100755 --- a/scripts/prepare-release.js +++ b/scripts/prepare-release.js @@ -39,6 +39,43 @@ const DEPENDENCY_TYPES = [ 'peerDependencies', ]; +/** + * Finds the current stable release version of the repo, looking at + * the current commit and backwards, finding the first commit were a + * stable version is present. + */ +async function findCurrentReleaseVersion(repo) { + const rootPkgPath = path.resolve(repo.root.dir, 'package.json'); + const pkg = await fs.readJson(rootPkgPath); + + if (!semver.prerelease(pkg.version)) { + return pkg.version; + } + + const { stdout: revListStr } = await execFile('git', [ + 'rev-list', + 'HEAD', + '--', + 'package.json', + ]); + const revList = revListStr.trim().split(/\r?\n/); + + for (const rev of revList) { + const { stdout: pkgJsonStr } = await execFile('git', [ + 'show', + `${rev}:package.json`, + ]); + if (pkgJsonStr) { + const pkgJson = JSON.parse(pkgJsonStr); + if (!semver.prerelease(pkgJson.version)) { + return pkgJson.version; + } + } + } + + throw new Error('No stable release found'); +} + /** * Finds the tip of the patch branch of a given release version. * Returns undefined if no patch branch exists. @@ -63,7 +100,7 @@ async function findTipOfPatchBranch(repo, release) { * Returns a map of packages to their versions for any package version * in that does not match the current version in the working directory. */ -async function detectPatchVersionsForRef(repo, ref) { +async function detectPatchVersionsBetweenRefs(repo, baseRef, ref) { const patchVersions = new Map(); for (const pkg of repo.packages) { @@ -72,20 +109,29 @@ async function detectPatchVersionsForRef(repo, ref) { 'package.json', ); try { + const { stdout: basePkgJsonStr } = await execFile('git', [ + 'show', + `${baseRef}:${pkgJsonPath}`, + ]); + const { stdout: pkgJsonStr } = await execFile('git', [ 'show', `${ref}:${pkgJsonPath}`, ]); - if (pkgJsonStr) { + if (basePkgJsonStr && pkgJsonStr) { + const basePkgJson = JSON.parse(basePkgJsonStr); const releasePkgJson = JSON.parse(pkgJsonStr); - const pkgJson = pkg.packageJson; - if (releasePkgJson.name !== pkgJson.name) { + + if (releasePkgJson.private) { + continue; + } + if (releasePkgJson.name !== basePkgJson.name) { throw new Error( - `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${pkgJson.name}`, + `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${basePkgJson.name}`, ); } - if (releasePkgJson.version !== pkgJson.version) { - patchVersions.set(pkgJson.name, releasePkgJson.version); + if (releasePkgJson.version !== basePkgJson.version) { + patchVersions.set(basePkgJson.name, releasePkgJson.version); } } } catch (error) { @@ -110,24 +156,33 @@ async function detectPatchVersionsForRef(repo, ref) { async function applyPatchVersions(repo, patchVersions) { const pendingVersionBumps = new Map(); - for (const [name, version] of patchVersions) { + for (const [name, patchVersion] of patchVersions) { const pkg = repo.packages.find(p => p.packageJson.name === name); if (!pkg) { throw new Error(`Package ${name} not found`); } - if (!semver.valid(version)) { - throw new Error(`Invalid base version ${version} for package ${name}`); + if (!semver.valid(patchVersion)) { + throw new Error( + `Invalid base version ${patchVersion} for package ${name}`, + ); } - let targetVersion = version; + if (semver.gte(pkg.packageJson.version, patchVersion)) { + console.log( + `No need to bump ${name} ${pkg.packageJson.version} is already ahead of ${patchVersion}`, + ); + continue; + } + + let targetVersion = patchVersion; // If we're currently in a pre-release we need to manually execute the // patch bump up to the next version. And we also need to make sure we // resume the releases at the same pre-release tag. const currentPrerelease = semver.prerelease(pkg.packageJson.version); if (currentPrerelease) { - const parsed = targetVersion.parse(version); + const parsed = semver.parse(targetVersion); parsed.inc('patch'); parsed.prerelease = currentPrerelease; targetVersion = parsed.format(); @@ -182,15 +237,18 @@ async function applyPatchVersions(repo, patchVersions) { * the main branch, and then bumps all packages in the repo accordingly. */ async function updatePackageVersions(repo) { - const rootPkgPath = path.resolve(repo.root.dir, 'package.json'); - const { version: currentRelease } = await fs.readJson(rootPkgPath); + const currentRelease = await findCurrentReleaseVersion(repo); console.log(`Current release version: ${currentRelease}`); const patchRef = await findTipOfPatchBranch(repo, currentRelease); if (patchRef) { console.log(`Tip of the patch branch: ${patchRef}`); - const patchVersions = await detectPatchVersionsForRef(repo, patchRef); + const patchVersions = await detectPatchVersionsBetweenRefs( + repo, + `v${currentRelease}`, + patchRef, + ); if (patchVersions.size > 0) { console.log( `Found ${patchVersions.size} packages that were patched since the last release`, From 2b07063d7777fe3ed0b01ae82085e1e6af9d2c0b Mon Sep 17 00:00:00 2001 From: Joe Porpeglia Date: Tue, 22 Mar 2022 21:44:47 -0400 Subject: [PATCH 040/144] Add PermissionEvaluator interface Signed-off-by: Joe Porpeglia --- .changeset/few-seas-fail.md | 8 +++ plugins/permission-common/api-report.md | 40 +++++++++++ plugins/permission-common/src/types/api.ts | 70 ++++++++++++++++++++ plugins/permission-common/src/types/index.ts | 6 ++ 4 files changed, 124 insertions(+) create mode 100644 .changeset/few-seas-fail.md diff --git a/.changeset/few-seas-fail.md b/.changeset/few-seas-fail.md new file mode 100644 index 0000000000..8ace0f3515 --- /dev/null +++ b/.changeset/few-seas-fail.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-permission-common': patch +--- + +Added `PermissionEvaluator`, which will replace the existing `PermissionAuthorizer` interface. This new interface provides stronger type safety and validation by splitting `PermissionAuthorizer.authorize()` into two methods: + +- `authorize()`: Used when the caller requires a definitive decision. +- `query()`: Used when the caller can optimize the evaluation of any conditional decisions. For example, a plugin backend may want to use conditions in a database query instead of evaluating each resource in memory. diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index ab0c68470d..2f283c0d8d 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -15,6 +15,20 @@ export type AnyOfCriteria = { anyOf: NonEmptyArray>; }; +// @public +export type AuthorizePermissionRequest = + | { + permission: Exclude; + resourceRef?: never; + } + | { + permission: ResourcePermission; + resourceRef: string; + }; + +// @public +export type AuthorizePermissionResponse = DefinitivePolicyDecision; + // @public export type AuthorizeRequestOptions = { token?: string; @@ -78,6 +92,11 @@ export type EvaluatePermissionResponse = PolicyDecision; export type EvaluatePermissionResponseBatch = PermissionMessageBatch; +// @public +export type EvaluatorRequestOptions = { + token?: string; +}; + // @public export type IdentifiedPermissionMessage = T & { id: string; @@ -163,6 +182,18 @@ export type PermissionCriteria = | NotCriteria | TQuery; +// @public +export interface PermissionEvaluator { + authorize( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + query( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; +} + // @public export type PermissionMessageBatch = { items: IdentifiedPermissionMessage[]; @@ -173,6 +204,15 @@ export type PolicyDecision = | DefinitivePolicyDecision | ConditionalPolicyDecision; +// @public +export type QueryPermissionRequest = { + permission: ResourcePermission; + resourceRef?: never; +}; + +// @public +export type QueryPermissionResponse = PolicyDecision; + // @public export type ResourcePermission = PermissionBase< diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 6aaabf978d..1bc9af5ad8 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ResourcePermission } from '.'; import { Permission } from './permission'; /** @@ -182,3 +183,72 @@ export type EvaluatePermissionResponse = PolicyDecision; */ export type EvaluatePermissionResponseBatch = PermissionMessageBatch; + +/** + * Request object for {@link PermissionEvaluator.authorize}. If a {@link ResourcePermission} + * is provided, it must include a corresponding `resourceRef`. + * @public + */ +export type AuthorizePermissionRequest = + | { + permission: Exclude; + resourceRef?: never; + } + | { permission: ResourcePermission; resourceRef: string }; + +/** + * Response object for {@link PermissionEvaluator.authorize}. + * @public + */ +export type AuthorizePermissionResponse = DefinitivePolicyDecision; + +/** + * Request object for {@link PermissionEvaluator.query}. + * @public + */ +export type QueryPermissionRequest = { + permission: ResourcePermission; + resourceRef?: never; +}; + +/** + * Response object for {@link PermissionEvaluator.query}. + * @public + */ +export type QueryPermissionResponse = PolicyDecision; + +/** + * A client interacting with the permission backend can implement this evaluator interface. + * + * @public + */ +export interface PermissionEvaluator { + /** + * Evaluates {@link Permission | Permissions} and returns a definitive decision. + */ + authorize( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + + /** + * Evaluates {@link ResourcePermission | ResourcePermissions} and returns both definitive and + * conditional decisions, depending on the configured + * {@link @backstage/plugin-permission-node#PermissionPolicy}. This method is useful when the + * caller needs more control over the processing of conditional decisions. For example, a plugin + * backend may want to use {@link PermissionCriteria | conditions} in a database query instead of + * evaluating each resource in memory. + */ + query( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; +} + +/** + * Options for {@link PermissionEvaluator} requests. + * @public + */ +export type EvaluatorRequestOptions = { + token?: string; +}; diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index 1a993a981c..f244a70b1a 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -22,6 +22,12 @@ export type { EvaluatePermissionResponseBatch, IdentifiedPermissionMessage, PermissionMessageBatch, + AuthorizePermissionRequest, + AuthorizePermissionResponse, + QueryPermissionRequest, + QueryPermissionResponse, + EvaluatorRequestOptions, + PermissionEvaluator, ConditionalPolicyDecision, DefinitivePolicyDecision, PolicyDecision, From 8960a2bfed11411c09f34a0e8acb090fe6d74cb2 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 23 Mar 2022 14:45:02 +0100 Subject: [PATCH 041/144] Split PermissionClient#authorize Co-authored-by: Mike Lewis Signed-off-by: Vincenzo Scamporlino --- .../src/PermissionClient.test.ts | 188 +++++++++++++++++- .../permission-common/src/PermissionClient.ts | 169 ++++++++++------ plugins/permission-common/src/types/api.ts | 14 ++ plugins/permission-common/src/types/index.ts | 1 + .../src/ServerPermissionClient.test.ts | 186 ++++++++++++----- .../src/ServerPermissionClient.ts | 48 +++-- plugins/permission-node/src/policy/index.ts | 2 +- plugins/permission-node/src/policy/types.ts | 16 +- 8 files changed, 473 insertions(+), 151 deletions(-) diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index 65a7c7fbbe..ec038e7f16 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -22,6 +22,7 @@ import { EvaluatePermissionRequest, AuthorizeResult, IdentifiedPermissionMessage, + ConditionalPolicyDecision, } from './types/api'; import { DiscoveryApi } from './types/discovery'; import { createPermission } from './permissions'; @@ -140,7 +141,7 @@ describe('PermissionClient', () => { ); await expect( client.authorize([mockAuthorizeQuery], { token }), - ).rejects.toThrowError(/Unexpected authorization response/i); + ).rejects.toThrowError(/items in response do not match request/i); }); it('should reject invalid responses', async () => { @@ -209,4 +210,189 @@ describe('PermissionClient', () => { expect(mockAuthorizeHandler).not.toBeCalled(); }); }); + + describe('query', () => { + const mockResourceAuthorizeQuery = { + permission: mockPermission, + }; + + const mockPolicyDecisionHandler = jest.fn( + (req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + pluginId: 'test-plugin', + resourceType: 'test-resource', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'FOO', + params: ['bar'], + }, + }), + ); + + return res(json({ items: responses })); + }, + ); + + beforeEach(() => { + server.use( + rest.post(`${mockBaseUrl}/authorize`, mockPolicyDecisionHandler), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch entities from correct endpoint', async () => { + await client.query([mockResourceAuthorizeQuery]); + expect(mockPolicyDecisionHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.query([mockResourceAuthorizeQuery]); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + + expect(request.body).toEqual({ + items: [ + expect.objectContaining({ + permission: mockPermission, + }), + ], + }); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.query([mockResourceAuthorizeQuery]); + expect(response[0]).toEqual( + expect.objectContaining({ + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'FOO', + params: ['bar'], + }, + }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.query([mockResourceAuthorizeQuery]); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + expect(request.headers.has('authorization')).toEqual(false); + }); + + it('should include correctly-constructed authorization header if token is supplied', async () => { + await client.query([mockResourceAuthorizeQuery], { + token, + }); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + await expect( + client.query([mockResourceAuthorizeQuery], { + token, + }), + ).rejects.toThrowError(/request failed with 401/i); + }); + + it('should reject responses with missing ids', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res( + json({ + items: [{ id: 'wrong-id', result: AuthorizeResult.ALLOW }], + }), + ); + }, + ); + await expect( + client.query([mockResourceAuthorizeQuery], { + token, + }), + ).rejects.toThrowError(/items in response do not match request/i); + }); + + it('should reject invalid responses', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + outcome: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }, + ); + await expect( + client.query([mockResourceAuthorizeQuery], { + token, + }), + ).rejects.toThrowError(/invalid input/i); + }); + + it('should allow all when permission.enabled is false', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + result: AuthorizeResult.DENY, + }), + ); + + return res(json({ items: responses })); + }, + ); + const disabled = new PermissionClient({ + discovery, + config: new ConfigReader({ permission: { enabled: false } }), + }); + const response = await disabled.query([mockResourceAuthorizeQuery], { + token, + }); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockPolicyDecisionHandler).not.toBeCalled(); + }); + + it('should allow all when permission.enabled is not configured', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + }), + ); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ + discovery, + config: new ConfigReader({}), + }); + const response = await disabled.query([mockResourceAuthorizeQuery], { + token, + }); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockPolicyDecisionHandler).not.toBeCalled(); + }); + }); }); diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 72f6b04567..9ca9bfe255 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -21,19 +21,20 @@ import * as uuid from 'uuid'; import { z } from 'zod'; import { AuthorizeResult, - EvaluatePermissionRequest, - EvaluatePermissionResponse, - IdentifiedPermissionMessage, + DefinitivePolicyDecision, + PermissionMessageBatch, PermissionCriteria, PermissionCondition, - EvaluatePermissionResponseBatch, - EvaluatePermissionRequestBatch, + PermissionEvaluator, + PolicyDecision, + QueryPermissionRequest, + AuthorizePermissionRequest, + EvaluatorRequestOptions, + AuthorizePermissionResponse, + QueryPermissionResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; -import { - PermissionAuthorizer, - AuthorizeRequestOptions, -} from './types/permission'; +import { AuthorizeRequestOptions } from './types/permission'; const permissionCriteriaSchema: z.ZodSchema< PermissionCriteria @@ -58,30 +59,56 @@ const permissionCriteriaSchema: z.ZodSchema< .or(z.object({ not: permissionCriteriaSchema }).strict()), ); -const responseSchema = z.object({ - items: z.array( - z - .object({ - id: z.string(), - result: z - .literal(AuthorizeResult.ALLOW) - .or(z.literal(AuthorizeResult.DENY)), - }) - .or( - z.object({ - id: z.string(), - result: z.literal(AuthorizeResult.CONDITIONAL), - conditions: permissionCriteriaSchema, - }), +const authorizeDecisionSchema: z.ZodSchema = z.object( + { + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }, +); + +const policyDecisionSchema: z.ZodSchema = z.union([ + z.object({ + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }), + z.object({ + result: z.literal(AuthorizeResult.CONDITIONAL), + pluginId: z.string(), + resourceType: z.string(), + conditions: permissionCriteriaSchema, + }), +]); + +const responseSchema = ( + itemSchema: z.ZodSchema, + ids: Set, +): z.ZodSchema> => + z.object({ + items: z + .array( + z.intersection( + z.object({ + id: z.string(), + }), + itemSchema, + ), + ) + .refine( + items => + items.length === ids.size && items.every(({ id }) => ids.has(id)), + { + message: 'Items in response do not match request', + }, ), - ), -}); + }); /** * An isomorphic client for requesting authorization for Backstage permissions. * @public */ -export class PermissionClient implements PermissionAuthorizer { +export class PermissionClient implements PermissionEvaluator { private readonly enabled: boolean; private readonly discovery: DiscoveryApi; @@ -92,35 +119,67 @@ export class PermissionClient implements PermissionAuthorizer { } /** - * Request authorization from the permission-backend for the given set of permissions. + * Request authorization from the permission-backend for the given set of + * permissions. * - * Authorization requests check that a given Backstage user can perform a protected operation, - * potentially for a specific resource (such as a catalog entity). The Backstage identity token - * should be included in the `options` if available. + * @remarks * - * Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`. + * Checks that a given Backstage user can perform a protected operation. When + * authorization is for a {@link ResourcePermission}s, a resourceRef + * corresponding to the resource should always be supplied along with the + * permission. The Backstage identity token should be included in the + * `options` if available. + * + * Permissions can be imported from plugins exposing them, such as + * `catalogEntityReadPermission`. + * + * For each query, the response will be either ALLOW or DENY. * - * The response will be either ALLOW or DENY when either the permission has no resourceType, or a - * resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be - * returned if no resourceRef is provided in the request. Conditional responses are intended only - * for backends which have access to the data source for permissioned resources, so that filters - * can be applied when loading collections of resources. * @public */ async authorize( - queries: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise { + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { // TODO(permissions): it would be great to provide some kind of typing guarantee that // conditional responses will only ever be returned for requests containing a resourceType // but no resourceRef. That way clients who aren't prepared to handle filtering according // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. + return this.makeRequest(requests, authorizeDecisionSchema, options); + } + + /** + * Fetch the conditional authorization decisions for the given set of + * {@link ResourcePermission}s in order to apply the conditions to an upstream + * data source. + * + * @remarks + * + * For each query, the response will be either ALLOW, DENY, or CONDITIONAL. + * Conditional responses are intended only for backends which have access to + * the data source for permissioned resources, so that filters can be applied + * when loading collections of resources. + * + * @public + */ + async query( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return this.makeRequest(queries, policyDecisionSchema, options); + } + + private async makeRequest( + queries: TQuery[], + itemSchema: z.ZodSchema, + options?: AuthorizeRequestOptions, + ) { if (!this.enabled) { - return queries.map(_ => ({ result: AuthorizeResult.ALLOW })); + return queries.map(_ => ({ result: AuthorizeResult.ALLOW as const })); } - const request: EvaluatePermissionRequestBatch = { + const request = { items: queries.map(query => ({ id: uuid.v4(), ...query, @@ -141,12 +200,16 @@ export class PermissionClient implements PermissionAuthorizer { } const responseBody = await response.json(); - this.assertValidResponse(request, responseBody); - const responsesById = responseBody.items.reduce((acc, r) => { + const parsedResponse = responseSchema( + itemSchema, + new Set(request.items.map(({ id }) => id)), + ).parse(responseBody); + + const responsesById = parsedResponse.items.reduce((acc, r) => { acc[r.id] = r; return acc; - }, {} as Record>); + }, {} as Record); return request.items.map(query => responsesById[query.id]); } @@ -154,20 +217,4 @@ export class PermissionClient implements PermissionAuthorizer { private getAuthorizationHeader(token?: string): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } - - private assertValidResponse( - request: EvaluatePermissionRequestBatch, - json: any, - ): asserts json is EvaluatePermissionResponseBatch { - const authorizedResponses = responseSchema.parse(json); - const responseIds = authorizedResponses.items.map(r => r.id); - const hasAllRequestIds = request.items.every(r => - responseIds.includes(r.id), - ); - if (!hasAllRequestIds) { - throw new Error( - 'Unexpected authorization response from permission-backend', - ); - } - } } diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 1bc9af5ad8..c1996fc462 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -91,6 +91,20 @@ export type PolicyDecision = | DefinitivePolicyDecision | ConditionalPolicyDecision; +/** + * A query to be evaluated by the {@link PermissionPolicy}. + * + * @remarks + * + * Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps + * the policy decoupled from the resource loading and condition applying logic. + * + * @public + */ +export type PolicyQuery = { + permission: Permission; +}; + /** * A condition returned with a CONDITIONAL authorization response. * diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index f244a70b1a..dd644ff345 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -36,6 +36,7 @@ export type { AllOfCriteria, AnyOfCriteria, NotCriteria, + PolicyQuery, } from './api'; export type { DiscoveryApi } from './discovery'; export type { diff --git a/plugins/permission-node/src/ServerPermissionClient.test.ts b/plugins/permission-node/src/ServerPermissionClient.test.ts index f5711ed4a0..2e3e5d988f 100644 --- a/plugins/permission-node/src/ServerPermissionClient.test.ts +++ b/plugins/permission-node/src/ServerPermissionClient.test.ts @@ -17,9 +17,10 @@ import { ServerPermissionClient } from './ServerPermissionClient'; import { IdentifiedPermissionMessage, - EvaluatePermissionRequest, AuthorizeResult, createPermission, + DefinitivePolicyDecision, + ConditionalPolicyDecision, } from '@backstage/plugin-permission-common'; import { ConfigReader } from '@backstage/config'; import { @@ -31,16 +32,7 @@ import { setupServer } from 'msw/node'; import { RestContext, rest } from 'msw'; const server = setupServer(); -const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { - const responses = req.body.items.map( - (r: IdentifiedPermissionMessage) => ({ - id: r.id, - result: AuthorizeResult.ALLOW, - }), - ); - return res(json({ items: responses })); -}); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discovery: PluginEndpointDiscovery = { async getBaseUrl() { @@ -50,10 +42,17 @@ const discovery: PluginEndpointDiscovery = { return mockBaseUrl; }, }; -const testPermission = createPermission({ +const testBasicPermission = createPermission({ name: 'test.permission', attributes: {}, }); + +const testResourcePermission = createPermission({ + name: 'test.permission-2', + attributes: {}, + resourceType: 'resource-type', +}); + const config = new ConfigReader({ permission: { enabled: true }, backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, @@ -63,49 +62,6 @@ const logger = getVoidLogger(); describe('ServerPermissionClient', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterAll(() => server.close()); - beforeEach(() => { - server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); - }); - afterEach(() => server.resetHandlers()); - - it('should bypass authorization if permissions are disabled', async () => { - const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { - discovery, - tokenManager: ServerTokenManager.noop(), - }); - - await client.authorize([{ permission: testPermission }]); - - expect(mockAuthorizeHandler).not.toHaveBeenCalled(); - }); - - it('should bypass authorization if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); - const client = ServerPermissionClient.fromConfig(config, { - discovery, - tokenManager, - }); - - await client.authorize([{ permission: testPermission }], { - token: (await tokenManager.getToken()).token, - }); - - expect(mockAuthorizeHandler).not.toHaveBeenCalled(); - }); - - it('should authorize normally if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); - const client = ServerPermissionClient.fromConfig(config, { - discovery, - tokenManager, - }); - - await client.authorize([{ permission: testPermission }], { - token: 'a-user-token', - }); - - expect(mockAuthorizeHandler).toHaveBeenCalled(); - }); it('should error if permissions are enabled but a no-op token manager is configured', async () => { expect(() => @@ -117,4 +73,126 @@ describe('ServerPermissionClient', () => { 'Backend-to-backend authentication must be configured before enabling permissions. Read more here https://backstage.io/docs/tutorials/backend-to-backend-auth', ); }); + + describe('authorize', () => { + let mockAuthorizeHandler: jest.Mock; + + beforeEach(() => { + mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (r: IdentifiedPermissionMessage) => ({ + id: r.id, + result: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }); + + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + afterEach(() => server.resetHandlers()); + + it('should bypass the permission backend if permissions are disabled', async () => { + const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { + discovery, + tokenManager: ServerTokenManager.noop(), + }); + + await client.authorize([ + { + permission: testBasicPermission, + }, + ]); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testBasicPermission }], { + token: (await tokenManager.getToken()).token, + }); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testBasicPermission }], { + token: 'a-user-token', + }); + + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + }); + + describe('query', () => { + let mockAuthorizeHandler: jest.Mock; + + beforeEach(() => { + mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (r: IdentifiedPermissionMessage) => ({ + id: r.id, + result: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }); + + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + afterEach(() => server.resetHandlers()); + + it('should bypass the permission backend if permissions are disabled', async () => { + const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { + discovery, + tokenManager: ServerTokenManager.noop(), + }); + + await client.query([{ permission: testResourcePermission }]); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.query([{ permission: testResourcePermission }], { + token: (await tokenManager.getToken()).token, + }); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.query([{ permission: testResourcePermission }], { + token: 'a-user-token', + }); + + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + }); }); diff --git a/plugins/permission-node/src/ServerPermissionClient.ts b/plugins/permission-node/src/ServerPermissionClient.ts index 56381dff17..2ea11e9e95 100644 --- a/plugins/permission-node/src/ServerPermissionClient.ts +++ b/plugins/permission-node/src/ServerPermissionClient.ts @@ -20,12 +20,14 @@ import { } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { - EvaluatePermissionRequest, - AuthorizeRequestOptions, - EvaluatePermissionResponse, AuthorizeResult, PermissionClient, - PermissionAuthorizer, + PermissionEvaluator, + AuthorizePermissionRequest, + EvaluatorRequestOptions, + AuthorizePermissionResponse, + PolicyDecision, + QueryPermissionRequest, } from '@backstage/plugin-permission-common'; /** @@ -34,7 +36,7 @@ import { * backend-to-backend requests. * @public */ -export class ServerPermissionClient implements PermissionAuthorizer { +export class ServerPermissionClient implements PermissionEvaluator { private readonly permissionClient: PermissionClient; private readonly tokenManager: TokenManager; private readonly permissionEnabled: boolean; @@ -76,22 +78,22 @@ export class ServerPermissionClient implements PermissionAuthorizer { this.tokenManager = options.tokenManager; this.permissionEnabled = options.permissionEnabled; } + async query( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return (await this.isEnabled(options?.token)) + ? this.permissionClient.query(queries, options) + : queries.map(_ => ({ result: AuthorizeResult.ALLOW })); + } async authorize( - requests: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise { - // Check if permissions are enabled before validating the server token. That - // way when permissions are disabled, the noop token manager can be used - // without fouling up the logic inside the ServerPermissionClient, because - // the code path won't be reached. - if ( - !this.permissionEnabled || - (await this.isValidServerToken(options?.token)) - ) { - return requests.map(_ => ({ result: AuthorizeResult.ALLOW })); - } - return this.permissionClient.authorize(requests, options); + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return (await this.isEnabled(options?.token)) + ? this.permissionClient.authorize(requests, options) + : requests.map(_ => ({ result: AuthorizeResult.ALLOW })); } private async isValidServerToken( @@ -105,4 +107,12 @@ export class ServerPermissionClient implements PermissionAuthorizer { .then(() => true) .catch(() => false); } + + private async isEnabled(token?: string) { + // Check if permissions are enabled before validating the server token. That + // way when permissions are disabled, the noop token manager can be used + // without fouling up the logic inside the ServerPermissionClient, because + // the code path won't be reached. + return this.permissionEnabled && !(await this.isValidServerToken(token)); + } } diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts index f151cfb4fb..0b7e03cc7c 100644 --- a/plugins/permission-node/src/policy/index.ts +++ b/plugins/permission-node/src/policy/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export type { PermissionPolicy, PolicyQuery } from './types'; +export type { PermissionPolicy } from './types'; diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index 0f469cfbfe..992ad3a1cf 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -15,25 +15,11 @@ */ import { - Permission, PolicyDecision, + PolicyQuery, } from '@backstage/plugin-permission-common'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; -/** - * A query to be evaluated by the {@link PermissionPolicy}. - * - * @remarks - * - * Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps - * the policy decoupled from the resource loading and condition applying logic. - * - * @public - */ -export type PolicyQuery = { - permission: Permission; -}; - /** * A policy to evaluate authorization requests for any permissioned action performed in Backstage. * From b1bbb9c7605ad8ed65878cdba5188666fd312888 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 24 Mar 2022 10:07:10 +0100 Subject: [PATCH 042/144] Fix IdentityPermissionApi typings Signed-off-by: Vincenzo Scamporlino --- .../src/apis/IdentityPermissionApi.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 80d4eb8859..0bb46f5587 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -19,6 +19,7 @@ import { PermissionApi } from './PermissionApi'; import { EvaluatePermissionRequest, EvaluatePermissionResponse, + isResourcePermission, PermissionClient, } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; @@ -47,8 +48,21 @@ export class IdentityPermissionApi implements PermissionApi { async authorize( request: EvaluatePermissionRequest, ): Promise { + const { permission, resourceRef } = request; + if (isResourcePermission(permission)) { + if (!resourceRef) { + throw new Error( + 'A resourceRef should be provided when a ResourcePermission is used.', + ); + } + const response = await this.permissionClient.authorize( + [{ permission, resourceRef }], + await this.identityApi.getCredentials(), + ); + return response[0]; + } const response = await this.permissionClient.authorize( - [request], + [{ permission }], await this.identityApi.getCredentials(), ); return response[0]; From 2903c1fd5d9957bd704d60c8662f3652b5b8e31c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 24 Mar 2022 14:20:32 +0100 Subject: [PATCH 043/144] Move PolicyQuery to permission-node Signed-off-by: Vincenzo Scamporlino --- plugins/permission-common/api-report.md | 12 +++++++---- plugins/permission-common/src/types/api.ts | 14 ------------- plugins/permission-common/src/types/index.ts | 1 - plugins/permission-node/api-report.md | 22 +++++++++++++------- plugins/permission-node/src/policy/index.ts | 2 +- plugins/permission-node/src/policy/types.ts | 16 +++++++++++++- 6 files changed, 38 insertions(+), 29 deletions(-) diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index 2f283c0d8d..e952425bbf 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -157,12 +157,16 @@ export type PermissionBase = { } & TFields; // @public -export class PermissionClient implements PermissionAuthorizer { +export class PermissionClient implements PermissionEvaluator { constructor(options: { discovery: DiscoveryApi; config: Config }); authorize( - queries: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise; + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + query( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; } // @public diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index c1996fc462..1bc9af5ad8 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -91,20 +91,6 @@ export type PolicyDecision = | DefinitivePolicyDecision | ConditionalPolicyDecision; -/** - * A query to be evaluated by the {@link PermissionPolicy}. - * - * @remarks - * - * Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps - * the policy decoupled from the resource loading and condition applying logic. - * - * @public - */ -export type PolicyQuery = { - permission: Permission; -}; - /** * A condition returned with a CONDITIONAL authorization response. * diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index dd644ff345..f244a70b1a 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -36,7 +36,6 @@ export type { AllOfCriteria, AnyOfCriteria, NotCriteria, - PolicyQuery, } from './api'; export type { DiscoveryApi } from './discovery'; export type { diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 57379dd68b..3cf6b70d08 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -5,22 +5,23 @@ ```ts import { AllOfCriteria } from '@backstage/plugin-permission-common'; import { AnyOfCriteria } from '@backstage/plugin-permission-common'; -import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; import { DefinitivePolicyDecision } from '@backstage/plugin-permission-common'; -import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common'; -import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common'; +import { EvaluatorRequestOptions } from '@backstage/plugin-permission-common'; import express from 'express'; import { IdentifiedPermissionMessage } from '@backstage/plugin-permission-common'; import { NotCriteria } from '@backstage/plugin-permission-common'; import { Permission } from '@backstage/plugin-permission-common'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PolicyDecision } from '@backstage/plugin-permission-common'; +import { QueryPermissionRequest } from '@backstage/plugin-permission-common'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { TokenManager } from '@backstage/backend-common'; @@ -178,12 +179,12 @@ export type PolicyQuery = { }; // @public -export class ServerPermissionClient implements PermissionAuthorizer { +export class ServerPermissionClient implements PermissionEvaluator { // (undocumented) authorize( - requests: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise; + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; // (undocumented) static fromConfig( config: Config, @@ -192,5 +193,10 @@ export class ServerPermissionClient implements PermissionAuthorizer { tokenManager: TokenManager; }, ): ServerPermissionClient; + // (undocumented) + query( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; } ``` diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts index 0b7e03cc7c..f151cfb4fb 100644 --- a/plugins/permission-node/src/policy/index.ts +++ b/plugins/permission-node/src/policy/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export type { PermissionPolicy } from './types'; +export type { PermissionPolicy, PolicyQuery } from './types'; diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index 992ad3a1cf..0f469cfbfe 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -15,11 +15,25 @@ */ import { + Permission, PolicyDecision, - PolicyQuery, } from '@backstage/plugin-permission-common'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +/** + * A query to be evaluated by the {@link PermissionPolicy}. + * + * @remarks + * + * Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps + * the policy decoupled from the resource loading and condition applying logic. + * + * @public + */ +export type PolicyQuery = { + permission: Permission; +}; + /** * A policy to evaluate authorization requests for any permissioned action performed in Backstage. * From ef2dd617e3ae4d31d40a417f02590d94b8c4b879 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 10:17:26 +0200 Subject: [PATCH 044/144] Fix comments Signed-off-by: Vincenzo Scamporlino --- .../permission-common/src/PermissionClient.ts | 51 +++---------------- plugins/permission-common/src/types/api.ts | 1 + 2 files changed, 9 insertions(+), 43 deletions(-) diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 9ca9bfe255..3f7b459cb5 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -21,12 +21,10 @@ import * as uuid from 'uuid'; import { z } from 'zod'; import { AuthorizeResult, - DefinitivePolicyDecision, PermissionMessageBatch, PermissionCriteria, PermissionCondition, PermissionEvaluator, - PolicyDecision, QueryPermissionRequest, AuthorizePermissionRequest, EvaluatorRequestOptions, @@ -59,15 +57,14 @@ const permissionCriteriaSchema: z.ZodSchema< .or(z.object({ not: permissionCriteriaSchema }).strict()), ); -const authorizeDecisionSchema: z.ZodSchema = z.object( - { +const authorizeDecisionSchema: z.ZodSchema = + z.object({ result: z .literal(AuthorizeResult.ALLOW) .or(z.literal(AuthorizeResult.DENY)), - }, -); + }); -const policyDecisionSchema: z.ZodSchema = z.union([ +const policyDecisionSchema: z.ZodSchema = z.union([ z.object({ result: z .literal(AuthorizeResult.ALLOW) @@ -119,49 +116,17 @@ export class PermissionClient implements PermissionEvaluator { } /** - * Request authorization from the permission-backend for the given set of - * permissions. - * - * @remarks - * - * Checks that a given Backstage user can perform a protected operation. When - * authorization is for a {@link ResourcePermission}s, a resourceRef - * corresponding to the resource should always be supplied along with the - * permission. The Backstage identity token should be included in the - * `options` if available. - * - * Permissions can be imported from plugins exposing them, such as - * `catalogEntityReadPermission`. - * - * For each query, the response will be either ALLOW or DENY. - * - * @public + * {@inheritdoc PermissionEvaluator.authorize} */ async authorize( requests: AuthorizePermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { - // TODO(permissions): it would be great to provide some kind of typing guarantee that - // conditional responses will only ever be returned for requests containing a resourceType - // but no resourceRef. That way clients who aren't prepared to handle filtering according - // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. - return this.makeRequest(requests, authorizeDecisionSchema, options); } /** - * Fetch the conditional authorization decisions for the given set of - * {@link ResourcePermission}s in order to apply the conditions to an upstream - * data source. - * - * @remarks - * - * For each query, the response will be either ALLOW, DENY, or CONDITIONAL. - * Conditional responses are intended only for backends which have access to - * the data source for permissioned resources, so that filters can be applied - * when loading collections of resources. - * - * @public + * {@inheritdoc PermissionEvaluator.query} */ async query( queries: QueryPermissionRequest[], @@ -179,7 +144,7 @@ export class PermissionClient implements PermissionEvaluator { return queries.map(_ => ({ result: AuthorizeResult.ALLOW as const })); } - const request = { + const request: PermissionMessageBatch = { items: queries.map(query => ({ id: uuid.v4(), ...query, @@ -209,7 +174,7 @@ export class PermissionClient implements PermissionEvaluator { const responsesById = parsedResponse.items.reduce((acc, r) => { acc[r.id] = r; return acc; - }, {} as Record); + }, {} as Record>); return request.items.map(query => responsesById[query.id]); } diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 1bc9af5ad8..0e4cf782e1 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -247,6 +247,7 @@ export interface PermissionEvaluator { /** * Options for {@link PermissionEvaluator} requests. + * The Backstage identity token should be defined if available. * @public */ export type EvaluatorRequestOptions = { From 23646e51a51065e8773f5fe4b56bbe4e5f1ccbf9 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 8 Mar 2022 13:16:16 +0000 Subject: [PATCH 045/144] catalog-backend: use new PermissionAuthorizer#policyDecision method Signed-off-by: Mike Lewis --- .changeset/cuddly-turtles-sleep.md | 5 +++++ .../service/AuthorizedEntitiesCatalog.test.ts | 21 ++++++++++--------- .../src/service/AuthorizedEntitiesCatalog.ts | 6 +++--- .../service/AuthorizedLocationService.test.ts | 1 + .../service/AuthorizedRefreshService.test.ts | 1 + 5 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 .changeset/cuddly-turtles-sleep.md diff --git a/.changeset/cuddly-turtles-sleep.md b/.changeset/cuddly-turtles-sleep.md new file mode 100644 index 0000000000..bb1a925506 --- /dev/null +++ b/.changeset/cuddly-turtles-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Use new `PermissionEvaluator#query` method when retrieving permission conditions. diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index b29e1121b3..16d3070c6d 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -30,6 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => { }; const fakePermissionApi = { authorize: jest.fn(), + policyDecision: jest.fn(), }; const createCatalog = (...rules: CatalogPermissionRule[]) => @@ -45,7 +46,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('entities', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -61,7 +62,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -78,7 +79,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); @@ -98,7 +99,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -113,7 +114,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('throws error on CONDITIONAL authorization that evaluates to 0 entities', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -132,7 +133,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on CONDITIONAL authorization that evaluates to nonzero entities', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -158,7 +159,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -252,7 +253,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('facets', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -268,7 +269,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -286,7 +287,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.policyDecision.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 2f94e23303..fe1bb579f5 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -45,7 +45,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async entities(request?: EntitiesRequest): Promise { const authorizeDecision = ( - await this.permissionApi.authorize( + await this.permissionApi.policyDecision( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) @@ -78,7 +78,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { options?: { authorizationToken?: string }, ): Promise { const authorizeResponse = ( - await this.permissionApi.authorize( + await this.permissionApi.policyDecision( [{ permission: catalogEntityDeletePermission }], { token: options?.authorizationToken }, ) @@ -155,7 +155,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async facets(request: EntityFacetsRequest): Promise { const authorizeDecision = ( - await this.permissionApi.authorize( + await this.permissionApi.policyDecision( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index b27ca92924..3db6bff208 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -27,6 +27,7 @@ describe('AuthorizedLocationService', () => { }; const fakePermissionApi = { authorize: jest.fn(), + policyDecision: jest.fn(), }; const mockAllow = () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts index 82bedec573..d1c4fe3fc1 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts @@ -25,6 +25,7 @@ describe('AuthorizedRefreshService', () => { }; const permissionApi = { authorize: jest.fn(), + policyDecision: jest.fn(), }; afterEach(() => { From b831d53972f7f3dfd88c442fb1d3164809c16ad9 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 8 Mar 2022 13:18:24 +0000 Subject: [PATCH 046/144] jenkins-backend: update PermissionAuthorizer mock in jenkinsApi test suite Signed-off-by: Mike Lewis --- .changeset/spicy-dingos-serve.md | 5 +++++ plugins/jenkins-backend/src/service/jenkinsApi.test.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/spicy-dingos-serve.md diff --git a/.changeset/spicy-dingos-serve.md b/.changeset/spicy-dingos-serve.md new file mode 100644 index 0000000000..282818b49d --- /dev/null +++ b/.changeset/spicy-dingos-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +Add `policyDecision` method to `PermissionClient` mock in jenkinsApi test suite. diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index a5453b5ebe..e11ed32c54 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -49,6 +49,7 @@ const fakePermissionApi = { result: AuthorizeResult.ALLOW, }, ]), + policyDecision: jest.fn(), }; describe('JenkinsApi', () => { From 3c8cfaaa800cc6ba84150b3021d7460baf4ee4d0 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 8 Mar 2022 13:20:35 +0000 Subject: [PATCH 047/144] search-backend: use new policyDecision method in AuthorizedSearchEngine and test suites Signed-off-by: Mike Lewis --- .changeset/eight-cobras-think.md | 5 + .../service/AuthorizedSearchEngine.test.ts | 155 +++++++++++------- .../src/service/AuthorizedSearchEngine.ts | 32 +++- .../search-backend/src/service/router.test.ts | 3 + 4 files changed, 127 insertions(+), 68 deletions(-) create mode 100644 .changeset/eight-cobras-think.md diff --git a/.changeset/eight-cobras-think.md b/.changeset/eight-cobras-think.md new file mode 100644 index 0000000000..df661d1a0a --- /dev/null +++ b/.changeset/eight-cobras-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Use new `PermissionAuthorizer#policyDecision` method in `AuthorizedSearchEngine` and test suites. diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts index 47503312fb..8a7662c1f4 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts @@ -20,6 +20,8 @@ import { AuthorizeResult, createPermission, PermissionAuthorizer, + PolicyDecision, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, @@ -69,12 +71,15 @@ describe('AuthorizedSearchEngine', () => { query: mockedQuery, }; - const mockedAuthorize: jest.MockedFunction< - PermissionAuthorizer['authorize'] + const mockedAuthorize: jest.MockedFunction = + jest.fn(); + const mockedPermissionQuery: jest.MockedFunction< + PermissionEvaluator['query'] > = jest.fn(); - const permissionAuthorizer: PermissionAuthorizer = { + const permissionAuthorizer: PermissionEvaluator = { authorize: mockedAuthorize, + query: mockedPermissionQuery, }; const defaultTypes: Record = { @@ -117,7 +122,8 @@ describe('AuthorizedSearchEngine', () => { const options = { token: 'token' }; - const allowAll: PermissionAuthorizer['authorize'] = async queries => { + const allowAll: PermissionAuthorizer['authorize'] & + PermissionEvaluator['query'] = async queries => { return queries.map(() => ({ result: AuthorizeResult.ALLOW, })); @@ -126,11 +132,12 @@ describe('AuthorizedSearchEngine', () => { beforeEach(() => { mockedQuery.mockReset(); mockedAuthorize.mockClear(); + mockedPermissionQuery.mockClear(); }); it('should forward the parameters correctly', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + const filters = { just: 1, a: 2, filter: 3 }; await authorizedSearchEngine.query( { term: 'term', filters, types: ['one', 'two'] }, @@ -148,7 +155,8 @@ describe('AuthorizedSearchEngine', () => { it('should forward the default types if none are passed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); + await authorizedSearchEngine.query({ term: '' }, options); expect(mockedQuery).toHaveBeenCalledWith( { term: '', types: ['users', 'templates', 'services', 'groups'] }, @@ -158,17 +166,17 @@ describe('AuthorizedSearchEngine', () => { it('should return all the results if all queries are allowed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); await expect( authorizedSearchEngine.query({ term: '' }, options), ).resolves.toEqual({ results }); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); }); it('should batch authorized requests', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); await authorizedSearchEngine.query( { term: '', types: [typeUsers, typeTemplates] }, @@ -178,8 +186,8 @@ describe('AuthorizedSearchEngine', () => { { term: '', types: ['users', 'templates'] }, { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); - expect(mockedAuthorize).toHaveBeenLastCalledWith( + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenLastCalledWith( [ { permission: defaultTypes[typeUsers].visibilityPermission }, { permission: defaultTypes[typeTemplates].visibilityPermission }, @@ -190,7 +198,7 @@ describe('AuthorizedSearchEngine', () => { it('should skip sending request for types that are not allowed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(async queries => { + mockedPermissionQuery.mockImplementation(async queries => { return queries.map(query => { if ( query.permission.name === @@ -213,7 +221,7 @@ describe('AuthorizedSearchEngine', () => { { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); }); it('should perform result-by-result filtering', async () => { @@ -226,21 +234,12 @@ describe('AuthorizedSearchEngine', () => { results: resultsWithAuth, })); - const userToBeReturned = 8; - - mockedAuthorize.mockImplementation(async queries => + mockedPermissionQuery.mockImplementation(async queries => queries.map(query => { if ( query.permission.name === defaultTypes.users.visibilityPermission?.name ) { - if (query.resourceRef) { - return { - result: query.resourceRef.endsWith(userToBeReturned.toString()) - ? AuthorizeResult.ALLOW - : AuthorizeResult.DENY, - }; - } return { result: AuthorizeResult.CONDITIONAL, } as EvaluatePermissionResponse; @@ -252,9 +251,20 @@ describe('AuthorizedSearchEngine', () => { }), ); + mockedAuthorize.mockImplementation(async queries => + queries.map(query => { + return { + result: + query.resourceRef! === `users_doc_8` + ? AuthorizeResult.ALLOW + : AuthorizeResult.DENY, + }; + }), + ); + await expect( authorizedSearchEngine.query({ term: '' }, options), - ).resolves.toEqual({ results: [usersWithAuth[userToBeReturned]] }); + ).resolves.toEqual({ results: [usersWithAuth[8]] }); expect(mockedQuery).toHaveBeenCalledWith( { term: '', types: ['users'] }, @@ -284,27 +294,27 @@ describe('AuthorizedSearchEngine', () => { results: searchResults, })); - mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { - result: AuthorizeResult.ALLOW, - }; - } + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as EvaluatePermissionResponse), + ), + ); - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + mockedAuthorize.mockImplementation(async queries => + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), ); await expect( authorizedSearchEngine.query({ term: '', types: ['templates'] }, options), ).resolves.toEqual({ results: searchResults }); - expect(mockedAuthorize).toHaveBeenCalledTimes(2); - expect(mockedAuthorize).toHaveBeenNthCalledWith( - 1, + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledWith( [ { permission: expect.objectContaining({ @@ -314,8 +324,8 @@ describe('AuthorizedSearchEngine', () => { ], { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenNthCalledWith( - 2, + expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedAuthorize).toHaveBeenCalledWith( [ { permission: expect.objectContaining({ @@ -329,7 +339,16 @@ describe('AuthorizedSearchEngine', () => { }); it('should perform search until the target number of results is reached', async () => { - mockedAuthorize.mockImplementation(async queries => + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + + mockedPermissionQuery.mockImplementation(async queries => queries.map(query => { if (query.resourceRef) { return { @@ -342,6 +361,12 @@ describe('AuthorizedSearchEngine', () => { }), ); + mockedAuthorize.mockImplementation(async queries => + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), + ); + const usersWithAuth = generateSampleResults(typeUsers, true); const templatesWithAuth = generateSampleResults(typeTemplates, true); const servicesWithAuth = generateSampleResults(typeServices, true); @@ -405,20 +430,22 @@ describe('AuthorizedSearchEngine', () => { }); it('should perform search until the target number of results is reached, excluding unauthorized results', async () => { + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { - result: - query.permission.name === 'search.services.read' - ? AuthorizeResult.DENY - : AuthorizeResult.ALLOW, - }; - } - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + queries.map(query => ({ + result: + query.permission.name === 'search.services.read' + ? AuthorizeResult.DENY + : AuthorizeResult.ALLOW, + })), ); const usersWithAuth = generateSampleResults(typeUsers, true); @@ -494,15 +521,19 @@ describe('AuthorizedSearchEngine', () => { }); it('should discard results until the target cursor is reached', async () => { + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { result: AuthorizeResult.ALLOW }; - } - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), ); const usersWithAuth = generateSampleResults(typeUsers, true); diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts index a529bf04c6..2397734089 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts @@ -22,7 +22,9 @@ import { EvaluatePermissionRequest, AuthorizeResult, isResourcePermission, - PermissionAuthorizer, + PermissionEvaluator, + AuthorizePermissionRequest, + QueryPermissionRequest, } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, @@ -67,7 +69,7 @@ export class AuthorizedSearchEngine implements SearchEngine { constructor( private readonly searchEngine: SearchEngine, private readonly types: Record, - private readonly permissions: PermissionAuthorizer, + private readonly permissions: PermissionEvaluator, config: Config, ) { this.queryLatencyBudgetMs = @@ -89,8 +91,16 @@ export class AuthorizedSearchEngine implements SearchEngine { ): Promise { const queryStartTime = Date.now(); + const conditionFetcher = new DataLoader( + (requests: readonly QueryPermissionRequest[]) => + this.permissions.query(requests.slice(), options), + { + cacheKeyFn: ({ permission: { name } }) => name, + }, + ); + const authorizer = new DataLoader( - (requests: readonly EvaluatePermissionRequest[]) => + (requests: readonly AuthorizePermissionRequest[]) => this.permissions.authorize(requests.slice(), options), { // Serialize the permission name and resourceRef as @@ -100,6 +110,7 @@ export class AuthorizedSearchEngine implements SearchEngine { qs.stringify({ name, resourceRef }), }, ); + const requestedTypes = query.types || Object.keys(this.types); const typeDecisions = zipObject( @@ -108,9 +119,18 @@ export class AuthorizedSearchEngine implements SearchEngine { requestedTypes.map(type => { const permission = this.types[type]?.visibilityPermission; - return permission - ? authorizer.load({ permission }) - : { result: AuthorizeResult.ALLOW as const }; + // No permission configured for this document type - always allow. + if (!permission) { + return { result: AuthorizeResult.ALLOW as const }; + } + + // Resource permission supplied, so we need to check for conditional decisions. + if (isResourcePermission(permission)) { + return conditionFetcher.load({ permission }); + } + + // Non-resource permission supplied - we can perform a standard authorization. + return authorizer.load({ permission }); }), ), ); diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 2efab91256..e93f0dd671 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -30,6 +30,9 @@ const mockPermissionAuthorizer: PermissionAuthorizer = { authorize: () => { throw new Error('Not implemented'); }, + policyDecision: () => { + throw new Error('Not implemented'); + }, }; describe('createRouter', () => { From dc8037213cd1291ab4b0f197409b0470d2f08825 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 12:16:27 +0200 Subject: [PATCH 048/144] Use PermissionEvaluator Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-backend/api-report.md | 4 ++-- .../service/AuthorizedEntitiesCatalog.test.ts | 22 +++++++++---------- .../src/service/AuthorizedEntitiesCatalog.ts | 10 ++++----- .../src/service/CatalogBuilder.ts | 4 ++-- .../src/PermissionClient.test.ts | 2 ++ plugins/search-backend/api-report.md | 4 ++-- .../search-backend/src/service/router.test.ts | 6 ++--- plugins/search-backend/src/service/router.ts | 4 ++-- 8 files changed, 29 insertions(+), 27 deletions(-) diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index d44e744bb6..7efdfec424 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -19,9 +19,9 @@ import { JsonValue } from '@backstage/types'; import { LocationEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -181,7 +181,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator; }; // @alpha diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 16d3070c6d..dbdcde11c4 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -30,7 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => { }; const fakePermissionApi = { authorize: jest.fn(), - policyDecision: jest.fn(), + query: jest.fn(), }; const createCatalog = (...rules: CatalogPermissionRule[]) => @@ -46,7 +46,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('entities', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -62,7 +62,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -79,7 +79,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); @@ -99,7 +99,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -114,7 +114,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('throws error on CONDITIONAL authorization that evaluates to 0 entities', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -133,7 +133,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on CONDITIONAL authorization that evaluates to nonzero entities', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -159,7 +159,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -253,7 +253,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('facets', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -269,7 +269,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -287,7 +287,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.policyDecision.mockResolvedValue([ + fakePermissionApi.query.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index fe1bb579f5..fcd05f95d8 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -22,7 +22,7 @@ import { import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { ConditionTransformer } from '@backstage/plugin-permission-node'; import { @@ -39,13 +39,13 @@ import { basicEntityFilter } from './request/basicEntityFilter'; export class AuthorizedEntitiesCatalog implements EntitiesCatalog { constructor( private readonly entitiesCatalog: EntitiesCatalog, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, private readonly transformConditions: ConditionTransformer, ) {} async entities(request?: EntitiesRequest): Promise { const authorizeDecision = ( - await this.permissionApi.policyDecision( + await this.permissionApi.query( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) @@ -78,7 +78,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { options?: { authorizationToken?: string }, ): Promise { const authorizeResponse = ( - await this.permissionApi.policyDecision( + await this.permissionApi.query( [{ permission: catalogEntityDeletePermission }], { token: options?.authorizationToken }, ) @@ -155,7 +155,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async facets(request: EntityFacetsRequest): Promise { const authorizeDecision = ( - await this.permissionApi.policyDecision( + await this.permissionApi.query( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 075fcd6a5d..e9135393ba 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -79,7 +79,7 @@ import { CatalogPermissionRule, permissionRules as catalogPermissionRules, } from '../permissions/rules'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { createConditionTransformer, createPermissionIntegrationRouter, @@ -95,7 +95,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator; }; /** diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index ec038e7f16..8da30185b7 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -225,6 +225,7 @@ describe('PermissionClient', () => { resourceType: 'test-resource', result: AuthorizeResult.CONDITIONAL, conditions: { + resourceType: 'test-resource', rule: 'FOO', params: ['bar'], }, @@ -271,6 +272,7 @@ describe('PermissionClient', () => { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'FOO', + resourceType: 'test-resource', params: ['bar'], }, }), diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index 3c7f9c2754..dc14d2f15b 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -7,7 +7,7 @@ import { Config } from '@backstage/config'; import { DocumentTypeInfo } from '@backstage/plugin-search-common'; import express from 'express'; import { Logger } from 'winston'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -21,7 +21,7 @@ export function createRouter(options: RouterOptions): Promise; export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator; config: Config; logger: Logger; }; diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index e93f0dd671..85773fb3b8 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -16,7 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { IndexBuilder, SearchEngine, @@ -26,11 +26,11 @@ import request from 'supertest'; import { createRouter } from './router'; -const mockPermissionAuthorizer: PermissionAuthorizer = { +const mockPermissionAuthorizer: PermissionEvaluator = { authorize: () => { throw new Error('Not implemented'); }, - policyDecision: () => { + query: () => { throw new Error('Not implemented'); }, }; diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index ff91465cf4..6867c382df 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -23,7 +23,7 @@ import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, IndexableResultSet, @@ -50,7 +50,7 @@ const jsonObjectSchema: z.ZodSchema = z.lazy(() => { export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator; config: Config; logger: Logger; }; From 322b69e46a7bedff7e60f1b103dac5f1120feea3 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 16:01:28 +0200 Subject: [PATCH 049/144] Add changesets Signed-off-by: Vincenzo Scamporlino --- .changeset/eight-cobras-think.md | 2 +- .changeset/four-dolphins-report.md | 5 +++++ .changeset/soft-rice-remember.md | 5 +++++ .changeset/spicy-dingos-serve.md | 5 ----- 4 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/four-dolphins-report.md create mode 100644 .changeset/soft-rice-remember.md delete mode 100644 .changeset/spicy-dingos-serve.md diff --git a/.changeset/eight-cobras-think.md b/.changeset/eight-cobras-think.md index df661d1a0a..854e4fcec8 100644 --- a/.changeset/eight-cobras-think.md +++ b/.changeset/eight-cobras-think.md @@ -2,4 +2,4 @@ '@backstage/plugin-search-backend': patch --- -Use new `PermissionAuthorizer#policyDecision` method in `AuthorizedSearchEngine` and test suites. +Fix typing when invoking `PermissionClient#authorize` diff --git a/.changeset/four-dolphins-report.md b/.changeset/four-dolphins-report.md new file mode 100644 index 0000000000..52bbbac1e8 --- /dev/null +++ b/.changeset/four-dolphins-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +Use new `PermissionEvaluator#query` method in `ServerPermissionClient` and test suites. diff --git a/.changeset/soft-rice-remember.md b/.changeset/soft-rice-remember.md new file mode 100644 index 0000000000..27e4c0a35d --- /dev/null +++ b/.changeset/soft-rice-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-react': patch +--- + +Fix typing when invoking `PermissionClient#authorize` diff --git a/.changeset/spicy-dingos-serve.md b/.changeset/spicy-dingos-serve.md deleted file mode 100644 index 282818b49d..0000000000 --- a/.changeset/spicy-dingos-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-jenkins-backend': patch ---- - -Add `policyDecision` method to `PermissionClient` mock in jenkinsApi test suite. From 06d32493e65f4c329544486f5f924f5f25ffbf27 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 16:29:24 +0200 Subject: [PATCH 050/144] Fix integration tests Signed-off-by: Vincenzo Scamporlino --- .../templates/default-app/packages/backend/src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 0862b0e874..7984b7361f 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -8,7 +8,7 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export type PluginEnvironment = { logger: Logger; @@ -19,5 +19,5 @@ export type PluginEnvironment = { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; - permissions: PermissionAuthorizer; + permissions: ServerPermissionClient; }; From 1882dbda2b1fe792e8589443c1a65283edcc2d65 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 22:43:52 +0200 Subject: [PATCH 051/144] Add create-app changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/rare-parents-pretend.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/rare-parents-pretend.md diff --git a/.changeset/rare-parents-pretend.md b/.changeset/rare-parents-pretend.md new file mode 100644 index 0000000000..b483666e66 --- /dev/null +++ b/.changeset/rare-parents-pretend.md @@ -0,0 +1,21 @@ +--- +'@backstage/create-app': patch +--- + +Use `ServerPermissionClient` instead of `PermissionAuthorizer`. + +Apply the following to `packages/backend/src/types.ts`: + +```diff +- import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; ++ import { ServerPermissionClient } from '@backstage/plugin-permission-node'; + + export type PluginEnvironment = { + ... + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + scheduler: PluginTaskScheduler; +- permissions: PermissionAuthorizer; ++ permissions: ServerPermissionClient; + }; +``` From e7f28d8152ca726841f5045c813d33524f757da7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 28 Mar 2022 22:58:43 +0200 Subject: [PATCH 052/144] IdentityPermissionApi: strict typing Signed-off-by: Vincenzo Scamporlino --- .changeset/soft-rice-remember.md | 2 +- plugins/permission-react/api-report.md | 6 +++-- .../src/apis/IdentityPermissionApi.ts | 24 ++++--------------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/.changeset/soft-rice-remember.md b/.changeset/soft-rice-remember.md index 27e4c0a35d..bcb2860d18 100644 --- a/.changeset/soft-rice-remember.md +++ b/.changeset/soft-rice-remember.md @@ -2,4 +2,4 @@ '@backstage/plugin-permission-react': patch --- -Fix typing when invoking `PermissionClient#authorize` +Make `IdentityPermissionApi#authorize` typing more strict, using `AuthorizePermissionRequest` and `AuthorizePermissionResponse`. diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md index 17b29a463a..12f1300c70 100644 --- a/plugins/permission-react/api-report.md +++ b/plugins/permission-react/api-report.md @@ -4,6 +4,8 @@ ```ts import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common'; import { ComponentProps } from 'react'; import { Config } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -26,8 +28,8 @@ export type AsyncPermissionResult = { export class IdentityPermissionApi implements PermissionApi { // (undocumented) authorize( - request: EvaluatePermissionRequest, - ): Promise; + request: AuthorizePermissionRequest, + ): Promise; // (undocumented) static create(options: { config: Config; diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 0bb46f5587..1fe295c9f2 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -17,9 +17,8 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { PermissionApi } from './PermissionApi'; import { - EvaluatePermissionRequest, - EvaluatePermissionResponse, - isResourcePermission, + AuthorizePermissionRequest, + AuthorizePermissionResponse, PermissionClient, } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; @@ -46,23 +45,10 @@ export class IdentityPermissionApi implements PermissionApi { } async authorize( - request: EvaluatePermissionRequest, - ): Promise { - const { permission, resourceRef } = request; - if (isResourcePermission(permission)) { - if (!resourceRef) { - throw new Error( - 'A resourceRef should be provided when a ResourcePermission is used.', - ); - } - const response = await this.permissionClient.authorize( - [{ permission, resourceRef }], - await this.identityApi.getCredentials(), - ); - return response[0]; - } + request: AuthorizePermissionRequest, + ): Promise { const response = await this.permissionClient.authorize( - [{ permission }], + [request], await this.identityApi.getCredentials(), ); return response[0]; From a119dfbbd5c422773e6efce546032baf4b95e614 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 30 Mar 2022 14:58:51 +0200 Subject: [PATCH 053/144] Apply suggestions from code review Co-authored-by: Joe Porpeglia Signed-off-by: Vincenzo Scamporlino --- .changeset/soft-rice-remember.md | 2 +- .../service/AuthorizedLocationService.test.ts | 2 +- .../service/AuthorizedRefreshService.test.ts | 2 +- .../src/service/jenkinsApi.test.ts | 2 +- .../permission-common/src/PermissionClient.ts | 37 +++++++++++-------- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.changeset/soft-rice-remember.md b/.changeset/soft-rice-remember.md index bcb2860d18..bf207a0dd5 100644 --- a/.changeset/soft-rice-remember.md +++ b/.changeset/soft-rice-remember.md @@ -2,4 +2,4 @@ '@backstage/plugin-permission-react': patch --- -Make `IdentityPermissionApi#authorize` typing more strict, using `AuthorizePermissionRequest` and `AuthorizePermissionResponse`. +**BREAKING:** Make `IdentityPermissionApi#authorize` typing more strict, using `AuthorizePermissionRequest` and `AuthorizePermissionResponse`. diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index 3db6bff208..151ba1c3e8 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -27,7 +27,7 @@ describe('AuthorizedLocationService', () => { }; const fakePermissionApi = { authorize: jest.fn(), - policyDecision: jest.fn(), + query: jest.fn(), }; const mockAllow = () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts index d1c4fe3fc1..5d0a192470 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts @@ -25,7 +25,7 @@ describe('AuthorizedRefreshService', () => { }; const permissionApi = { authorize: jest.fn(), - policyDecision: jest.fn(), + query: jest.fn(), }; afterEach(() => { diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index e11ed32c54..7c4214f714 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -49,7 +49,7 @@ const fakePermissionApi = { result: AuthorizeResult.ALLOW, }, ]), - policyDecision: jest.fn(), + query: jest.fn(), }; describe('JenkinsApi', () => { diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 3f7b459cb5..07d60a8e65 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -57,26 +57,27 @@ const permissionCriteriaSchema: z.ZodSchema< .or(z.object({ not: permissionCriteriaSchema }).strict()), ); -const authorizeDecisionSchema: z.ZodSchema = +const authorizePermissionResponseSchema: z.ZodSchema = z.object({ result: z .literal(AuthorizeResult.ALLOW) .or(z.literal(AuthorizeResult.DENY)), }); -const policyDecisionSchema: z.ZodSchema = z.union([ - z.object({ - result: z - .literal(AuthorizeResult.ALLOW) - .or(z.literal(AuthorizeResult.DENY)), - }), - z.object({ - result: z.literal(AuthorizeResult.CONDITIONAL), - pluginId: z.string(), - resourceType: z.string(), - conditions: permissionCriteriaSchema, - }), -]); +const queryPermissionResponseSchema: z.ZodSchema = + z.union([ + z.object({ + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }), + z.object({ + result: z.literal(AuthorizeResult.CONDITIONAL), + pluginId: z.string(), + resourceType: z.string(), + conditions: permissionCriteriaSchema, + }), + ]); const responseSchema = ( itemSchema: z.ZodSchema, @@ -122,7 +123,11 @@ export class PermissionClient implements PermissionEvaluator { requests: AuthorizePermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { - return this.makeRequest(requests, authorizeDecisionSchema, options); + return this.makeRequest( + requests, + authorizePermissionResponseSchema, + options, + ); } /** @@ -132,7 +137,7 @@ export class PermissionClient implements PermissionEvaluator { queries: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { - return this.makeRequest(queries, policyDecisionSchema, options); + return this.makeRequest(queries, queryPermissionResponseSchema, options); } private async makeRequest( From afb7535c2eb3c45b136cb41b7b831391d7b755af Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 31 Mar 2022 10:00:05 +0200 Subject: [PATCH 054/144] Use PermissionEvaluator instead of ServerPermissionClient Signed-off-by: Vincenzo Scamporlino --- .changeset/four-dolphins-report.md | 4 ++-- .changeset/rare-parents-pretend.md | 6 +++--- packages/backend/src/types.ts | 4 ++-- .../templates/default-app/packages/backend/src/types.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/four-dolphins-report.md b/.changeset/four-dolphins-report.md index 52bbbac1e8..ff0178c9f6 100644 --- a/.changeset/four-dolphins-report.md +++ b/.changeset/four-dolphins-report.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-permission-node': patch +'@backstage/plugin-permission-node': minor --- -Use new `PermissionEvaluator#query` method in `ServerPermissionClient` and test suites. +**BREAKING:** `ServerPermissionClient` now implements `PermissionEvaluator`, which moves out the capabilities for evaluating conditional decisions from `authorize()` to `query()` method. diff --git a/.changeset/rare-parents-pretend.md b/.changeset/rare-parents-pretend.md index b483666e66..cb97c6999e 100644 --- a/.changeset/rare-parents-pretend.md +++ b/.changeset/rare-parents-pretend.md @@ -2,13 +2,13 @@ '@backstage/create-app': patch --- -Use `ServerPermissionClient` instead of `PermissionAuthorizer`. +Use `PermissionEvaluator` instead of `PermissionAuthorizer`. Apply the following to `packages/backend/src/types.ts`: ```diff - import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; -+ import { ServerPermissionClient } from '@backstage/plugin-permission-node'; ++ import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { ... @@ -16,6 +16,6 @@ Apply the following to `packages/backend/src/types.ts`: tokenManager: TokenManager; scheduler: PluginTaskScheduler; - permissions: PermissionAuthorizer; -+ permissions: ServerPermissionClient; ++ permissions: PermissionEvaluator; }; ``` diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 0b2543c4f5..d1f62d833b 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -23,8 +23,8 @@ import { TokenManager, UrlReader, } from '@backstage/backend-common'; -import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -34,6 +34,6 @@ export type PluginEnvironment = { reader: UrlReader; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; - permissions: ServerPermissionClient; + permissions: PermissionEvaluator; scheduler: PluginTaskScheduler; }; diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 7984b7361f..8e0a86404b 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -8,7 +8,7 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -19,5 +19,5 @@ export type PluginEnvironment = { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; - permissions: ServerPermissionClient; + permissions: PermissionEvaluator; }; From 1917923ab82e9995d783ab203e43c2ba0d0859a7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 31 Mar 2022 10:01:35 +0200 Subject: [PATCH 055/144] Jenkins: use PermissionEvaluator instead of PermissionAuthorizer Signed-off-by: Vincenzo Scamporlino --- .changeset/fresh-boxes-pull.md | 5 +++++ plugins/jenkins-backend/api-report.md | 4 ++-- plugins/jenkins-backend/src/service/jenkinsApi.ts | 4 ++-- plugins/jenkins-backend/src/service/router.ts | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/fresh-boxes-pull.md diff --git a/.changeset/fresh-boxes-pull.md b/.changeset/fresh-boxes-pull.md new file mode 100644 index 0000000000..87bff5c3a7 --- /dev/null +++ b/.changeset/fresh-boxes-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +Use PermissionEvaluator instead of PermissionAuthorizer diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 37e8f80a80..315187e669 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -8,7 +8,7 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -98,6 +98,6 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - permissions?: PermissionAuthorizer; + permissions?: PermissionEvaluator; } ``` diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 38ab20b17b..8069f57692 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -25,7 +25,7 @@ import type { } from '../types'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; import { NotAllowedError } from '@backstage/errors'; @@ -64,7 +64,7 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; - constructor(private readonly permissionApi?: PermissionAuthorizer) {} + constructor(private readonly permissionApi?: PermissionEvaluator) {} /** * Get a list of projects for the given JenkinsInfo. diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 03462b923f..1ad8f22b25 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -20,14 +20,14 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; import { JenkinsApiImpl } from './jenkinsApi'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { stringifyEntityRef } from '@backstage/catalog-model'; export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; - permissions?: PermissionAuthorizer; + permissions?: PermissionEvaluator; } export async function createRouter( From 6dd85d588adc81536ba7060e6e4f999d381dadd6 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 31 Mar 2022 10:02:23 +0200 Subject: [PATCH 056/144] catalog-backend: use new PermissionEvaluator instead of PermissionAuthorizer Signed-off-by: Vincenzo Scamporlino --- .../catalog-backend/src/service/AuthorizedLocationService.ts | 4 ++-- .../catalog-backend/src/service/AuthorizedRefreshService.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 077f56c737..a73597649e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -24,14 +24,14 @@ import { } from '@backstage/plugin-catalog-common'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { LocationInput, LocationService } from './types'; export class AuthorizedLocationService implements LocationService { constructor( private readonly locationService: LocationService, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, ) {} async createLocation( diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts index 17dfb58c54..8634fbf86d 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts @@ -18,14 +18,14 @@ import { NotAllowedError } from '@backstage/errors'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { RefreshOptions, RefreshService } from './types'; export class AuthorizedRefreshService implements RefreshService { constructor( private readonly service: RefreshService, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, ) {} async refresh(options: RefreshOptions) { From 8f4792a1962fd60e981aef730d0335a1801806d8 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 31 Mar 2022 10:09:29 +0200 Subject: [PATCH 057/144] Mark PermissionAuthorizer as deprecated Signed-off-by: Vincenzo Scamporlino --- plugins/permission-common/api-report.md | 2 +- plugins/permission-common/src/types/permission.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index e952425bbf..e33449c3d6 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -139,7 +139,7 @@ export type PermissionAttributes = { action?: 'create' | 'read' | 'update' | 'delete'; }; -// @public +// @public @deprecated export interface PermissionAuthorizer { // (undocumented) authorize( diff --git a/plugins/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts index 34e7884793..ab9f888605 100644 --- a/plugins/permission-common/src/types/permission.ts +++ b/plugins/permission-common/src/types/permission.ts @@ -91,6 +91,7 @@ export type ResourcePermission = /** * A client interacting with the permission backend can implement this authorizer interface. * @public + * @deprecated Use PermissionEvaluator instead */ export interface PermissionAuthorizer { authorize( From 8b27170d3024d2b1a3f446e0cbc6a1a4abcdf2c9 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 31 Mar 2022 10:10:08 +0200 Subject: [PATCH 058/144] search-backend: Use PermissionEvaluator instead of PermissionAuthorizer Signed-off-by: Vincenzo Scamporlino --- .changeset/eight-cobras-think.md | 2 +- .../src/service/AuthorizedSearchEngine.test.ts | 7 +++---- plugins/search-backend/src/service/router.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.changeset/eight-cobras-think.md b/.changeset/eight-cobras-think.md index 854e4fcec8..3a9ec54a90 100644 --- a/.changeset/eight-cobras-think.md +++ b/.changeset/eight-cobras-think.md @@ -2,4 +2,4 @@ '@backstage/plugin-search-backend': patch --- -Fix typing when invoking `PermissionClient#authorize` +Use `PermissionEvaluator` instead of `PermissionAuthorizer`. diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts index 8a7662c1f4..5f666b3a26 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts @@ -19,7 +19,6 @@ import { EvaluatePermissionResponse, AuthorizeResult, createPermission, - PermissionAuthorizer, PolicyDecision, PermissionEvaluator, } from '@backstage/plugin-permission-common'; @@ -77,7 +76,7 @@ describe('AuthorizedSearchEngine', () => { PermissionEvaluator['query'] > = jest.fn(); - const permissionAuthorizer: PermissionEvaluator = { + const permissionEvaluator: PermissionEvaluator = { authorize: mockedAuthorize, query: mockedPermissionQuery, }; @@ -116,13 +115,13 @@ describe('AuthorizedSearchEngine', () => { const authorizedSearchEngine = new AuthorizedSearchEngine( searchEngine, defaultTypes, - permissionAuthorizer, + permissionEvaluator, new ConfigReader({}), ); const options = { token: 'token' }; - const allowAll: PermissionAuthorizer['authorize'] & + const allowAll: PermissionEvaluator['authorize'] & PermissionEvaluator['query'] = async queries => { return queries.map(() => ({ result: AuthorizeResult.ALLOW, diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 85773fb3b8..673556f450 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -26,7 +26,7 @@ import request from 'supertest'; import { createRouter } from './router'; -const mockPermissionAuthorizer: PermissionEvaluator = { +const mockPermissionEvaluator: PermissionEvaluator = { authorize: () => { throw new Error('Not implemented'); }, @@ -62,7 +62,7 @@ describe('createRouter', () => { 'second-type': {}, }, config: new ConfigReader({ permissions: { enabled: false } }), - permissions: mockPermissionAuthorizer, + permissions: mockPermissionEvaluator, logger, }); app = express().use(router); @@ -167,7 +167,7 @@ describe('createRouter', () => { engine: indexBuilder.getSearchEngine(), types: indexBuilder.getDocumentTypes(), config: new ConfigReader({ permissions: { enabled: false } }), - permissions: mockPermissionAuthorizer, + permissions: mockPermissionEvaluator, logger, }); app = express().use(router); From 173aadff5b4be95d84ac75222c4123947031674e Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 5 Apr 2022 12:47:28 +0200 Subject: [PATCH 059/144] Avoid PermissionEvaluator breaking changes Signed-off-by: Vincenzo Scamporlino --- packages/backend/src/types.ts | 7 ++-- .../src/service/CatalogBuilder.ts | 25 ++++++++++--- plugins/jenkins-backend/src/service/router.ts | 24 ++++++++++--- .../permission-common/src/permissions/util.ts | 36 ++++++++++++++++++- plugins/search-backend/src/service/router.ts | 25 +++++++++++-- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d1f62d833b..3e47b1a523 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -24,7 +24,10 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -34,6 +37,6 @@ export type PluginEnvironment = { reader: UrlReader; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; - permissions: PermissionEvaluator; + permissions: PermissionEvaluator | PermissionAuthorizer; scheduler: PluginTaskScheduler; }; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index e9135393ba..90ffc467c2 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -79,7 +79,11 @@ import { CatalogPermissionRule, permissionRules as catalogPermissionRules, } from '../permissions/rules'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { createConditionTransformer, createPermissionIntegrationRouter, @@ -95,7 +99,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionEvaluator; + permissions: PermissionEvaluator | PermissionAuthorizer; }; /** @@ -376,9 +380,20 @@ export class CatalogBuilder { policy, }); const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient); + + let permissionEvaluator: PermissionEvaluator; + if (!permissions.hasOwnProperty('query')) { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in catalog.ts', + ); + permissionEvaluator = toPermissionEvaluator(permissions); + } else { + permissionEvaluator = permissions as PermissionEvaluator; + } + const entitiesCatalog = new AuthorizedEntitiesCatalog( unauthorizedEntitiesCatalog, - permissions, + permissionEvaluator, createConditionTransformer(this.permissionRules), ); const permissionIntegrationRouter = createPermissionIntegrationRouter({ @@ -428,11 +443,11 @@ export class CatalogBuilder { this.locationAnalyzer ?? new RepoLocationAnalyzer(logger, integrations); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator), - permissions, + permissionEvaluator, ); const refreshService = new AuthorizedRefreshService( new DefaultRefreshService({ database: processingDatabase }), - permissions, + permissionEvaluator, ); const router = await createRouter({ entitiesCatalog, diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 1ad8f22b25..8787be70df 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -20,22 +20,38 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; import { JenkinsApiImpl } from './jenkinsApi'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { stringifyEntityRef } from '@backstage/catalog-model'; export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; - permissions?: PermissionEvaluator; + permissions?: PermissionEvaluator | PermissionAuthorizer; } export async function createRouter( options: RouterOptions, ): Promise { - const { jenkinsInfoProvider } = options; + const { jenkinsInfoProvider, permissions, logger } = options; - const jenkinsApi = new JenkinsApiImpl(options.permissions); + let permissionEvaluator: PermissionEvaluator | undefined; + if (permissions?.hasOwnProperty('query')) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in your jenkins.ts', + ); + permissionEvaluator = permissions + ? toPermissionEvaluator(permissions) + : undefined; + } + + const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); const router = Router(); router.use(express.json()); diff --git a/plugins/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts index 1a5ed434a7..b40b582149 100644 --- a/plugins/permission-common/src/permissions/util.ts +++ b/plugins/permission-common/src/permissions/util.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -import { Permission, ResourcePermission } from '../types'; +import { + AuthorizePermissionRequest, + AuthorizePermissionResponse, + DefinitivePolicyDecision, + EvaluatorRequestOptions, + Permission, + PermissionAuthorizer, + PermissionEvaluator, + QueryPermissionRequest, + QueryPermissionResponse, + ResourcePermission, +} from '../types'; /** * Check if the two parameters are equivalent permissions. @@ -75,3 +86,26 @@ export function isUpdatePermission(permission: Permission) { export function isDeletePermission(permission: Permission) { return permission.attributes.action === 'delete'; } + +export function toPermissionEvaluator( + permissionAuthorizer: PermissionAuthorizer, +): PermissionEvaluator { + return { + authorize: async ( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise => { + const response = await permissionAuthorizer.authorize(requests, options); + + return response as DefinitivePolicyDecision[]; + }, + query( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + // @ts-expect-error + const parsedRequests: AuthorizePermissionRequest[] = requests; + return permissionAuthorizer.authorize(parsedRequests, options); + }, + }; +} diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 6867c382df..bd956a9ff8 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -23,7 +23,11 @@ import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, IndexableResultSet, @@ -50,7 +54,7 @@ const jsonObjectSchema: z.ZodSchema = z.lazy(() => { export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionEvaluator; + permissions: PermissionEvaluator | PermissionAuthorizer; config: Config; logger: Logger; }; @@ -71,8 +75,23 @@ export async function createRouter( pageCursor: z.string().optional(), }); + let permissionEvaluator: PermissionEvaluator; + if (!permissions.hasOwnProperty('query')) { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in search.ts', + ); + permissionEvaluator = toPermissionEvaluator(permissions); + } else { + permissionEvaluator = permissions as PermissionEvaluator; + } + const engine = config.getOptionalBoolean('permission.enabled') - ? new AuthorizedSearchEngine(inputEngine, types, permissions, config) + ? new AuthorizedSearchEngine( + inputEngine, + types, + permissionEvaluator, + config, + ) : inputEngine; const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ From b4af8664b5ab1943c913a27cfdddeccc08edfaed Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 5 Apr 2022 16:05:25 +0200 Subject: [PATCH 060/144] api report and minor fixes Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-backend/api-report.md | 3 ++- plugins/catalog-backend/src/service/CatalogBuilder.ts | 8 ++++---- plugins/jenkins-backend/api-report.md | 3 ++- plugins/jenkins-backend/src/service/router.ts | 4 ++-- plugins/permission-common/api-report.md | 5 +++++ plugins/permission-common/src/permissions/util.ts | 9 +++++++-- plugins/search-backend/api-report.md | 3 ++- plugins/search-backend/src/service/router.ts | 8 ++++---- 8 files changed, 28 insertions(+), 15 deletions(-) diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 7efdfec424..a770d6289e 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -19,6 +19,7 @@ import { JsonValue } from '@backstage/types'; import { LocationEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; @@ -181,7 +182,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionEvaluator; + permissions: PermissionEvaluator | PermissionAuthorizer; }; // @alpha diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 90ffc467c2..f5f00cd0fc 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -382,13 +382,13 @@ export class CatalogBuilder { const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient); let permissionEvaluator: PermissionEvaluator; - if (!permissions.hasOwnProperty('query')) { + if ('query' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { logger.warn( - 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in catalog.ts', + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', ); permissionEvaluator = toPermissionEvaluator(permissions); - } else { - permissionEvaluator = permissions as PermissionEvaluator; } const entitiesCatalog = new AuthorizedEntitiesCatalog( diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 315187e669..029f2c7af3 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -8,6 +8,7 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -98,6 +99,6 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - permissions?: PermissionEvaluator; + permissions?: PermissionEvaluator | PermissionAuthorizer; } ``` diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 8787be70df..f6bd21c633 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -40,11 +40,11 @@ export async function createRouter( const { jenkinsInfoProvider, permissions, logger } = options; let permissionEvaluator: PermissionEvaluator | undefined; - if (permissions?.hasOwnProperty('query')) { + if (permissions && 'query' in permissions) { permissionEvaluator = permissions as PermissionEvaluator; } else { logger.warn( - 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in your jenkins.ts', + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', ); permissionEvaluator = permissions ? toPermissionEvaluator(permissions) diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index e33449c3d6..41abb62cf6 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -225,4 +225,9 @@ export type ResourcePermission = resourceType: TResourceType; } >; + +// @public +export function toPermissionEvaluator( + permissionAuthorizer: PermissionAuthorizer, +): PermissionEvaluator; ``` diff --git a/plugins/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts index b40b582149..6e0aca457b 100644 --- a/plugins/permission-common/src/permissions/util.ts +++ b/plugins/permission-common/src/permissions/util.ts @@ -87,6 +87,11 @@ export function isDeletePermission(permission: Permission) { return permission.attributes.action === 'delete'; } +/** + * Convert {@link PermissionAuthorizer} to {@link PermissionEvaluator}. + * + * @public + */ export function toPermissionEvaluator( permissionAuthorizer: PermissionAuthorizer, ): PermissionEvaluator { @@ -103,8 +108,8 @@ export function toPermissionEvaluator( requests: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { - // @ts-expect-error - const parsedRequests: AuthorizePermissionRequest[] = requests; + const parsedRequests = + requests as unknown as AuthorizePermissionRequest[]; return permissionAuthorizer.authorize(parsedRequests, options); }, }; diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index dc14d2f15b..c5c8eb7a13 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -7,6 +7,7 @@ import { Config } from '@backstage/config'; import { DocumentTypeInfo } from '@backstage/plugin-search-common'; import express from 'express'; import { Logger } from 'winston'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; @@ -21,7 +22,7 @@ export function createRouter(options: RouterOptions): Promise; export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionEvaluator; + permissions: PermissionEvaluator | PermissionAuthorizer; config: Config; logger: Logger; }; diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index bd956a9ff8..11f0eb22e6 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -76,13 +76,13 @@ export async function createRouter( }); let permissionEvaluator: PermissionEvaluator; - if (!permissions.hasOwnProperty('query')) { + if ('query' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { logger.warn( - 'PermissionAuthorizer is deprecated. Please use PermissionEvaluator instead of PermissionAuthorizer in search.ts', + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', ); permissionEvaluator = toPermissionEvaluator(permissions); - } else { - permissionEvaluator = permissions as PermissionEvaluator; } const engine = config.getOptionalBoolean('permission.enabled') From 6ca16bdf2db7a588a9874c6945e38631609e8a1d Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 6 Apr 2022 14:44:58 +0200 Subject: [PATCH 061/144] Apply suggestions from code review Co-authored-by: Joe Porpeglia Signed-off-by: Vincenzo Scamporlino --- plugins/permission-common/src/types/permission.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts index ab9f888605..eb1c0eb50d 100644 --- a/plugins/permission-common/src/types/permission.ts +++ b/plugins/permission-common/src/types/permission.ts @@ -91,7 +91,7 @@ export type ResourcePermission = /** * A client interacting with the permission backend can implement this authorizer interface. * @public - * @deprecated Use PermissionEvaluator instead + * @deprecated Use {@link @backstage/plugin-permission-common#PermissionEvaluator} instead */ export interface PermissionAuthorizer { authorize( From 3f41ada39d2b693070b034f44bb16aa56120e6f7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 6 Apr 2022 14:47:06 +0200 Subject: [PATCH 062/144] Update create-app changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/rare-parents-pretend.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.changeset/rare-parents-pretend.md b/.changeset/rare-parents-pretend.md index cb97c6999e..4c8952ad14 100644 --- a/.changeset/rare-parents-pretend.md +++ b/.changeset/rare-parents-pretend.md @@ -2,20 +2,23 @@ '@backstage/create-app': patch --- -Use `PermissionEvaluator` instead of `PermissionAuthorizer`. +Accept `PermissionEvaluator` together with the deprecated `PermissionAuthorizer`. Apply the following to `packages/backend/src/types.ts`: ```diff -- import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; -+ import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +- import { ServerPermissionClient } from '@backstage/plugin-permission-node'; ++ import { ++ PermissionAuthorizer, ++ PermissionEvaluator, ++ } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { ... discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; -- permissions: PermissionAuthorizer; -+ permissions: PermissionEvaluator; +- permissions: ServerPermissionClient; ++ permissions: PermissionEvaluator | PermissionAuthorizer; }; ``` From 63902fcc1787d7824ef6b5d735611e14f8a53987 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 7 Apr 2022 20:25:53 +0200 Subject: [PATCH 063/144] PermissionEvaluator: rename query to authorizeConditional Signed-off-by: Vincenzo Scamporlino --- .changeset/cuddly-turtles-sleep.md | 2 +- .changeset/eight-cobras-think.md | 2 +- .changeset/few-seas-fail.md | 2 +- .changeset/four-dolphins-report.md | 2 +- .changeset/fresh-boxes-pull.md | 2 +- .changeset/rare-parents-pretend.md | 13 ++-- .../service/AuthorizedEntitiesCatalog.test.ts | 22 +++---- .../src/service/AuthorizedEntitiesCatalog.ts | 6 +- .../service/AuthorizedLocationService.test.ts | 2 +- .../src/service/jenkinsApi.test.ts | 2 +- plugins/permission-common/api-report.md | 4 +- .../src/PermissionClient.test.ts | 62 +++++++++++-------- .../permission-common/src/PermissionClient.ts | 4 +- .../permission-common/src/permissions/util.ts | 2 +- plugins/permission-common/src/types/api.ts | 6 +- plugins/permission-node/api-report.md | 10 +-- .../src/ServerPermissionClient.test.ts | 24 ++++--- .../src/ServerPermissionClient.ts | 5 +- .../service/AuthorizedSearchEngine.test.ts | 6 +- .../src/service/AuthorizedSearchEngine.ts | 2 +- .../search-backend/src/service/router.test.ts | 2 +- 21 files changed, 98 insertions(+), 84 deletions(-) diff --git a/.changeset/cuddly-turtles-sleep.md b/.changeset/cuddly-turtles-sleep.md index bb1a925506..a63cd44232 100644 --- a/.changeset/cuddly-turtles-sleep.md +++ b/.changeset/cuddly-turtles-sleep.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': patch --- -Use new `PermissionEvaluator#query` method when retrieving permission conditions. +Use new `PermissionEvaluator#authorizeConditional` method when retrieving permission conditions. diff --git a/.changeset/eight-cobras-think.md b/.changeset/eight-cobras-think.md index 3a9ec54a90..af1c7cd113 100644 --- a/.changeset/eight-cobras-think.md +++ b/.changeset/eight-cobras-think.md @@ -2,4 +2,4 @@ '@backstage/plugin-search-backend': patch --- -Use `PermissionEvaluator` instead of `PermissionAuthorizer`. +Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated. diff --git a/.changeset/few-seas-fail.md b/.changeset/few-seas-fail.md index 8ace0f3515..c5d9db4d47 100644 --- a/.changeset/few-seas-fail.md +++ b/.changeset/few-seas-fail.md @@ -5,4 +5,4 @@ Added `PermissionEvaluator`, which will replace the existing `PermissionAuthorizer` interface. This new interface provides stronger type safety and validation by splitting `PermissionAuthorizer.authorize()` into two methods: - `authorize()`: Used when the caller requires a definitive decision. -- `query()`: Used when the caller can optimize the evaluation of any conditional decisions. For example, a plugin backend may want to use conditions in a database query instead of evaluating each resource in memory. +- `authorizeConditional()`: Used when the caller can optimize the evaluation of any conditional decisions. For example, a plugin backend may want to use conditions in a database query instead of evaluating each resource in memory. diff --git a/.changeset/four-dolphins-report.md b/.changeset/four-dolphins-report.md index ff0178c9f6..fa3f06c532 100644 --- a/.changeset/four-dolphins-report.md +++ b/.changeset/four-dolphins-report.md @@ -2,4 +2,4 @@ '@backstage/plugin-permission-node': minor --- -**BREAKING:** `ServerPermissionClient` now implements `PermissionEvaluator`, which moves out the capabilities for evaluating conditional decisions from `authorize()` to `query()` method. +**BREAKING:** `ServerPermissionClient` now implements `PermissionEvaluator`, which moves out the capabilities for evaluating conditional decisions from `authorize()` to `authorizeConditional()` method. diff --git a/.changeset/fresh-boxes-pull.md b/.changeset/fresh-boxes-pull.md index 87bff5c3a7..e73d7e25c2 100644 --- a/.changeset/fresh-boxes-pull.md +++ b/.changeset/fresh-boxes-pull.md @@ -2,4 +2,4 @@ '@backstage/plugin-jenkins-backend': patch --- -Use PermissionEvaluator instead of PermissionAuthorizer +Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated. diff --git a/.changeset/rare-parents-pretend.md b/.changeset/rare-parents-pretend.md index 4c8952ad14..f211a85efe 100644 --- a/.changeset/rare-parents-pretend.md +++ b/.changeset/rare-parents-pretend.md @@ -2,23 +2,20 @@ '@backstage/create-app': patch --- -Accept `PermissionEvaluator` together with the deprecated `PermissionAuthorizer`. +Accept `PermissionEvaluator` instead of the deprecated `PermissionAuthorizer`. Apply the following to `packages/backend/src/types.ts`: ```diff -- import { ServerPermissionClient } from '@backstage/plugin-permission-node'; -+ import { -+ PermissionAuthorizer, -+ PermissionEvaluator, -+ } from '@backstage/plugin-permission-common'; +- import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; ++ import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { ... discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; -- permissions: ServerPermissionClient; -+ permissions: PermissionEvaluator | PermissionAuthorizer; +- permissions: PermissionAuthorizer; ++ permissions: PermissionEvaluator; }; ``` diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index dbdcde11c4..51edd903e6 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -30,7 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => { }; const fakePermissionApi = { authorize: jest.fn(), - query: jest.fn(), + authorizeConditional: jest.fn(), }; const createCatalog = (...rules: CatalogPermissionRule[]) => @@ -46,7 +46,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('entities', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -62,7 +62,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -79,7 +79,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); @@ -99,7 +99,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -114,7 +114,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('throws error on CONDITIONAL authorization that evaluates to 0 entities', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -133,7 +133,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on CONDITIONAL authorization that evaluates to nonzero entities', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -159,7 +159,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -253,7 +253,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('facets', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -269,7 +269,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -287,7 +287,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.query.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index fcd05f95d8..0e861a39e1 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -45,7 +45,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async entities(request?: EntitiesRequest): Promise { const authorizeDecision = ( - await this.permissionApi.query( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) @@ -78,7 +78,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { options?: { authorizationToken?: string }, ): Promise { const authorizeResponse = ( - await this.permissionApi.query( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityDeletePermission }], { token: options?.authorizationToken }, ) @@ -155,7 +155,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async facets(request: EntityFacetsRequest): Promise { const authorizeDecision = ( - await this.permissionApi.query( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index 151ba1c3e8..dc8719bb0e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -27,7 +27,7 @@ describe('AuthorizedLocationService', () => { }; const fakePermissionApi = { authorize: jest.fn(), - query: jest.fn(), + authorizeConditional: jest.fn(), }; const mockAllow = () => { diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 7c4214f714..29da414470 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -49,7 +49,7 @@ const fakePermissionApi = { result: AuthorizeResult.ALLOW, }, ]), - query: jest.fn(), + authorizeConditional: jest.fn(), }; describe('JenkinsApi', () => { diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index 41abb62cf6..c0c351d6cd 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -163,7 +163,7 @@ export class PermissionClient implements PermissionEvaluator { requests: AuthorizePermissionRequest[], options?: EvaluatorRequestOptions, ): Promise; - query( + authorizeConditional( queries: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise; @@ -192,7 +192,7 @@ export interface PermissionEvaluator { requests: AuthorizePermissionRequest[], options?: EvaluatorRequestOptions, ): Promise; - query( + authorizeConditional( requests: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise; diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index 8da30185b7..503ce2768d 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -53,7 +53,7 @@ describe('PermissionClient', () => { afterEach(() => server.resetHandlers()); describe('authorize', () => { - const mockAuthorizeQuery = { + const mockAuthorizeConditional = { permission: mockPermission, resourceRef: 'foo:bar', }; @@ -78,12 +78,12 @@ describe('PermissionClient', () => { }); it('should fetch entities from correct endpoint', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); expect(mockAuthorizeHandler).toHaveBeenCalled(); }); it('should include a request body', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); const request = mockAuthorizeHandler.mock.calls[0][0]; @@ -98,21 +98,21 @@ describe('PermissionClient', () => { }); it('should return the response from the fetch request', async () => { - const response = await client.authorize([mockAuthorizeQuery]); + const response = await client.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); }); it('should not include authorization headers if no token is supplied', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); const request = mockAuthorizeHandler.mock.calls[0][0]; expect(request.headers.has('authorization')).toEqual(false); }); it('should include correctly-constructed authorization header if token is supplied', async () => { - await client.authorize([mockAuthorizeQuery], { token }); + await client.authorize([mockAuthorizeConditional], { token }); const request = mockAuthorizeHandler.mock.calls[0][0]; expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); @@ -125,7 +125,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), + client.authorize([mockAuthorizeConditional], { token }), ).rejects.toThrowError(/request failed with 401/i); }); @@ -140,7 +140,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), + client.authorize([mockAuthorizeConditional], { token }), ).rejects.toThrowError(/items in response do not match request/i); }); @@ -158,7 +158,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), + client.authorize([mockAuthorizeConditional], { token }), ).rejects.toThrowError(/invalid input/i); }); @@ -179,7 +179,7 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({ permission: { enabled: false } }), }); - const response = await disabled.authorize([mockAuthorizeQuery]); + const response = await disabled.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); @@ -203,7 +203,7 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({}), }); - const response = await disabled.authorize([mockAuthorizeQuery]); + const response = await disabled.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); @@ -211,8 +211,8 @@ describe('PermissionClient', () => { }); }); - describe('query', () => { - const mockResourceAuthorizeQuery = { + describe('authorizeConditional', () => { + const mockResourceAuthorizeConditional = { permission: mockPermission, }; @@ -247,12 +247,12 @@ describe('PermissionClient', () => { }); it('should fetch entities from correct endpoint', async () => { - await client.query([mockResourceAuthorizeQuery]); + await client.authorizeConditional([mockResourceAuthorizeConditional]); expect(mockPolicyDecisionHandler).toHaveBeenCalled(); }); it('should include a request body', async () => { - await client.query([mockResourceAuthorizeQuery]); + await client.authorizeConditional([mockResourceAuthorizeConditional]); const request = mockPolicyDecisionHandler.mock.calls[0][0]; @@ -266,7 +266,9 @@ describe('PermissionClient', () => { }); it('should return the response from the fetch request', async () => { - const response = await client.query([mockResourceAuthorizeQuery]); + const response = await client.authorizeConditional([ + mockResourceAuthorizeConditional, + ]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.CONDITIONAL, @@ -280,14 +282,14 @@ describe('PermissionClient', () => { }); it('should not include authorization headers if no token is supplied', async () => { - await client.query([mockResourceAuthorizeQuery]); + await client.authorizeConditional([mockResourceAuthorizeConditional]); const request = mockPolicyDecisionHandler.mock.calls[0][0]; expect(request.headers.has('authorization')).toEqual(false); }); it('should include correctly-constructed authorization header if token is supplied', async () => { - await client.query([mockResourceAuthorizeQuery], { + await client.authorizeConditional([mockResourceAuthorizeConditional], { token, }); @@ -302,7 +304,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.query([mockResourceAuthorizeQuery], { + client.authorizeConditional([mockResourceAuthorizeConditional], { token, }), ).rejects.toThrowError(/request failed with 401/i); @@ -319,7 +321,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.query([mockResourceAuthorizeQuery], { + client.authorizeConditional([mockResourceAuthorizeConditional], { token, }), ).rejects.toThrowError(/items in response do not match request/i); @@ -339,7 +341,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.query([mockResourceAuthorizeQuery], { + client.authorizeConditional([mockResourceAuthorizeConditional], { token, }), ).rejects.toThrowError(/invalid input/i); @@ -362,9 +364,12 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({ permission: { enabled: false } }), }); - const response = await disabled.query([mockResourceAuthorizeQuery], { - token, - }); + const response = await disabled.authorizeConditional( + [mockResourceAuthorizeConditional], + { + token, + }, + ); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); @@ -388,9 +393,12 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({}), }); - const response = await disabled.query([mockResourceAuthorizeQuery], { - token, - }); + const response = await disabled.authorizeConditional( + [mockResourceAuthorizeConditional], + { + token, + }, + ); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 07d60a8e65..223209c69f 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -131,9 +131,9 @@ export class PermissionClient implements PermissionEvaluator { } /** - * {@inheritdoc PermissionEvaluator.query} + * {@inheritdoc PermissionEvaluator.authorizeConditional} */ - async query( + async authorizeConditional( queries: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { diff --git a/plugins/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts index 6e0aca457b..5cc9d9c08a 100644 --- a/plugins/permission-common/src/permissions/util.ts +++ b/plugins/permission-common/src/permissions/util.ts @@ -104,7 +104,7 @@ export function toPermissionEvaluator( return response as DefinitivePolicyDecision[]; }, - query( + authorizeConditional( requests: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 0e4cf782e1..f29a639d18 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -203,7 +203,7 @@ export type AuthorizePermissionRequest = export type AuthorizePermissionResponse = DefinitivePolicyDecision; /** - * Request object for {@link PermissionEvaluator.query}. + * Request object for {@link PermissionEvaluator.authorizeConditional}. * @public */ export type QueryPermissionRequest = { @@ -212,7 +212,7 @@ export type QueryPermissionRequest = { }; /** - * Response object for {@link PermissionEvaluator.query}. + * Response object for {@link PermissionEvaluator.authorizeConditional}. * @public */ export type QueryPermissionResponse = PolicyDecision; @@ -239,7 +239,7 @@ export interface PermissionEvaluator { * backend may want to use {@link PermissionCriteria | conditions} in a database query instead of * evaluating each resource in memory. */ - query( + authorizeConditional( requests: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise; diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 3cf6b70d08..b2e4ff45b8 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -186,6 +186,11 @@ export class ServerPermissionClient implements PermissionEvaluator { options?: EvaluatorRequestOptions, ): Promise; // (undocumented) + authorizeConditional( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + // (undocumented) static fromConfig( config: Config, options: { @@ -193,10 +198,5 @@ export class ServerPermissionClient implements PermissionEvaluator { tokenManager: TokenManager; }, ): ServerPermissionClient; - // (undocumented) - query( - queries: QueryPermissionRequest[], - options?: EvaluatorRequestOptions, - ): Promise; } ``` diff --git a/plugins/permission-node/src/ServerPermissionClient.test.ts b/plugins/permission-node/src/ServerPermissionClient.test.ts index 2e3e5d988f..e531bd3556 100644 --- a/plugins/permission-node/src/ServerPermissionClient.test.ts +++ b/plugins/permission-node/src/ServerPermissionClient.test.ts @@ -137,7 +137,7 @@ describe('ServerPermissionClient', () => { }); }); - describe('query', () => { + describe('authorizeConditional', () => { let mockAuthorizeHandler: jest.Mock; beforeEach(() => { @@ -162,7 +162,9 @@ describe('ServerPermissionClient', () => { tokenManager: ServerTokenManager.noop(), }); - await client.query([{ permission: testResourcePermission }]); + await client.authorizeConditional([ + { permission: testResourcePermission }, + ]); expect(mockAuthorizeHandler).not.toHaveBeenCalled(); }); @@ -174,9 +176,12 @@ describe('ServerPermissionClient', () => { tokenManager, }); - await client.query([{ permission: testResourcePermission }], { - token: (await tokenManager.getToken()).token, - }); + await client.authorizeConditional( + [{ permission: testResourcePermission }], + { + token: (await tokenManager.getToken()).token, + }, + ); expect(mockAuthorizeHandler).not.toHaveBeenCalled(); }); @@ -188,9 +193,12 @@ describe('ServerPermissionClient', () => { tokenManager, }); - await client.query([{ permission: testResourcePermission }], { - token: 'a-user-token', - }); + await client.authorizeConditional( + [{ permission: testResourcePermission }], + { + token: 'a-user-token', + }, + ); expect(mockAuthorizeHandler).toHaveBeenCalled(); }); diff --git a/plugins/permission-node/src/ServerPermissionClient.ts b/plugins/permission-node/src/ServerPermissionClient.ts index 2ea11e9e95..664e5a8309 100644 --- a/plugins/permission-node/src/ServerPermissionClient.ts +++ b/plugins/permission-node/src/ServerPermissionClient.ts @@ -78,12 +78,13 @@ export class ServerPermissionClient implements PermissionEvaluator { this.tokenManager = options.tokenManager; this.permissionEnabled = options.permissionEnabled; } - async query( + + async authorizeConditional( queries: QueryPermissionRequest[], options?: EvaluatorRequestOptions, ): Promise { return (await this.isEnabled(options?.token)) - ? this.permissionClient.query(queries, options) + ? this.permissionClient.authorizeConditional(queries, options) : queries.map(_ => ({ result: AuthorizeResult.ALLOW })); } diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts index 5f666b3a26..256608fd82 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts @@ -73,12 +73,12 @@ describe('AuthorizedSearchEngine', () => { const mockedAuthorize: jest.MockedFunction = jest.fn(); const mockedPermissionQuery: jest.MockedFunction< - PermissionEvaluator['query'] + PermissionEvaluator['authorizeConditional'] > = jest.fn(); const permissionEvaluator: PermissionEvaluator = { authorize: mockedAuthorize, - query: mockedPermissionQuery, + authorizeConditional: mockedPermissionQuery, }; const defaultTypes: Record = { @@ -122,7 +122,7 @@ describe('AuthorizedSearchEngine', () => { const options = { token: 'token' }; const allowAll: PermissionEvaluator['authorize'] & - PermissionEvaluator['query'] = async queries => { + PermissionEvaluator['authorizeConditional'] = async queries => { return queries.map(() => ({ result: AuthorizeResult.ALLOW, })); diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts index 2397734089..a74cdf8eef 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts @@ -93,7 +93,7 @@ export class AuthorizedSearchEngine implements SearchEngine { const conditionFetcher = new DataLoader( (requests: readonly QueryPermissionRequest[]) => - this.permissions.query(requests.slice(), options), + this.permissions.authorizeConditional(requests.slice(), options), { cacheKeyFn: ({ permission: { name } }) => name, }, diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 673556f450..26d901e519 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -30,7 +30,7 @@ const mockPermissionEvaluator: PermissionEvaluator = { authorize: () => { throw new Error('Not implemented'); }, - query: () => { + authorizeConditional: () => { throw new Error('Not implemented'); }, }; From 9ef575983864eac3346ecbe6d7f8ca9ae475501a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Apr 2022 10:31:13 +0000 Subject: [PATCH 064/144] build(deps): bump @types/prop-types from 15.7.4 to 15.7.5 Bumps [@types/prop-types](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/prop-types) from 15.7.4 to 15.7.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/prop-types) --- updated-dependencies: - dependency-name: "@types/prop-types" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21e7f59caa..f62190d798 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6545,9 +6545,9 @@ integrity sha512-QzSuZMBuG5u8HqYz01qtMdg/Jfctlnvj1z/lYnIDXs/golxw0fxtRAHd9KrzjR7Yxz1qVeI00o0kiO3PmVdJ9w== "@types/prop-types@*", "@types/prop-types@^15.7.3": - version "15.7.4" - resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" - integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + version "15.7.5" + resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== "@types/puppeteer@^5.4.4": version "5.4.5" @@ -6566,12 +6566,12 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.14" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" - integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ== +"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^17": + version "17.0.15" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156" + integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw== dependencies: - "@types/react" "*" + "@types/react" "^17" "@types/react-helmet@^6.1.0": version "6.1.5" @@ -6639,7 +6639,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17.0.39": +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": version "17.0.44" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== From 19f6c6c32acf38e48e12cd22310d381f25fca218 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Apr 2022 18:05:54 +0200 Subject: [PATCH 065/144] config-loader,backend-common: do not fail backend startup on config schema errors Signed-off-by: Patrik Oldsberg --- .changeset/heavy-stingrays-talk.md | 5 ++++ .changeset/thirty-icons-buy.md | 5 ++++ packages/backend-common/src/config.ts | 2 +- packages/config-loader/api-report.md | 1 + .../config-loader/src/lib/schema/load.test.ts | 7 +++++ packages/config-loader/src/lib/schema/load.ts | 26 ++++++++++++------- .../config-loader/src/lib/schema/types.ts | 5 ++++ 7 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 .changeset/heavy-stingrays-talk.md create mode 100644 .changeset/thirty-icons-buy.md diff --git a/.changeset/heavy-stingrays-talk.md b/.changeset/heavy-stingrays-talk.md new file mode 100644 index 0000000000..d17a85090b --- /dev/null +++ b/.changeset/heavy-stingrays-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +The backend will no longer fail to start up when configured secrets do not match the configuration schema. diff --git a/.changeset/thirty-icons-buy.md b/.changeset/thirty-icons-buy.md new file mode 100644 index 0000000000..dd5a9eec19 --- /dev/null +++ b/.changeset/thirty-icons-buy.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Added `ignoreSchemaErrors` to `schema.process`. diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 9081b99284..94b362d99d 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -41,7 +41,7 @@ const updateRedactionList = ( ) => { const secretAppConfigs = schema.process(configs, { visibility: ['secret'], - withDeprecatedKeys: true, + ignoreSchemaErrors: true, }); const secretConfig = ConfigReader.fromConfigs(secretAppConfigs); const values = new Set(); diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 06868e0f47..225b3ddd22 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -19,6 +19,7 @@ export type ConfigSchema = { // @public export type ConfigSchemaProcessingOptions = { visibility?: ConfigVisibility[]; + ignoreSchemaErrors?: boolean; valueTransform?: TransformFunc; withFilteredKeys?: boolean; withDeprecatedKeys?: boolean; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index 41971f7d01..475339be4e 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -275,5 +275,12 @@ describe('loadConfigSchema', () => { ).toThrow( "Config must have required property 'x a' { missingProperty=x a } at /other", ); + + expect( + schema.process([{ data: { other: {} }, context: 'test' }], { + visibility: ['frontend'], + ignoreSchemaErrors: true, + }), + ).toEqual([{ data: {}, context: 'test' }]); }); }); diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts index 959b438371..0309f64ea0 100644 --- a/packages/config-loader/src/lib/schema/load.ts +++ b/packages/config-loader/src/lib/schema/load.ts @@ -82,18 +82,26 @@ export async function loadConfigSchema( return { process( configs: AppConfig[], - { visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {}, + { + visibility, + valueTransform, + withFilteredKeys, + withDeprecatedKeys, + ignoreSchemaErrors, + } = {}, ): AppConfig[] { const result = validate(configs); - const visibleErrors = filterErrorsByVisibility( - result.errors, - visibility, - result.visibilityByDataPath, - result.visibilityBySchemaPath, - ); - if (visibleErrors.length > 0) { - throw errorsToError(visibleErrors); + if (!ignoreSchemaErrors) { + const visibleErrors = filterErrorsByVisibility( + result.errors, + visibility, + result.visibilityByDataPath, + result.visibilityBySchemaPath, + ); + if (visibleErrors.length > 0) { + throw errorsToError(visibleErrors); + } } let processedConfigs = configs; diff --git a/packages/config-loader/src/lib/schema/types.ts b/packages/config-loader/src/lib/schema/types.ts index cc62192e30..95576cf5aa 100644 --- a/packages/config-loader/src/lib/schema/types.ts +++ b/packages/config-loader/src/lib/schema/types.ts @@ -117,6 +117,11 @@ export type ConfigSchemaProcessingOptions = { */ visibility?: ConfigVisibility[]; + /** + * When set to `true`, any schema errors in the provided configuration will be ignored. + */ + ignoreSchemaErrors?: boolean; + /** * A transform function that can be used to transform primitive configuration values * during validation. The value returned from the transform function will be used From 3f801d1279eeb5cea50dc874be9f77ba8eb21c85 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Thu, 7 Apr 2022 16:21:25 +0200 Subject: [PATCH 066/144] feat: handle potential future case of no awsS3 integration Relates-to: PR #10480 Signed-off-by: Patrick Jungermann --- .../src/providers/AwsS3EntityProvider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts index f068e40130..2e64699d8b 100644 --- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts @@ -63,6 +63,9 @@ export class AwsS3EntityProvider implements EntityProvider { // In this case, we still want the first one though, but have no means to select it // just from the bucket name (and region). const integration = ScmIntegrations.fromConfig(configRoot).awsS3.list()[0]; + if (!integration) { + throw new Error('No integration found for awsS3'); + } return providerConfigs.map( providerConfig => From 9319b62763e9392c719207a16a3722ca3b425281 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Apr 2022 11:25:00 +0000 Subject: [PATCH 067/144] build(deps): bump google-auth-library from 7.14.0 to 7.14.1 Bumps [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs) from 7.14.0 to 7.14.1. - [Release notes](https://github.com/googleapis/google-auth-library-nodejs/releases) - [Changelog](https://github.com/googleapis/google-auth-library-nodejs/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-auth-library-nodejs/compare/v7.14.0...v7.14.1) --- updated-dependencies: - dependency-name: google-auth-library dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21e7f59caa..54dc6dac65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6566,12 +6566,12 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.14" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" - integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ== +"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^17": + version "17.0.15" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156" + integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw== dependencies: - "@types/react" "*" + "@types/react" "^17" "@types/react-helmet@^6.1.0": version "6.1.5" @@ -6639,7 +6639,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17.0.39": +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": version "17.0.44" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== @@ -13455,9 +13455,9 @@ globby@^7.1.1: slash "^1.0.0" google-auth-library@^7.0.0, google-auth-library@^7.14.0, google-auth-library@^7.6.1: - version "7.14.0" - resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.0.tgz#9d6a20592f7b4d4c463cd3e93934c4b1711d5dc6" - integrity sha512-or8r7qUqGVI3W8lVSdPh0ZpeFyQHeE73g5c0p+bLNTTUFXJ+GSeDQmZRZ2p4H8cF/RJYa4PNvi/A1ar1uVNLFA== + version "7.14.1" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" + integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== dependencies: arrify "^2.0.0" base64-js "^1.3.0" From b0494fb56b07ed54cc6d6f95bde6285e2f3aebcd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Apr 2022 11:25:15 +0000 Subject: [PATCH 068/144] build(deps): bump @types/dockerode from 3.3.7 to 3.3.8 Bumps [@types/dockerode](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/dockerode) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/dockerode) --- updated-dependencies: - dependency-name: "@types/dockerode" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21e7f59caa..e4521115c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5982,9 +5982,9 @@ "@types/ssh2" "*" "@types/dockerode@^3.2.7", "@types/dockerode@^3.3.0": - version "3.3.7" - resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.7.tgz#e5a1083c026bc8fef4b3f7dbb05ee98f72c79732" - integrity sha512-vxRyHVyYJOkLdquTZLNtKDPnPM4+6P/kXZRRb0axbx6WcBVZycmzeXvnllspKXvkjR5mG02IX3IFmkO9RfR0fw== + version "3.3.8" + resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz#afb844b8e8cf54160e1686832903a1444912ff79" + integrity sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw== dependencies: "@types/docker-modem" "*" "@types/node" "*" @@ -6415,10 +6415,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^16.9.2": - version "16.11.6" - resolved "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae" - integrity sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w== +"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^16.11.26", "@types/node@^16.9.2": + version "16.11.26" + resolved "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz#63d204d136c9916fb4dcd1b50f9740fe86884e47" + integrity sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ== "@types/node@12.20.24", "@types/node@^12.7.1": version "12.20.24" @@ -6440,11 +6440,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz#bc43c990c3c9be7281868bbc7b8fdd6e2b57adfa" integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== -"@types/node@^16.11.26": - version "16.11.26" - resolved "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz#63d204d136c9916fb4dcd1b50f9740fe86884e47" - integrity sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ== - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -6566,12 +6561,12 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.14" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" - integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ== +"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^17": + version "17.0.15" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156" + integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw== dependencies: - "@types/react" "*" + "@types/react" "^17" "@types/react-helmet@^6.1.0": version "6.1.5" @@ -6639,7 +6634,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17.0.39": +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": version "17.0.44" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== From 137b9e4047e6eb863df719f600096d600c31f8e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Apr 2022 11:28:11 +0000 Subject: [PATCH 069/144] build(deps): bump @material-ui/icons from 4.11.2 to 4.11.3 Bumps [@material-ui/icons](https://github.com/mui-org/material-ui/tree/HEAD/packages/material-ui-icons) from 4.11.2 to 4.11.3. - [Release notes](https://github.com/mui-org/material-ui/releases) - [Changelog](https://github.com/mui/material-ui/blob/v4.11.3/CHANGELOG.md) - [Commits](https://github.com/mui-org/material-ui/commits/v4.11.3/packages/material-ui-icons) --- updated-dependencies: - dependency-name: "@material-ui/icons" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21e7f59caa..abec43c6f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4238,9 +4238,9 @@ react-transition-group "^4.4.0" "@material-ui/icons@^4.11.2", "@material-ui/icons@^4.9.1": - version "4.11.2" - resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" - integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== + version "4.11.3" + resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz#b0693709f9b161ce9ccde276a770d968484ecff1" + integrity sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA== dependencies: "@babel/runtime" "^7.4.4" @@ -6566,12 +6566,12 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.14" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" - integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ== +"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^17": + version "17.0.15" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156" + integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw== dependencies: - "@types/react" "*" + "@types/react" "^17" "@types/react-helmet@^6.1.0": version "6.1.5" @@ -6639,7 +6639,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17.0.39": +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": version "17.0.44" resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== From 1c8ebf7af2f1a9657c165b3f9160af19989a0549 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Fri, 8 Apr 2022 13:39:46 +0200 Subject: [PATCH 070/144] add changeset Signed-off-by: Kiss Miklos --- .changeset/slimy-horses-do.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slimy-horses-do.md diff --git a/.changeset/slimy-horses-do.md b/.changeset/slimy-horses-do.md new file mode 100644 index 0000000000..0a8fc7f309 --- /dev/null +++ b/.changeset/slimy-horses-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Add dedicated grpc api definition widget From 81463f7f7bcb3e4b420cf37df5449b5a6c8e1b7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Apr 2022 11:47:09 +0000 Subject: [PATCH 071/144] build(deps-dev): bump puppeteer from 13.3.2 to 13.5.2 Bumps [puppeteer](https://github.com/puppeteer/puppeteer) from 13.3.2 to 13.5.2. - [Release notes](https://github.com/puppeteer/puppeteer/releases) - [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md) - [Commits](https://github.com/puppeteer/puppeteer/compare/v13.3.2...v13.5.2) --- updated-dependencies: - dependency-name: puppeteer dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index f62190d798..c5b2a6e9ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10755,7 +10755,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -11068,10 +11068,10 @@ detect-port-alt@^1.1.6: address "^1.0.1" debug "^2.6.0" -devtools-protocol@0.0.960912: - version "0.0.960912" - resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.960912.tgz#411c1fa355eddb72f06c4a8743f2808766db6245" - integrity sha512-I3hWmV9rWHbdnUdmMKHF2NuYutIM2kXz2mdXW8ha7TbRlGTVs+PF+PsB5QWvpCek4Fy9B+msiispCfwlhG5Sqg== +devtools-protocol@0.0.969999: + version "0.0.969999" + resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.969999.tgz#3d6be0a126b3607bb399ae2719b471dda71f3478" + integrity sha512-6GfzuDWU0OFAuOvBokXpXPLxjOJ5DZ157Ue3sGQQM3LgAamb8m0R0ruSfN0DDu+XG5XJgT50i6zZ/0o8RglreQ== dezalgo@1.0.3, dezalgo@^1.0.0: version "1.0.3" @@ -20681,13 +20681,13 @@ pupa@^2.1.1: escape-goat "^2.0.0" puppeteer@^13.1.1: - version "13.3.2" - resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-13.3.2.tgz#4ff1cf6e2009df29fd80038bc702dc067776f79d" - integrity sha512-TIt8/R0eaUwY1c0/O0sCJpSglvGEWVoWFfGZ2dNtxX3eHuBo1ln9abaWfxTjZfsrkYATLSs8oqEdRZpMNnCsvg== + version "13.5.2" + resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-13.5.2.tgz#73ae84969cbf514aeee871a05ec4549d67f6abee" + integrity sha512-DJAyXODBikZ3xPs8C35CtExEw78LZR9RyelGDAs0tX1dERv3OfW7qpQ9VPBgsfz+hG2HiMTO/Tyf7BuMVWsrxg== dependencies: cross-fetch "3.1.5" - debug "4.3.3" - devtools-protocol "0.0.960912" + debug "4.3.4" + devtools-protocol "0.0.969999" extract-zip "2.0.1" https-proxy-agent "5.0.0" pkg-dir "4.2.0" From 776a91ea3a9ae7c293a5bd081939aa6d9caf0850 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 8 Apr 2022 07:10:25 -0500 Subject: [PATCH 072/144] Corrected title and URL to setup documentation Signed-off-by: Andre Wanlin --- .changeset/lemon-ears-build.md | 5 +++++ plugins/catalog-backend-module-aws/README.md | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/lemon-ears-build.md diff --git a/.changeset/lemon-ears-build.md b/.changeset/lemon-ears-build.md new file mode 100644 index 0000000000..95a44b5a9f --- /dev/null +++ b/.changeset/lemon-ears-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +Corrected title and URL to setup documentation diff --git a/plugins/catalog-backend-module-aws/README.md b/plugins/catalog-backend-module-aws/README.md index 304fe7a080..1e54822ea9 100644 --- a/plugins/catalog-backend-module-aws/README.md +++ b/plugins/catalog-backend-module-aws/README.md @@ -1,4 +1,4 @@ -# Catalog Backend Module for LDAP +# Catalog Backend Module for AWS This is an extension module to the plugin-catalog-backend plugin, providing an `AwsOrganizationCloudAccountProcessor` that can be used to ingest cloud accounts @@ -6,5 +6,5 @@ as `Resource` kind entities. ## Getting started -See [Backstage documentation](https://backstage.io/docs/integrations/ldap/org) for details on how to install +See [Backstage documentation](https://backstage.io/docs/integrations/aws-s3/discovery) for details on how to install and configure the plugin. From a136baa657aa6894f59dbe23f2d04c7cf26c843d Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Fri, 8 Apr 2022 14:12:59 +0200 Subject: [PATCH 073/144] spell grpc as gRPC Signed-off-by: Kiss Miklos --- .changeset/slimy-horses-do.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/slimy-horses-do.md b/.changeset/slimy-horses-do.md index 0a8fc7f309..196c47c988 100644 --- a/.changeset/slimy-horses-do.md +++ b/.changeset/slimy-horses-do.md @@ -2,4 +2,4 @@ '@backstage/plugin-api-docs': patch --- -Add dedicated grpc api definition widget +Add dedicated gRPC api definition widget From cc5962d04bb5cfe9b6456d0a7130caf7e0fa3773 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 8 Apr 2022 07:13:00 -0500 Subject: [PATCH 074/144] Improved changeset Signed-off-by: Andre Wanlin --- .changeset/lemon-ears-build.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lemon-ears-build.md b/.changeset/lemon-ears-build.md index 95a44b5a9f..87a04d150b 100644 --- a/.changeset/lemon-ears-build.md +++ b/.changeset/lemon-ears-build.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend-module-aws': patch --- -Corrected title and URL to setup documentation +Corrected title and URL to setup documentation in README From 3f8aae6ffa8d1fcc14c43327e18c800bc00ba127 Mon Sep 17 00:00:00 2001 From: Niklas Aronsson Date: Fri, 8 Apr 2022 14:54:59 +0200 Subject: [PATCH 075/144] Added url schemas to Gerrit location doc examples Also added the Gerrit location docs to the microsite. Signed-off-by: Niklas Aronsson --- docs/integrations/gerrit/locations.md | 4 ++-- microsite/sidebars.json | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/integrations/gerrit/locations.md b/docs/integrations/gerrit/locations.md index 45514948c6..8091de3a2e 100644 --- a/docs/integrations/gerrit/locations.md +++ b/docs/integrations/gerrit/locations.md @@ -19,8 +19,8 @@ To use this integration, add at least one Gerrit configuration to your root `app integrations: gerrit: - host: gerrit.company.com - apiBaseUrl: gerrit.company.com/gerrit - gitilesBaseUrl: gerrit.company.com/gitiles + apiBaseUrl: https://gerrit.company.com/gerrit + gitilesBaseUrl: https://gerrit.company.com/gitiles username: ${GERRIT_USERNAME} password: ${GERRIT_PASSWORD} ``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f0f8811666..d284a3c8ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -153,6 +153,11 @@ "label": "Datadog", "ids": ["integrations/datadog-rum/installation"] }, + { + "type": "subcategory", + "label": "Gerrit", + "ids": ["integrations/gerrit/locations"] + }, { "type": "subcategory", "label": "GitHub", From eb85cd5fac95da83b4edd65fc6c36f6077447d66 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Fri, 8 Apr 2022 16:09:59 +0200 Subject: [PATCH 076/144] use explicit type for props Signed-off-by: Kiss Miklos --- .../GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx index 8178f76073..c5f9b071ef 100644 --- a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx @@ -20,7 +20,7 @@ import { useTheme } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; export type GrpcApiDefinitionWidgetProps = { - definition: any; + definition: string; }; export const GrpcApiDefinitionWidget = ( From 3d4542766619d52a1fbf6feb6e4e92d07f336873 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Thu, 24 Mar 2022 15:37:33 +0100 Subject: [PATCH 077/144] Make Kubernetes clusters refreshable To make it possible to refresh Kubernetes clusters responsible all places that used ClusterDetails[] before now use the KubernetesClustersSupplier directly and call getClusters() on it. This allows people to use implement custom KubernetesClustersSuppliers that fetch the clusters from somewhere and refresh them using whatever mechanism they might need. Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 63 +++++++++++++++++++ docs/features/kubernetes/configuration.md | 6 ++ docs/features/kubernetes/installation.md | 59 +++++++++++++++++ plugins/kubernetes-backend/api-report.md | 9 ++- .../MultiTenantServiceLocator.test.ts | 48 ++++++++------ .../MultiTenantServiceLocator.ts | 14 +++-- .../src/service/KubernetesBuilder.ts | 24 +++---- 7 files changed, 179 insertions(+), 44 deletions(-) create mode 100644 .changeset/hot-items-smoke.md diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md new file mode 100644 index 0000000000..832604cd35 --- /dev/null +++ b/.changeset/hot-items-smoke.md @@ -0,0 +1,63 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +**BREAKING** Custom cluster suppliers need to cache their getClusters result + +To allow custom `KubernetesClustersSupplier` instances to refresh the list of clusters +the `getClusters` method is now called whenever the list of clusters is needed. + +Existing `KubernetesClustersSupplier` implementations will need to ensure that `getClusters` +can be called frequently and should return a cached result from `getClusters` instead. + +For example, here's a simple example of this in `packages/backend/src/plugins/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ private clusterDetails: ClusterDetails[] = []; ++ ++ async retrieveClusters() { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ ++ const clusterSupplier = new CustomClustersSupplier(); ++ env.scheduler ++ .createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ minutes: 60 }), ++ timeout: Duration.fromObject({ minutes: 15 }), ++ }) ++ .run({ ++ id: 'refresh-kubernetes-clusters', ++ fn: clusterSupplier.retrieveClusters, ++ }); ++ builder.setClusterSupplier(clusterSupplier); ++ ++ const { router } = await builder.build(); + return router; + } +``` diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 612f4903d9..d082d46df2 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -261,6 +261,12 @@ Kubernetes plugin. Defaults to `false`. +#### Custom `KubernetesClustersSupplier` + +If the configuration-based cluster locators do not work for your use-case, +it is also possible to implement a +[custom `KubernetesClustersSupplier`](installation.md#custom-cluster-discovery). + ### `customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 2698580491..125889b858 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -90,6 +90,65 @@ async function main() { That's it! The Kubernetes frontend and backend have now been added to your Backstage app. +### Custom cluster discovery + +If either existing +[cluster locators](https://backstage.io/docs/features/kubernetes/configuration#clusterlocatormethods) +don't work for your use-case, it is possible to implement a custom +[KubernetesClustersSupplier](https://backstage.io/docs/reference/plugin-kubernetes-backend.kubernetesclusterssupplier). + +Change the following in `packages/backend/src/plugin/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ private clusterDetails: ClusterDetails[] = []; ++ ++ async retrieveClusters() { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ ++ const clusterSupplier = new CustomClustersSupplier(); ++ env.scheduler ++ .createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ minutes: 60 }), ++ timeout: Duration.fromObject({ minutes: 15 }), ++ }) ++ .run({ ++ id: 'refresh-kubernetes-clusters', ++ fn: clusterSupplier.retrieveClusters, ++ }); ++ builder.setClusterSupplier(clusterSupplier); ++ ++ const { router } = await builder.build(); + return router; + } +``` + ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 8a3b64bd93..c00f68a13f 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -92,11 +92,11 @@ export class KubernetesBuilder { protected buildFetcher(): KubernetesFetcher; // (undocumented) protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildObjectsProvider( @@ -105,12 +105,12 @@ export class KubernetesBuilder { // (undocumented) protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router; // (undocumented) protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) static createBuilder(env: KubernetesEnvironment): KubernetesBuilder; @@ -137,7 +137,6 @@ export class KubernetesBuilder { // @public export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index 8586c5e445..f97161591f 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -19,7 +19,7 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const sut = new MultiTenantServiceLocator([]); + const sut = new MultiTenantServiceLocator({ getClusters: async () => [] }); const result = await sut.getClustersByServiceId('ignored'); @@ -27,14 +27,18 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('one clusters returns one cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - serviceAccountToken: '12345', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + serviceAccountToken: '12345', + }, + ]; }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); @@ -49,19 +53,23 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('two clusters returns two cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]; }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', - }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts index 9f874c7fc8..30c66a38d1 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts @@ -14,20 +14,24 @@ * limitations under the License. */ -import { ClusterDetails, KubernetesServiceLocator } from '../types/types'; +import { + ClusterDetails, + KubernetesClustersSupplier, + KubernetesServiceLocator, +} from '../types/types'; // This locator assumes that every service is located on every cluster // Therefore it will always return all clusters provided export class MultiTenantServiceLocator implements KubernetesServiceLocator { - private readonly clusterDetails: ClusterDetails[]; + private readonly clusterSupplier: KubernetesClustersSupplier; - constructor(clusterDetails: ClusterDetails[]) { - this.clusterDetails = clusterDetails; + constructor(clusterSupplier: KubernetesClustersSupplier) { + this.clusterSupplier = clusterSupplier; } // As this implementation always returns all clusters serviceId is ignored here // eslint-disable-next-line @typescript-eslint/no-unused-vars async getClustersByServiceId(_serviceId: string): Promise { - return this.clusterDetails; + return this.clusterSupplier.getClusters(); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index a1eb60a239..6f5cfd0add 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -20,7 +20,6 @@ import { Logger } from 'winston'; import { getCombinedClusterDetails } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { - ClusterDetails, KubernetesObjectTypes, ServiceLocatorMethod, CustomResource, @@ -50,7 +49,6 @@ export interface KubernetesEnvironment { */ export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; @@ -93,11 +91,9 @@ export class KubernetesBuilder { const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); - const clusterDetails = await this.fetchClusterDetails(clusterSupplier); - const serviceLocator = this.serviceLocator ?? - this.buildServiceLocator(this.getServiceLocatorMethod(), clusterDetails); + this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); const objectsProvider = this.objectsProvider ?? @@ -109,10 +105,9 @@ export class KubernetesBuilder { objectTypesToFetch: this.getObjectTypesToFetch(), }); - const router = this.buildRouter(objectsProvider, clusterDetails); + const router = this.buildRouter(objectsProvider, clusterSupplier); return { - clusterDetails, clusterSupplier, customResources, fetcher, @@ -185,13 +180,13 @@ export class KubernetesBuilder { protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { switch (method) { case 'multiTenant': - return this.buildMultiTenantServiceLocator(clusterDetails); + return this.buildMultiTenantServiceLocator(clusterSupplier); case 'http': - return this.buildHttpServiceLocator(clusterDetails); + return this.buildHttpServiceLocator(clusterSupplier); default: throw new Error( `Unsupported kubernetes.clusterLocatorMethod "${method}"`, @@ -200,20 +195,20 @@ export class KubernetesBuilder { } protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { - return new MultiTenantServiceLocator(clusterDetails); + return new MultiTenantServiceLocator(clusterSupplier); } protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { throw new Error('not implemented'); } protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router { const logger = this.env.logger; const router = Router(); @@ -236,6 +231,7 @@ export class KubernetesBuilder { }); router.get('/clusters', async (_, res) => { + const clusterDetails = await this.fetchClusterDetails(clusterSupplier); res.json({ items: clusterDetails.map(cd => ({ name: cd.name, From 5818bead7d20bb59b3989ca3f9b1348e9e3fd570 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Tue, 29 Mar 2022 11:22:22 +0200 Subject: [PATCH 078/144] Ensure GkeClusterLocator only fetches the clusters once This is now necessary because cluster suppliers are called whenever the list of clusters is required and thus should cache their clusters. Signed-off-by: Luna Stadler --- .../src/cluster-locator/GkeClusterLocator.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 8b70db40eb..989b69c7c8 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -17,7 +17,11 @@ import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; -import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { + ClusterDetails, + GKEClusterDetails, + KubernetesClustersSupplier, +} from '../types/types'; type GkeClusterLocatorOptions = { projectId: string; @@ -31,6 +35,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { constructor( private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, + private clusterDetails: GKEClusterDetails[] | undefined = undefined, ) {} static fromConfigWithClient( @@ -55,8 +60,17 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { ); } + async getClusters(): Promise { + if (this.clusterDetails) { + return this.clusterDetails; + } + + this.clusterDetails = await this.retrieveClusters(); + return this.clusterDetails; + } + // TODO pass caData into the object - async getClusters(): Promise { + async retrieveClusters(): Promise { const { projectId, region, From 38ea1f136c7f61ec5fcad1adbca45b1ac237a240 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Tue, 29 Mar 2022 12:03:21 +0200 Subject: [PATCH 079/144] List existing cluster locator methods in one place I found the nested headings difficult to read, so this now lists all available methods in one place. Signed-off-by: Luna Stadler --- docs/features/kubernetes/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index d082d46df2..dd7e666be4 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -57,6 +57,10 @@ This is an array used to determine where to retrieve cluster configuration from. Valid cluster locator methods are: +- [`config`](#config) +- [`gke`](#gke) +- [custom `KubernetesClustersSupplier`](#custom-kubernetesclusterssupplier) + #### `config` This cluster locator method will read cluster information from your app-config From 8d050f714d4e73662772f82b50cce176c2c8cbfb Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Mon, 4 Apr 2022 14:26:11 +0200 Subject: [PATCH 080/144] Make Kubernetes cluster refresh more explicit The `refreshClusters` method is now part of the `KubernetesClustersSupplier` interface definition and also documented. All existing cluster suppliers now implement it as well and the examples in the docs and in the CHANGELOG have been adjusted. Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 19 ++--- docs/features/kubernetes/installation.md | 19 ++--- plugins/kubernetes-backend/api-report.md | 4 +- .../cluster-locator/ConfigClusterLocator.ts | 2 + .../cluster-locator/GkeClusterLocator.test.ts | 7 +- .../src/cluster-locator/GkeClusterLocator.ts | 11 +-- .../src/cluster-locator/index.test.ts | 10 +-- .../src/cluster-locator/index.ts | 71 +++++++++++-------- .../MultiTenantServiceLocator.test.ts | 7 +- .../src/service/KubernetesBuilder.test.ts | 2 + .../src/service/KubernetesBuilder.ts | 25 +++++-- .../src/service/runPeriodically.ts | 54 ++++++++++++++ plugins/kubernetes-backend/src/types/types.ts | 11 +++ 13 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 plugins/kubernetes-backend/src/service/runPeriodically.ts diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md index 832604cd35..52c5088d3e 100644 --- a/.changeset/hot-items-smoke.md +++ b/.changeset/hot-items-smoke.md @@ -21,12 +21,11 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -+import { Duration } from 'luxon'; -+ + +export class CustomClustersSupplier implements KubernetesClustersSupplier { + private clusterDetails: ClusterDetails[] = []; + -+ async retrieveClusters() { ++ async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere + } + @@ -34,7 +33,7 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + return this.clusterDetails; + } +} - ++ export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -46,18 +45,12 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + }); + + const clusterSupplier = new CustomClustersSupplier(); -+ env.scheduler -+ .createScheduledTaskRunner({ -+ frequency: Duration.fromObject({ minutes: 60 }), -+ timeout: Duration.fromObject({ minutes: 15 }), -+ }) -+ .run({ -+ id: 'refresh-kubernetes-clusters', -+ fn: clusterSupplier.retrieveClusters, -+ }); + builder.setClusterSupplier(clusterSupplier); + + const { router } = await builder.build(); return router; } ``` + +If you need to adjust the refresh interval from the default once per hour +you can call `builder.setClusterRefreshInterval`. diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 125889b858..4b4ba1139d 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -108,12 +108,11 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -+import { Duration } from 'luxon'; -+ + +export class CustomClustersSupplier implements KubernetesClustersSupplier { + private clusterDetails: ClusterDetails[] = []; + -+ async retrieveClusters() { ++ async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere + } + @@ -121,7 +120,7 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + return this.clusterDetails; + } +} - ++ export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -133,15 +132,6 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + }); + + const clusterSupplier = new CustomClustersSupplier(); -+ env.scheduler -+ .createScheduledTaskRunner({ -+ frequency: Duration.fromObject({ minutes: 60 }), -+ timeout: Duration.fromObject({ minutes: 15 }), -+ }) -+ .run({ -+ id: 'refresh-kubernetes-clusters', -+ fn: clusterSupplier.retrieveClusters, -+ }); + builder.setClusterSupplier(clusterSupplier); + + const { router } = await builder.build(); @@ -149,6 +139,9 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: } ``` +If you need to adjust the refresh interval from the default once per hour +you can call `builder.setClusterRefreshInterval`. + ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index c00f68a13f..573198acf0 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -125,6 +125,8 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) + setClusterRefreshInterval(refreshMs: number): this; + // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) setFetcher(fetcher?: KubernetesFetcher): this; @@ -148,8 +150,8 @@ export type KubernetesBuilderReturn = Promise<{ // // @public (undocumented) export interface KubernetesClustersSupplier { - // (undocumented) getClusters(): Promise; + refreshClusters(): Promise; } // Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 1bde1226dd..893db0f5d0 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -77,6 +77,8 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { ); } + async refreshClusters(): Promise {} + async getClusters(): Promise { return this.clusterDetails; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 6bef8e0009..51062066da 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -69,6 +69,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([]); @@ -100,6 +101,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -137,6 +139,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -179,6 +182,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -217,7 +221,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await expect(sut.getClusters()).rejects.toThrow( + await expect(sut.refreshClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', ); @@ -250,6 +254,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); + await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 989b69c7c8..8484f5006b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -61,16 +61,11 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } async getClusters(): Promise { - if (this.clusterDetails) { - return this.clusterDetails; - } - - this.clusterDetails = await this.retrieveClusters(); - return this.clusterDetails; + return this.clusterDetails ?? []; } // TODO pass caData into the object - async retrieveClusters(): Promise { + async refreshClusters(): Promise { const { projectId, region, @@ -84,7 +79,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { try { const [response] = await this.client.listClusters(request); - return (response.clusters ?? []).map(r => ({ + this.clusterDetails = (response.clusters ?? []).map(r => ({ // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', url: `https://${r.endpoint ?? ''}`, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 2bc2c719a0..2bdc37c31c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -15,9 +15,9 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { getCombinedClusterDetails } from './index'; +import { getCombinedClusterSupplier } from './index'; -describe('getCombinedClusterDetails', () => { +describe('getCombinedClusterSupplier', () => { it('should retrieve cluster details from config', async () => { const config: Config = new ConfigReader( { @@ -45,7 +45,9 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - const result = await getCombinedClusterDetails(config); + const clusterSupplier = getCombinedClusterSupplier(config); + await clusterSupplier.refreshClusters(); + const result = await clusterSupplier.getClusters(); expect(result).toStrictEqual([ { @@ -99,7 +101,7 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - await expect(getCombinedClusterDetails(config)).rejects.toStrictEqual( + expect(() => getCombinedClusterSupplier(config)).toThrowError( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index a4bcf77395..fec92d06d7 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,38 +15,51 @@ */ import { Config } from '@backstage/config'; -import { ClusterDetails } from '../types/types'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; -export const getCombinedClusterDetails = async ( +class CombinedClustersSupplier implements KubernetesClustersSupplier { + constructor( + readonly clusterSuppliers: KubernetesClustersSupplier[], + private clusterDetails: ClusterDetails[] | undefined = undefined, + ) {} + + async refreshClusters(): Promise { + this.clusterDetails = await Promise.all( + this.clusterSuppliers.map(supplier => supplier.getClusters()), + ) + .then(res => { + return res.flat(); + }) + .catch(e => { + throw e; + }); + } + + async getClusters(): Promise { + return this.clusterDetails ?? []; + } +} + +export const getCombinedClusterSupplier = ( rootConfig: Config, -): Promise => { - return Promise.all( - rootConfig - .getConfigArray('kubernetes.clusterLocatorMethods') - .map(clusterLocatorMethod => { - const type = clusterLocatorMethod.getString('type'); - switch (type) { - case 'config': - return ConfigClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - case 'gke': - return GkeClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - default: - throw new Error( - `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, - ); - } - }), - ) - .then(res => { - return res.flat(); - }) - .catch(e => { - throw e; +): KubernetesClustersSupplier => { + const clusterSuppliers = rootConfig + .getConfigArray('kubernetes.clusterLocatorMethods') + .map(clusterLocatorMethod => { + const type = clusterLocatorMethod.getString('type'); + switch (type) { + case 'config': + return ConfigClusterLocator.fromConfig(clusterLocatorMethod); + case 'gke': + return GkeClusterLocator.fromConfig(clusterLocatorMethod); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, + ); + } }); + + return new CombinedClustersSupplier(clusterSuppliers); }; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index f97161591f..974f554f45 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -19,7 +19,10 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const sut = new MultiTenantServiceLocator({ getClusters: async () => [] }); + const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, + getClusters: async () => [], + }); const result = await sut.getClustersByServiceId('ignored'); @@ -28,6 +31,7 @@ describe('MultiTenantConfigClusterLocator', () => { it('one clusters returns one cluster details', async () => { const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, getClusters: async () => { return [ { @@ -54,6 +58,7 @@ describe('MultiTenantConfigClusterLocator', () => { it('two clusters returns two cluster details', async () => { const sut = new MultiTenantServiceLocator({ + refreshClusters: async () => {}, getClusters: async () => { return [ { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 37dad8c3e4..c3afa4fb6a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -59,6 +59,7 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { + async refreshClusters() {}, async getClusters() { return clusters; }, @@ -179,6 +180,7 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { + async refreshClusters() {}, async getClusters() { return clusters; }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 6f5cfd0add..9cdef9f00d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -17,7 +17,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { getCombinedClusterDetails } from '../cluster-locator'; +import { getCombinedClusterSupplier } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { KubernetesObjectTypes, @@ -36,6 +36,7 @@ import { KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { runPeriodically } from './runPeriodically'; export interface KubernetesEnvironment { logger: Logger; @@ -58,6 +59,7 @@ export type KubernetesBuilderReturn = Promise<{ export class KubernetesBuilder { private clusterSupplier?: KubernetesClustersSupplier; + private clusterRefreshMs: number = 60 * 60 * 1000; // defaults to once per hour private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; @@ -91,6 +93,16 @@ export class KubernetesBuilder { const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); + // we cannot use the regular scheduler here because all instances need this info + // and it is not persisted anywhere. + runPeriodically(async () => { + try { + await clusterSupplier.refreshClusters(); + } catch (e) { + logger.warn(`Failed to refresh kubernetes clusters: ${e}`); + } + }, this.clusterRefreshMs); + const serviceLocator = this.serviceLocator ?? this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); @@ -122,6 +134,11 @@ export class KubernetesBuilder { return this; } + public setClusterRefreshInterval(refreshMs: number) { + this.clusterRefreshMs = refreshMs; + return this; + } + public setObjectsProvider(objectsProvider?: KubernetesObjectsProvider) { this.objectsProvider = objectsProvider; return this; @@ -158,11 +175,7 @@ export class KubernetesBuilder { protected buildClusterSupplier(): KubernetesClustersSupplier { const config = this.env.config; - return { - getClusters() { - return getCombinedClusterDetails(config); - }, - }; + return getCombinedClusterSupplier(config); } protected buildObjectsProvider( diff --git a/plugins/kubernetes-backend/src/service/runPeriodically.ts b/plugins/kubernetes-backend/src/service/runPeriodically.ts new file mode 100644 index 0000000000..2f3104e221 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/runPeriodically.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +/** + * Runs a function repeatedly, with a fixed wait between invocations. + * + * Supports async functions, and silently ignores exceptions and rejections. + * + * @param fn - The function to run. May return a Promise. + * @param delayMs - The delay between a completed function invocation and the + * next. + * @returns A function that, when called, stops the invocation loop. + */ +export function runPeriodically(fn: () => any, delayMs: number): () => void { + let cancel: () => void; + let cancelled = false; + const cancellationPromise = new Promise(resolve => { + cancel = () => { + resolve(); + cancelled = true; + }; + }); + + const startRefresh = async () => { + while (!cancelled) { + try { + await fn(); + } catch { + // ignore intentionally + } + + await Promise.race([ + new Promise(resolve => setTimeout(resolve, delayMs)), + cancellationPromise, + ]); + } + }; + startRefresh(); + + return cancel!; +} diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 37c26956fe..512d916639 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,6 +80,17 @@ export type KubernetesObjectTypes = // Used to load cluster details from different sources export interface KubernetesClustersSupplier { + /** + * Refreshes the list of cluster from the source. + * + * This will be called periodically on a schedule to refresh the list + * of clusters. + */ + refreshClusters(): Promise; + + /** + * Returns the cached list of clusters. + */ getClusters(): Promise; } From 67dbe016713b457aa3def29ac2439d181a99443b Mon Sep 17 00:00:00 2001 From: Jason Nguyen Date: Fri, 8 Apr 2022 09:26:23 -0600 Subject: [PATCH 081/144] catalog-backend: simplify getDocumentText Signed-off-by: Jason Nguyen --- plugins/catalog-backend/src/search/util.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-backend/src/search/util.ts b/plugins/catalog-backend/src/search/util.ts index 47a5570899..7e7ce7064e 100644 --- a/plugins/catalog-backend/src/search/util.ts +++ b/plugins/catalog-backend/src/search/util.ts @@ -25,15 +25,13 @@ function isGroupEntity(entity: Entity): entity is UserEntity { } export function getDocumentText(entity: Entity): string { - let documentText = entity.metadata.description || ''; + const documentTexts: string[] = []; + documentTexts.push(entity.metadata.description || ''); + if (isUserEntity(entity) || isGroupEntity(entity)) { - if (entity.spec?.profile?.displayName && documentText) { - // combine displayName and description - const displayName = entity.spec.profile.displayName; - documentText = displayName.concat(' : ', documentText); - } else { - documentText = entity.spec?.profile?.displayName || documentText; + if (entity.spec?.profile?.displayName) { + documentTexts.push(entity.spec.profile.displayName); } } - return documentText; + return documentTexts.join(' : '); } From 1d8d3c408ad72621166880d9882a7b39ee606fa0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 8 Apr 2022 18:13:05 +0200 Subject: [PATCH 082/144] Use human-readable durations throughout. Signed-off-by: Eric Peterson --- .changeset/ninety-eggs-argue.md | 4 ++-- docs/features/search/getting-started.md | 16 ++++++++-------- packages/backend/src/plugins/search.ts | 4 ++-- .../packages/backend/src/plugins/search.ts.hbs | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.changeset/ninety-eggs-argue.md b/.changeset/ninety-eggs-argue.md index 01f861fe4b..6fd221b722 100644 --- a/.changeset/ninety-eggs-argue.md +++ b/.changeset/ninety-eggs-argue.md @@ -17,8 +17,8 @@ To make this change to an existing app, make the following changes to `packages/ /* ... */ + const schedule = env.scheduler.createScheduledTaskRunner({ -+ frequency: Duration.fromObject({ seconds: 600 }), -+ timeout: Duration.fromObject({ seconds: 900 }), ++ frequency: Duration.fromObject({ minutes: 10 }), ++ timeout: Duration.fromObject({ minutes: 15 }), + initialDelay: Duration.fromObject({ seconds: 3 }), + }); diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index bc17b1eea2..1557a29b53 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -163,8 +163,8 @@ export default async function createPlugin( }); const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 600 }), - timeout: Duration.fromObject({ seconds: 900 }), + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), initialDelay: Duration.fromObject({ seconds: 3 }), }); @@ -299,14 +299,14 @@ import { Duration } from 'luxon'; const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine }); const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 600 }), - timeout: Duration.fromObject({ seconds: 900 }), + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), initialDelay: Duration.fromObject({ seconds: 3 }), }); const everyHourSchedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 3600 }), - timeout: Duration.fromObject({ seconds: 5400 }), + frequency: Duration.fromObject({ hours: 1 }), + timeout: Duration.fromObject({ minutes: 90 }), initialDelay: Duration.fromObject({ seconds: 3 }), }); @@ -332,8 +332,8 @@ a scheduled `TaskRunner` to pass into the `schedule` value, like this: ```typescript {3} const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 600 }), - timeout: Duration.fromObject({ seconds: 900 }), + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), initialDelay: Duration.fromObject({ seconds: 3 }), }); diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 052b7380c3..fc91d98cfe 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -57,8 +57,8 @@ export default async function createPlugin( }); const schedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 600 }), - timeout: Duration.fromObject({ seconds: 900 }), + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), // A 3 second delay gives the backend server a chance to initialize before // any collators are executed, which may attempt requests against the API. initialDelay: Duration.fromObject({ seconds: 3 }), diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs index bda10ae21b..62bc3056cc 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs @@ -33,8 +33,8 @@ export default async function createPlugin( }); const schedule = env.scheduler.createScheduledTaskRunner({ - frequency: Duration.fromObject({ seconds: 600 }), - timeout: Duration.fromObject({ seconds: 900 }), + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), // A 3 second delay gives the backend server a chance to initialize before // any collators are executed, which may attempt requests against the API. initialDelay: Duration.fromObject({ seconds: 3 }), From 5960b78a4cd175581827c374778706581a9ea5fc Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 8 Apr 2022 18:13:45 +0200 Subject: [PATCH 083/144] Document task scheduler / DB. And improve throughout. Signed-off-by: Eric Peterson --- docs/assets/search/architecture.drawio.svg | 945 ++++++++++++++------- 1 file changed, 637 insertions(+), 308 deletions(-) diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg index d7a8177d1c..709a008b43 100644 --- a/docs/assets/search/architecture.drawio.svg +++ b/docs/assets/search/architecture.drawio.svg @@ -1,20 +1,19 @@ - + - - - - - - - - + + + + + + + - -
-
-
+ +
+
+

@@ -22,18 +21,17 @@
- - + - +
-
-
+
+

@@ -42,20 +40,38 @@
- - - + + + + + +
+
+
+
+ Search +
+ Engine +
+
+
+
+ + Search... + +
+
- +
-
-
+
+
App Package: <Route path="/search" element={<... />} />
@@ -66,51 +82,51 @@ - + - -
-
-
- @backstage/ -
- plugin-search-backend + +
+
+
+ + @backstage/ +
+ plugin-search-backend +
- + @backstage/... - + - -
-
-
- Other Plugins -
- (TechDocs, Catalog, Etc) + +
+
+
+ @backstage/plugin-xyz
- - Other Plugins... + + @backstage/plugin-xyz - + - -
-
-
+ +
+
+

@@ -120,94 +136,90 @@
- + - + - -
-
-
+ +
+
+
@backstage/plugin-search
- + @backstage/plugin-search - - - + - -
-
-
- Other Backend Plugin (TechDocs, Catalog, Etc) + +
+
+
+ @backstage/plugin-xyz-backend
- - Other Backend Plugin (TechDocs, Catalog, Etc) + + @backstage/plugin-xyz-backend - + - -
-
-
- Search Engine (Elastic, Solr, SaaS, etc.) + +
+
+
+ e.g. ElasticSearch, Postgres, Lunr, etc.
- - Search Engine (Elastic, Solr, SaaS, et... + + e.g. ElasticSearch, Postgres, Lunr,... - + - -
-
-
- Search Engine Integration Layer + +
+
+
+ @backstage/plugin-search-backend-module-xyz
- - Search Engine Integration Layer + + @backstage/plugin-search-backend-module-xyz - - - - - + + + - +
-
-
+
+
1 2 3 @@ -223,10 +235,10 @@ - +
-
-
+
+
X number of search results @@ -239,17 +251,17 @@ - - - - - + + + + + - -
-
-
+ +
+
+
Components @@ -257,43 +269,18 @@
- + Components - - - + - -
-
-
- Pass Search -
- Term and Filters -
- and then -
- Return Results -
-
-
-
- - Pass Search... - -
-
- - - - -
-
-
+ +
+
+
Search API @@ -301,20 +288,20 @@
- + Search API - - + + - -
-
-
+ +
+
+
Components @@ -322,232 +309,574 @@
- + Components - - + + - -
-
-
+ +
+
+
+ + IndexBuilder + +
+
+
+
+ + IndexBui... + + + + + + + + + + +
+
+
+ + Query Service +
+
+
+
+
+
+ + Query Se... + +
+
+ + + + +
+
+
+ + + Indexer + +
+
+ Manages Indices +
+ and Writes Documents to a Search Engine +
+
+
+
+
+
+ + Indexer... + +
+
+ + + + + + + +
+
+
+ + + Query Handler + +
+
+ Compiles and Executes Query Against a Search Engine +
+
+
+
+
+ + Query Handler... + +
+
+ + + + +
+
+
+ @backstage/ +
+ plugin-search-backend-node +
+
+
+
+
+ + @backstage/... + +
+
+ + + + +
+
+
+ Backend Package: src/plugins/search.ts +
+
+
+
+ + Backend Package: src/plugins/search.ts + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + +
+
+
+ + + Authorization +
+ (Optional) +
+
+
+
+
+
+
+ + Authorization... + +
+
+ + + + + + + +
+
+
+ IndexBuilder +
+
+
+
+ + IndexBuilder + +
+
+ + + + +
+
+
+ + SearchEngine + +
+
+
+
+ + SearchEngine + +
+
+ + + + +
+
+
+ + Collator(s) + +
+
+
+
+ + Collator(s) + +
+
+ + + + +
+
+
+ + Decorator(s) + +
+
+
+
+ + Decorator(s) + +
+
+ + + + +
+
+
+ + Start Schedule + +
+
+
+
+ + Start Schedule + +
+
+ + + + +
+
+
+ + Create Router + +
+
+
+
+ + Create Router + +
+
+ + + + +
+
+
+ Scheduler
- + Scheduler
- + + + + + - -
-
-
- - Gather Documents From Plugins - + +
+
+
+
+ + Database for + +
+ +
+ + Task Coordination + +
+
+ + Among Nodes + +
+
- - Gather D... + + Database fo... - - - - + + - -
-
-
- - Register Document / Metadata Collation Handler(s) - + +
+
+
+
+ + Install and configure the +
+ search engine, collators, +
+ and decorators that are +
+ appropriate for your +
+ organization! +
+
- - Register Docu... + + Install and con... - + + - -
-
-
- - API Endpoint + +
+
+
+ + Custom Query
+ Translator (Optional)
- - API Endp... + + Custom Quer... - + - -
-
-
+ +
+
+
+ + SearchEngine Implementation + +
+
+
+
+ + SearchEngine Implementati... + + + + + + + +
+
+
+ + XyzCollatorFactory + +
+
+
+
+ + XyzCollatorFactory + +
+
+ + + + + + + +
+
+
+ + XyzDecoratorFactory + +
+
+
+
+ + XyzDecoratorFacto... + +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + Individual backend plugins +
+ define how documents are +
+ retrieved from the plugin's +
+ data store and mapped to +
+ an IndexableDocument. +
+
+
+
+
+
+ + Individual backe... + +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + Individual frontend plugins may +
+ define custom components, +
+ e.g. custom search result items. +
+
+
+
+
+
+ + Individual frontend... + +
+
+ + + + +
+
+
- Query Processing + Search Context
- - Query Processing - -
-
- - - - -
-
-
- - Index Processing - -
-
-
-
- - Index Processi... - -
-
- - - - -
-
-
- - Index Processing - -
-
-
-
- - Index Processi... - -
-
- - - - -
-
-
- - Query Processing - -
-
-
-
- - Query Processing - -
-
- - - - -
-
-
- - - Manage Index - -
- Create, Remove, Replace Documents and Indices -
-
-
-
-
- - Manage Index... - -
-
- - - - - - - -
-
-
- - Compile and Execute Query from Term and Filters - -
-
-
-
- - Compile and Execute... - -
-
- - - - -
-
-
- @backstage/ -
- plugin-search-backend-node -
-
-
-
- - @backstage/... + + Search Cont...
+ + + + + + + + + From c0d0e2bccb4fe26b79d220b2ed8d99e3499b3948 Mon Sep 17 00:00:00 2001 From: LvffY Date: Sat, 2 Apr 2022 15:39:17 +0200 Subject: [PATCH 084/144] =?UTF-8?q?[#10582]=20=F0=9F=90=9B=20Use=20getEnti?= =?UTF-8?q?tySourceLocation=20instead=20of=20custom=20method=20to=20get=20?= =?UTF-8?q?the=20entity=20source=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit f0e86e8ba349cf7056c64960b7ad39740dad4cf6) --- .../src/service/TodoReaderService.ts | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 4397f34045..1b3376dbfd 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -17,10 +17,7 @@ import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; import { - ANNOTATION_LOCATION, - ANNOTATION_SOURCE_LOCATION, - Entity, - parseLocationRef, + getEntitySourceLocation, stringifyEntityRef, } from '@backstage/catalog-model'; import { TodoReader } from '../lib'; @@ -75,7 +72,7 @@ export class TodoReaderService implements TodoService { ); } - const url = this.getEntitySourceUrl(entity); + const url = getEntitySourceLocation(entity).target; const todos = await this.todoReader.readTodos({ url }); let limit = req.limit ?? this.defaultPageSize; @@ -125,36 +122,4 @@ export class TodoReaderService implements TodoService { limit, }; } - - private getEntitySourceUrl(entity: Entity) { - const sourceLocation = - entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION]; - if (sourceLocation) { - const parsed = parseLocationRef(sourceLocation); - if (parsed.type !== 'url') { - throw new InputError( - `Invalid entity source location type for ${stringifyEntityRef( - entity, - )}, got '${parsed.type}'`, - ); - } - return parsed.target; - } - - const location = entity.metadata.annotations?.[ANNOTATION_LOCATION]; - if (location) { - const parsed = parseLocationRef(location); - if (parsed.type !== 'url') { - throw new InputError( - `Invalid entity location type for ${stringifyEntityRef( - entity, - )}, got '${parsed.type}'`, - ); - } - return parsed.target; - } - throw new InputError( - `No entity location annotation found for ${stringifyEntityRef(entity)}`, - ); - } } From 1b13978d4d1ee85aaf09d092c3c8a021478244fc Mon Sep 17 00:00:00 2001 From: LvffY Date: Sat, 2 Apr 2022 17:19:33 +0200 Subject: [PATCH 085/144] =?UTF-8?q?[#10582]=20=F0=9F=92=9A=20Fix=20tests?= =?UTF-8?q?=20for=20todo-backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit eba6328126af2e20a429c96081ae96c4615a7c9d) --- .../src/service/TodoReaderService.test.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 943462bd63..f99a9b7502 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -310,7 +310,7 @@ describe('TodoReaderService', () => { }); }); - it('should throw if entity does not have a location', async () => { + it('should not throw if entity does not have a location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, @@ -320,14 +320,14 @@ describe('TodoReaderService', () => { await expect(service.listTodos({ entity: entityName })).rejects.toEqual( expect.objectContaining({ - name: 'InputError', + name: 'Error', message: - 'No entity location annotation found for component:default/my-component', + 'Entity \'component:default/my-component\' is missing location', }), ); }); - it('should throw if entity has an invalid location', async () => { + it('should not throw if entity has an invalid location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, @@ -340,15 +340,16 @@ describe('TodoReaderService', () => { }); const service = new TodoReaderService({ todoReader, catalogClient }); - await expect(service.listTodos({ entity: entityName })).rejects.toEqual( - expect.objectContaining({ - name: 'InputError', - message: `Invalid entity location type for component:default/my-component, got 'file'`, - }), + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [], + totalCount: 0, + offset: 0, + limit: 10, + } ); }); - it('should throw if entity has an invalid source location', async () => { + it('should not throw if entity has an invalid source location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, @@ -361,11 +362,12 @@ describe('TodoReaderService', () => { }); const service = new TodoReaderService({ todoReader, catalogClient }); - await expect(service.listTodos({ entity: entityName })).rejects.toEqual( - expect.objectContaining({ - name: 'InputError', - message: `Invalid entity source location type for component:default/my-component, got 'file'`, - }), + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [], + totalCount: 0, + offset: 0, + limit: 10, + } ); }); }); From 5da036264ba7b2114e9e8bb22f1b5c58e3312509 Mon Sep 17 00:00:00 2001 From: LvffY Date: Sat, 2 Apr 2022 17:29:40 +0200 Subject: [PATCH 086/144] =?UTF-8?q?[#10582]=20=E2=9C=A8=20Add=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit e16af648068bd0cc49999c5d078385f15aa7c49b) --- .changeset/gorgeous-donuts-float.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/gorgeous-donuts-float.md diff --git a/.changeset/gorgeous-donuts-float.md b/.changeset/gorgeous-donuts-float.md new file mode 100644 index 0000000000..9016b1117e --- /dev/null +++ b/.changeset/gorgeous-donuts-float.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-todo-backend': patch +--- + +Fix method to get source-location. + +See https://github.com/backstage/backstage/pull/10584 From 4e96e3fdb1ff3813cf1bf4f81970e8485b1312a3 Mon Sep 17 00:00:00 2001 From: LvffY Date: Sat, 2 Apr 2022 17:35:43 +0200 Subject: [PATCH 087/144] =?UTF-8?q?[#10582]=20=F0=9F=93=9D=20Fix=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit 1fad23ee1ece324b6026b399e8570552a11369cf) --- plugins/todo-backend/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md index 4015087b02..0f4342c19d 100644 --- a/plugins/todo-backend/README.md +++ b/plugins/todo-backend/README.md @@ -52,8 +52,9 @@ async function main() { ## Scanned Files -The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location` -](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. +The included `TodoReaderService` and `TodoScmReader` works by getting the entity source location from the catalog. + +The location source code is determined automatically. In case of the source code of the component is not in the same place of the entity YAML file, you can explicitly set the value of the [`backstage.io/source-location`](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. ## Parser Configuration From 3399dfb8c075383b7e2151a6718c704b84adf1e0 Mon Sep 17 00:00:00 2001 From: LvffY Date: Sat, 2 Apr 2022 17:41:48 +0200 Subject: [PATCH 088/144] =?UTF-8?q?[#10582]=20=F0=9F=91=8C=20Pass=20pretti?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit a103befad1950863049a5711471500690d6eb2bd) --- plugins/todo-backend/README.md | 2 +- .../src/service/TodoReaderService.test.ts | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md index 0f4342c19d..4686c2d39f 100644 --- a/plugins/todo-backend/README.md +++ b/plugins/todo-backend/README.md @@ -52,7 +52,7 @@ async function main() { ## Scanned Files -The included `TodoReaderService` and `TodoScmReader` works by getting the entity source location from the catalog. +The included `TodoReaderService` and `TodoScmReader` works by getting the entity source location from the catalog. The location source code is determined automatically. In case of the source code of the component is not in the same place of the entity YAML file, you can explicitly set the value of the [`backstage.io/source-location`](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index f99a9b7502..b16f0ee669 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -321,8 +321,7 @@ describe('TodoReaderService', () => { await expect(service.listTodos({ entity: entityName })).rejects.toEqual( expect.objectContaining({ name: 'Error', - message: - 'Entity \'component:default/my-component\' is missing location', + message: "Entity 'component:default/my-component' is missing location", }), ); }); @@ -341,12 +340,11 @@ describe('TodoReaderService', () => { const service = new TodoReaderService({ todoReader, catalogClient }); await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ - items: [], - totalCount: 0, - offset: 0, - limit: 10, - } - ); + items: [], + totalCount: 0, + offset: 0, + limit: 10, + }); }); it('should not throw if entity has an invalid source location', async () => { @@ -363,11 +361,10 @@ describe('TodoReaderService', () => { const service = new TodoReaderService({ todoReader, catalogClient }); await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ - items: [], - totalCount: 0, - offset: 0, - limit: 10, - } - ); + items: [], + totalCount: 0, + offset: 0, + limit: 10, + }); }); }); From fe3c72181412f9d591ff87f03468c7dadff4589a Mon Sep 17 00:00:00 2001 From: LvffY Date: Fri, 8 Apr 2022 19:12:00 +0200 Subject: [PATCH 089/144] =?UTF-8?q?[#10582]=20=F0=9F=91=8C=20Fix=20changes?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/backstage/backstage/pull/10584#discussion_r841507071 Signed-off-by: LvffY (cherry picked from commit bd7e5271b1fe283fc1b304185c0d53115d1da172) --- .changeset/gorgeous-donuts-float.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/gorgeous-donuts-float.md b/.changeset/gorgeous-donuts-float.md index 9016b1117e..72a28ba6f9 100644 --- a/.changeset/gorgeous-donuts-float.md +++ b/.changeset/gorgeous-donuts-float.md @@ -3,5 +3,3 @@ --- Fix method to get source-location. - -See https://github.com/backstage/backstage/pull/10584 From 75ab45d8b4d8988d5a52b3c1e0eb6a79d2c786d2 Mon Sep 17 00:00:00 2001 From: LvffY Date: Fri, 8 Apr 2022 19:49:37 +0200 Subject: [PATCH 090/144] =?UTF-8?q?[#10582]=20=F0=9F=91=8C=20Re-add=20test?= =?UTF-8?q?s=20and=20code=20to=20throw=20exceptions=20when=20entity=20type?= =?UTF-8?q?=20is=20not=20url.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/backstage/backstage/pull/10584#discussion_r841505986 See https://github.com/backstage/backstage/pull/10584#discussion_r841502848 Signed-off-by: LvffY (cherry picked from commit 8a986150cc1e96b3150516ef36f24bdd89c55bff) --- .../src/service/TodoReaderService.test.ts | 32 +++++++++---------- .../src/service/TodoReaderService.ts | 11 +++++-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index b16f0ee669..3806895666 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -326,45 +326,45 @@ describe('TodoReaderService', () => { ); }); - it('should not throw if entity has an invalid location', async () => { + it('should throw if entity has an invalid location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, metadata: { ...mockEntity.metadata, annotations: { - ['backstage.io/managed-by-location']: 'file:../info.yaml', + ['backstage.io/managed-by-location']: 'file:../managed-by-location.yaml', }, }, }); const service = new TodoReaderService({ todoReader, catalogClient }); - await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ - items: [], - totalCount: 0, - offset: 0, - limit: 10, - }); + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity location type for component:default/my-component, got 'file' for location ../managed-by-location.yaml`, + }), + ); }); - it('should not throw if entity has an invalid source location', async () => { + it('should throw if entity has an invalid source location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, metadata: { ...mockEntity.metadata, annotations: { - ['backstage.io/source-location']: 'file:../info.yaml', + ['backstage.io/source-location']: 'file:../source-location.yaml', }, }, }); const service = new TodoReaderService({ todoReader, catalogClient }); - await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ - items: [], - totalCount: 0, - offset: 0, - limit: 10, - }); + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity location type for component:default/my-component, got 'file' for location ../source-location.yaml`, + }), + ); }); }); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 1b3376dbfd..20d633e409 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -71,8 +71,15 @@ export class TodoReaderService implements TodoService { `Entity not found, ${stringifyEntityRef(req.entity)}`, ); } - - const url = getEntitySourceLocation(entity).target; + const entitySourceLocation = getEntitySourceLocation(entity) + if (entitySourceLocation.type !== 'url') { + throw new InputError( + `Invalid entity location type for ${stringifyEntityRef( + entity, + )}, got '${entitySourceLocation.type}' for location ${entitySourceLocation.target}`, + ); + } + const url = entitySourceLocation.target; const todos = await this.todoReader.readTodos({ url }); let limit = req.limit ?? this.defaultPageSize; From 659ef6aaa9949b7ff6d638ae4750927059101111 Mon Sep 17 00:00:00 2001 From: LvffY Date: Fri, 8 Apr 2022 19:56:23 +0200 Subject: [PATCH 091/144] =?UTF-8?q?[#10582]=20=F0=9F=91=8C=20Pass=20pretti?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: LvffY (cherry picked from commit 90efbd7f197c999f7a067bb813ff5b0232307b2f) --- .../todo-backend/src/service/TodoReaderService.test.ts | 3 ++- plugins/todo-backend/src/service/TodoReaderService.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 3806895666..6dd14afcb4 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -333,7 +333,8 @@ describe('TodoReaderService', () => { metadata: { ...mockEntity.metadata, annotations: { - ['backstage.io/managed-by-location']: 'file:../managed-by-location.yaml', + ['backstage.io/managed-by-location']: + 'file:../managed-by-location.yaml', }, }, }); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 20d633e409..35fd76b8be 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -71,12 +71,12 @@ export class TodoReaderService implements TodoService { `Entity not found, ${stringifyEntityRef(req.entity)}`, ); } - const entitySourceLocation = getEntitySourceLocation(entity) + const entitySourceLocation = getEntitySourceLocation(entity); if (entitySourceLocation.type !== 'url') { throw new InputError( - `Invalid entity location type for ${stringifyEntityRef( - entity, - )}, got '${entitySourceLocation.type}' for location ${entitySourceLocation.target}`, + `Invalid entity location type for ${stringifyEntityRef(entity)}, got '${ + entitySourceLocation.type + }' for location ${entitySourceLocation.target}`, ); } const url = entitySourceLocation.target; From bb0e30bb940aca1af0bfed83983523d39c709ed4 Mon Sep 17 00:00:00 2001 From: daftgopher Date: Fri, 8 Apr 2022 15:22:38 -0400 Subject: [PATCH 092/144] Update adopters file Signed-off-by: daftgopher --- ADOPTERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index ce5b2b70ef..006dc071cd 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -26,7 +26,7 @@ _If you're using Backstage in your organization, please try to add your company | [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | | [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | | [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [Peloton](https://www.onepeloton.com/) | [Matt Waldron](https://github.com/daftgopher) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | | [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | | [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | | [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | From c2b79c12dedc97134026b8ee18e6998fe5206a14 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Sat, 9 Apr 2022 09:22:36 -0300 Subject: [PATCH 093/144] Update docs to refer new prop Signed-off-by: Rogerio Angeliski --- plugins/tech-insights/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/tech-insights/README.md b/plugins/tech-insights/README.md index 5f7d7ad4e5..a305ef5dc7 100644 --- a/plugins/tech-insights/README.md +++ b/plugins/tech-insights/README.md @@ -38,6 +38,10 @@ const serviceEntityPage = ( title="Customized title for the scorecard" description="Small description about scorecards" /> + ... @@ -46,6 +50,8 @@ const serviceEntityPage = ( It is not obligatory to pass title and description props to `EntityTechInsightsScorecardContent`. If those are left out, default values from `defaultCheckResultRenderers` in `CheckResultRenderer` will be taken, hence `Boolean scorecard` and `This card represents an overview of default boolean Backstage checks`. +You can pass an array `checksId` as a prop with the [Fact Retrievers ids](../tech-insights-backend#creating-fact-retrievers) to limit which checks you want to show in this card, If you don't pass, the default value is show all checks. + ## Boolean Scorecard Example If you follow the [Backend Example](https://github.com/backstage/backstage/tree/master/plugins/tech-insights-backend#backend-example), once the needed facts have been generated the boolean scorecard will look like this: From f26cf63878366afee00ea81766b5d6fac4cc5ca1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 Apr 2022 12:31:39 +0000 Subject: [PATCH 094/144] build(deps-dev): bump @types/jest-when from 2.7.2 to 3.5.0 Bumps [@types/jest-when](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest-when) from 2.7.2 to 3.5.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest-when) --- updated-dependencies: - dependency-name: "@types/jest-when" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .changeset/dependabot-699dc23.md | 5 +++++ plugins/kafka-backend/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/dependabot-699dc23.md diff --git a/.changeset/dependabot-699dc23.md b/.changeset/dependabot-699dc23.md new file mode 100644 index 0000000000..b73b0eab46 --- /dev/null +++ b/.changeset/dependabot-699dc23.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kafka-backend': patch +--- + +build(deps-dev): bump `@types/jest-when` from 2.7.2 to 3.5.0 diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 4deeefc788..732fc30927 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@backstage/cli": "^0.17.0-next.1", - "@types/jest-when": "^2.7.2", + "@types/jest-when": "^3.5.0", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", "supertest": "^6.1.3" diff --git a/yarn.lock b/yarn.lock index 37046a1f16..e469f8432b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6224,10 +6224,10 @@ dependencies: "@types/node" "*" -"@types/jest-when@^2.7.2": - version "2.7.2" - resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.2.tgz#619fbc5f623bcd0b29efde0e4993c7f0d50d026d" - integrity sha512-vOtj0cev6vO1VX7Jbfg/qvy+sfLI64STsHbKVkggK+1kd11rcMGzFpZKBxUvQfsm4JRULCBISu+qrfs7fYZFGg== +"@types/jest-when@^3.5.0": + version "3.5.0" + resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-3.5.0.tgz#6a573cd521da131e6801f0991b4f1d4dee2ebab5" + integrity sha512-rNUuZ3Mn/HDzpImPXDeOtW18zqyerPoOS2aKU0zUFbirWgJ7sN7LnRv73RmbBQ/uzw28sxf/nUofxbhJ5DWB0w== dependencies: "@types/jest" "*" From f95c796c98abf0ccbdeaa66bcfafbe7f1d944395 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 Apr 2022 13:22:26 +0000 Subject: [PATCH 095/144] build(deps-dev): bump @types/lodash from 4.14.178 to 4.14.181 Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.14.178 to 4.14.181. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..8d38361061 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6325,9 +6325,9 @@ "@types/node" "*" "@types/lodash@^4.14.151", "@types/lodash@^4.14.173", "@types/lodash@^4.14.175": - version "4.14.178" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" - integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + version "4.14.181" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz#d1d3740c379fda17ab175165ba04e2d03389385d" + integrity sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag== "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.1" From fedff63fd61f74170b5cec2908ad3ff6aea6b8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 9 Apr 2022 15:49:45 +0200 Subject: [PATCH 096/144] update dependencies needed in the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/integrations/github/discovery.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index 46e054364f..1cbf8905a2 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -16,11 +16,12 @@ catalog. You will have to add the processors in the catalog initialization code of your backend. They are not installed by default, therefore you have to add a -dependency to `@backstage/plugin-catalog-backend-module-github` to your backend -package. +dependency on `@backstage/plugin-catalog-backend-module-github` to your backend +package, plus `@backstage/integration` for the basic credentials management: ```bash # From your Backstage root directory +yarn add --cwd packages/backend @backstage/integration yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-github ``` From 08d0a6abf10b1c79bcacafd503b450e6c2544c22 Mon Sep 17 00:00:00 2001 From: djamaile Date: Fri, 11 Mar 2022 19:33:32 +0100 Subject: [PATCH 097/144] feat: introduce cicd-statistics-module-gitlab plugin Signed-off-by: djamaile --- packages/app/src/apis.ts | 13 +- .../app/src/components/catalog/EntityPage.tsx | 5 + .../.eslintrc.js | 3 + .../cicd-statistics-module-gitlab/README.md | 9 + .../package.json | 48 +++++ .../src/api/gitlab.ts | 189 ++++++++++++++++++ .../src/api/index.ts | 17 ++ .../src/index.ts | 17 ++ .../src/setupTests.ts | 17 ++ plugins/cicd-statistics/src/apis/types.ts | 2 +- .../src/components/button-switch.tsx | 3 + plugins/cicd-statistics/src/entity-page.tsx | 2 + .../cicd-statistics/src/utils/stage-names.ts | 4 +- 13 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 plugins/cicd-statistics-module-gitlab/.eslintrc.js create mode 100644 plugins/cicd-statistics-module-gitlab/README.md create mode 100644 plugins/cicd-statistics-module-gitlab/package.json create mode 100644 plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts create mode 100644 plugins/cicd-statistics-module-gitlab/src/api/index.ts create mode 100644 plugins/cicd-statistics-module-gitlab/src/index.ts create mode 100644 plugins/cicd-statistics-module-gitlab/src/setupTests.ts diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index d587a88402..afc96a68db 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -32,8 +32,10 @@ import { configApiRef, createApiFactory, errorApiRef, - githubAuthApiRef, + githubAuthApiRef, gitlabAuthApiRef, } from '@backstage/core-plugin-api'; +import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; +import {CicdStatisticsApiGitlab} from "@backstage/plugin-cicd-statistics-module-gitlab"; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -42,6 +44,7 @@ export const apis: AnyApiFactory[] = [ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), + ScmAuth.createDefaultApiFactory(), createApiFactory({ @@ -64,4 +67,12 @@ export const apis: AnyApiFactory[] = [ }), createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), + createApiFactory({ + api: cicdStatisticsApiRef, + deps: {gitlabAuthApi: gitlabAuthApiRef}, + factory({ gitlabAuthApi }) { + return new CicdStatisticsApiGitlab(gitlabAuthApi); + }, + }), +// createApiFactory(cicdStatisticsApiRef, new CicdStatisticsApiGitlab()), ]; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 26068c6c8f..89897905b8 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -136,6 +136,7 @@ import { EntityNewRelicDashboardCard, } from '@backstage/plugin-newrelic-dashboard'; import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; +import {EntityCicdStatisticsContent} from "@backstage/plugin-cicd-statistics"; import React, { ReactNode, useMemo, useState } from 'react'; @@ -444,6 +445,10 @@ const websiteEntityPage = ( {cicdContent} + + + + diff --git a/plugins/cicd-statistics-module-gitlab/.eslintrc.js b/plugins/cicd-statistics-module-gitlab/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md new file mode 100644 index 0000000000..85c297932e --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -0,0 +1,9 @@ +# cicd-statistics-module-gitlab + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/cicd-statistics-module-gitlab](http://localhost:3000/cicd-statistics-module-gitlab). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json new file mode 100644 index 0000000000..df9ccef6b8 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/plugin-cicd-statistics-module-gitlab", + "description": "CI/CD Statistics plugin module; Gitlab CICD", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/cicd-statistics-module-gitlab" + }, + "keywords": [ + "backstage", + "cicd statestics", + "gitlab" + ], + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/catalog-model": "^0.9.10", + "@backstage/core-plugin-api": "^0.8.0", + "@backstage/plugin-cicd-statistics": "^0.1.0", + "@gitbeaker/browser": "34.2.0", + "@gitbeaker/core": "^35.4.0", + "lodash": "^4.17.21", + "luxon": "^2.0.2", + "p-limit": "^4.0.0" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts new file mode 100644 index 0000000000..a28ce612d5 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -0,0 +1,189 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { + CicdStatisticsApi, + CicdState, + CicdConfiguration, + Build, + FilterStatusType, + FetchBuildsOptions, + Stage, +} from '@backstage/plugin-cicd-statistics'; +import { Gitlab } from '@gitbeaker/browser'; +import { OAuthApi } from '@backstage/core-plugin-api'; +import limiterFactory from 'p-limit'; + +import { Types } from '@gitbeaker/core'; +import { Entity, getEntitySourceLocation } from '@backstage/catalog-model'; + +const statusMap: Record = { + manual: 'unknown', + created: 'enqueued', + waiting_for_resource: 'stalled', + preparing: 'unknown', + pending: 'scheduled', + running: 'running', + success: 'succeeded', + failed: 'failed', + canceled: 'aborted', + skipped: 'aborted', + scheduled: 'scheduled', +}; + +function jobtToBuild(jobs: Array): Build[] { + return jobs.map(j => { + return { + id: j.id.toString(), + status: statusMap[j.status], + branchType: 'master', + duration: 0, // will get filled in later in a seperate API call + requestedAt: new Date(j.created_at), + stages: [], + }; + }); +} + +function jobsToStages(jobs: Array): Stage[] { + return jobs.map(j => { + const status = statusMap[j.status] ? statusMap[j.status] : 'unknown'; + return { + name: j.name, + status: status, + duration: j.duration ? ((j.duration * 1000) as number) : 0, + }; + }); +} + +type GitlabClient = { + api: InstanceType; + owner: string; +}; + +export class CicdStatisticsApiGitlab implements CicdStatisticsApi { + readonly #gitLabAuthApi: OAuthApi; + + constructor(gitLabAuthApi: OAuthApi) { + this.#gitLabAuthApi = gitLabAuthApi; + } + + public async createGitlabApi( + entity: Entity, + scopes: string[], + ): Promise { + const entityInfo = getEntitySourceLocation(entity); + const url = new URL(entityInfo.target); + const owner = url.pathname.split('/-/blob/')[0]; + const oauthToken = await this.#gitLabAuthApi.getAccessToken(scopes); + return { + api: new Gitlab({ + host: `https://${url.host}`, + oauthToken, + }), + owner: owner.substring(1), + }; + } + + private static async updateBuildWithStages( + gitbeaker: InstanceType, + owner: string, + build: Build, + ): Promise { + const jobs = await gitbeaker.Jobs.showPipelineJobs( + owner, + parseInt(build.id, 10), + ); + const stages = jobsToStages(jobs); + return stages; + } + + private static async getDurationOfBuild( + gitbeaker: InstanceType, + owner: string, + build: Build, + ): Promise { + const pipeline = (await gitbeaker.Pipelines.show( + owner, + parseInt(build.id, 10), + )) as Types.PipelineExtendedSchema; + return parseInt(pipeline.duration as string, 10) * 1000; + } + + private static async getDefaultBranch( + gitbeaker: InstanceType, + owner: string, + ): Promise { + const branches = await gitbeaker.Branches.all(owner); + return branches.find(b => b.default)?.name; + } + + public async fetchBuilds(options: FetchBuildsOptions): Promise { + const { + entity, + updateProgress, + timeFrom, + timeTo, + filterStatus = ['all'], + filterType = 'all', + } = options; + const { api, owner } = await this.createGitlabApi(entity as Entity, [ + 'read_api', + ]); + updateProgress(0, 0, 0); + + const branch = + filterType === 'master' + ? await CicdStatisticsApiGitlab.getDefaultBranch(api, owner) + : undefined; + const pipelines = await api.Pipelines.all(owner, { + perPage: 25, + updated_after: timeFrom.toISOString(), + updated_before: timeTo.toISOString(), + ref: branch, + }); + + const limiter = limiterFactory(10); + const builds = jobtToBuild(pipelines).map(async build => ({ + ...build, + duration: await limiter(() => + CicdStatisticsApiGitlab.getDurationOfBuild(api, owner, build), + ), + stages: await limiter(() => + CicdStatisticsApiGitlab.updateBuildWithStages(api, owner, build), + ), + })); + const promisedBuilds = (await Promise.all(builds)).filter(b => + filterStatus.includes(b.status), + ) as unknown as Build[]; + + return { builds: promisedBuilds }; + } + + public async getConfiguration(): Promise> { + return { + availableStatuses: [ + 'succeeded', + 'failed', + 'enqueued', + 'running', + 'aborted', + 'stalled', + 'expired', + 'unknown', + ] as const, + }; + } +} diff --git a/plugins/cicd-statistics-module-gitlab/src/api/index.ts b/plugins/cicd-statistics-module-gitlab/src/api/index.ts new file mode 100644 index 0000000000..686c8b5f63 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export * from "./gitlab"; diff --git a/plugins/cicd-statistics-module-gitlab/src/index.ts b/plugins/cicd-statistics-module-gitlab/src/index.ts new file mode 100644 index 0000000000..409c45eecd --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { CicdStatisticsApiGitlab } from './api'; diff --git a/plugins/cicd-statistics-module-gitlab/src/setupTests.ts b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts new file mode 100644 index 0000000000..9bb3e72355 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts index 32d9d3f2df..975f69525c 100644 --- a/plugins/cicd-statistics/src/apis/types.ts +++ b/plugins/cicd-statistics/src/apis/types.ts @@ -117,7 +117,7 @@ export interface Build { duration: number; /** Top-level build stages */ - stages: Array; + stages?: Array; } /** diff --git a/plugins/cicd-statistics/src/components/button-switch.tsx b/plugins/cicd-statistics/src/components/button-switch.tsx index 65f55eee41..89a5f7735a 100644 --- a/plugins/cicd-statistics/src/components/button-switch.tsx +++ b/plugins/cicd-statistics/src/components/button-switch.tsx @@ -78,6 +78,9 @@ export function ButtonSwitch(props: ButtonSwitchProps) { ); const value = switchValue(values[index]); + if(!value){ + console.log(values[index]); + } if (props.multi) { props.onChange( props.selection.includes(value as T) diff --git a/plugins/cicd-statistics/src/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx index 3c51dabf2a..8c3209c854 100644 --- a/plugins/cicd-statistics/src/entity-page.tsx +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -166,6 +166,8 @@ function CicdCharts(props: CicdChartsProps) { errorApi.post(chartableStagesState.error); }, [errorApi, chartableStagesState.error]); + console.log(chartableStagesState); + return ( diff --git a/plugins/cicd-statistics/src/utils/stage-names.ts b/plugins/cicd-statistics/src/utils/stage-names.ts index 69c8cdcf25..8853336fef 100644 --- a/plugins/cicd-statistics/src/utils/stage-names.ts +++ b/plugins/cicd-statistics/src/utils/stage-names.ts @@ -68,6 +68,8 @@ export async function cleanupBuildTree( return map(builds, { chunk: 'idle' }, build => ({ ...build, - stages: build.stages.map(stage => recurseStage(stage, [])), + stages: build.stages + ? build.stages.map(stage => recurseStage(stage, [])) + : [], })); } From d3af30b47d8d5a9879456adcf5acbb9343ee3790 Mon Sep 17 00:00:00 2001 From: djamaile Date: Fri, 11 Mar 2022 20:22:00 +0100 Subject: [PATCH 098/144] chore: run prettier to prevent build from failing Signed-off-by: djamaile --- .changeset/proud-news-perform.md | 5 +++++ packages/app/src/apis.ts | 10 +++++----- packages/app/src/components/catalog/EntityPage.tsx | 2 +- plugins/cicd-statistics-module-gitlab/src/api/index.ts | 2 +- plugins/cicd-statistics/src/apis/types.ts | 2 +- .../cicd-statistics/src/components/button-switch.tsx | 3 --- plugins/cicd-statistics/src/entity-page.tsx | 2 -- plugins/cicd-statistics/src/utils/stage-names.ts | 4 +--- 8 files changed, 14 insertions(+), 16 deletions(-) create mode 100644 .changeset/proud-news-perform.md diff --git a/.changeset/proud-news-perform.md b/.changeset/proud-news-perform.md new file mode 100644 index 0000000000..20259c33e5 --- /dev/null +++ b/.changeset/proud-news-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cicd-statistics-module-gitlab': minor +--- + +Will update this once the PR is ready to be merged diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index afc96a68db..7214a96688 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -32,10 +32,11 @@ import { configApiRef, createApiFactory, errorApiRef, - githubAuthApiRef, gitlabAuthApiRef, + githubAuthApiRef, + gitlabAuthApiRef, } from '@backstage/core-plugin-api'; import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; -import {CicdStatisticsApiGitlab} from "@backstage/plugin-cicd-statistics-module-gitlab"; +import { CicdStatisticsApiGitlab } from '@backstage/plugin-cicd-statistics-module-gitlab'; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -44,7 +45,6 @@ export const apis: AnyApiFactory[] = [ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), - ScmAuth.createDefaultApiFactory(), createApiFactory({ @@ -69,10 +69,10 @@ export const apis: AnyApiFactory[] = [ createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), createApiFactory({ api: cicdStatisticsApiRef, - deps: {gitlabAuthApi: gitlabAuthApiRef}, + deps: { gitlabAuthApi: gitlabAuthApiRef }, factory({ gitlabAuthApi }) { return new CicdStatisticsApiGitlab(gitlabAuthApi); }, }), -// createApiFactory(cicdStatisticsApiRef, new CicdStatisticsApiGitlab()), + // createApiFactory(cicdStatisticsApiRef, new CicdStatisticsApiGitlab()), ]; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 89897905b8..3ade051077 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -136,7 +136,7 @@ import { EntityNewRelicDashboardCard, } from '@backstage/plugin-newrelic-dashboard'; import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; -import {EntityCicdStatisticsContent} from "@backstage/plugin-cicd-statistics"; +import { EntityCicdStatisticsContent } from '@backstage/plugin-cicd-statistics'; import React, { ReactNode, useMemo, useState } from 'react'; diff --git a/plugins/cicd-statistics-module-gitlab/src/api/index.ts b/plugins/cicd-statistics-module-gitlab/src/api/index.ts index 686c8b5f63..21f5f622a6 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/index.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from "./gitlab"; +export * from './gitlab'; diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts index 975f69525c..32d9d3f2df 100644 --- a/plugins/cicd-statistics/src/apis/types.ts +++ b/plugins/cicd-statistics/src/apis/types.ts @@ -117,7 +117,7 @@ export interface Build { duration: number; /** Top-level build stages */ - stages?: Array; + stages: Array; } /** diff --git a/plugins/cicd-statistics/src/components/button-switch.tsx b/plugins/cicd-statistics/src/components/button-switch.tsx index 89a5f7735a..65f55eee41 100644 --- a/plugins/cicd-statistics/src/components/button-switch.tsx +++ b/plugins/cicd-statistics/src/components/button-switch.tsx @@ -78,9 +78,6 @@ export function ButtonSwitch(props: ButtonSwitchProps) { ); const value = switchValue(values[index]); - if(!value){ - console.log(values[index]); - } if (props.multi) { props.onChange( props.selection.includes(value as T) diff --git a/plugins/cicd-statistics/src/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx index 8c3209c854..3c51dabf2a 100644 --- a/plugins/cicd-statistics/src/entity-page.tsx +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -166,8 +166,6 @@ function CicdCharts(props: CicdChartsProps) { errorApi.post(chartableStagesState.error); }, [errorApi, chartableStagesState.error]); - console.log(chartableStagesState); - return ( diff --git a/plugins/cicd-statistics/src/utils/stage-names.ts b/plugins/cicd-statistics/src/utils/stage-names.ts index 8853336fef..69c8cdcf25 100644 --- a/plugins/cicd-statistics/src/utils/stage-names.ts +++ b/plugins/cicd-statistics/src/utils/stage-names.ts @@ -68,8 +68,6 @@ export async function cleanupBuildTree( return map(builds, { chunk: 'idle' }, build => ({ ...build, - stages: build.stages - ? build.stages.map(stage => recurseStage(stage, [])) - : [], + stages: build.stages.map(stage => recurseStage(stage, [])), })); } From e3cca397e4e2686add39644b3ad1f533e6192777 Mon Sep 17 00:00:00 2001 From: djamaile Date: Sat, 12 Mar 2022 19:25:49 +0100 Subject: [PATCH 099/144] fix: yarn.lock to keep tsc command from failing Signed-off-by: djamaile --- packages/app/src/apis.ts | 4 +--- .../.eslintrc.js | 4 +--- .../package.json | 22 ++++++++----------- .../src/setupTests.ts | 3 +-- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 7214a96688..7b3232f42d 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -65,8 +65,6 @@ export const apis: AnyApiFactory[] = [ }), ]), }), - - createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), createApiFactory({ api: cicdStatisticsApiRef, deps: { gitlabAuthApi: gitlabAuthApiRef }, @@ -74,5 +72,5 @@ export const apis: AnyApiFactory[] = [ return new CicdStatisticsApiGitlab(gitlabAuthApi); }, }), - // createApiFactory(cicdStatisticsApiRef, new CicdStatisticsApiGitlab()), + createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), ]; diff --git a/plugins/cicd-statistics-module-gitlab/.eslintrc.js b/plugins/cicd-statistics-module-gitlab/.eslintrc.js index 13573efa9c..e2a53a6ad2 100644 --- a/plugins/cicd-statistics-module-gitlab/.eslintrc.js +++ b/plugins/cicd-statistics-module-gitlab/.eslintrc.js @@ -1,3 +1 @@ -module.exports = { - extends: [require.resolve('@backstage/cli/config/eslint')], -}; +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json index df9ccef6b8..e41e634dc2 100644 --- a/plugins/cicd-statistics-module-gitlab/package.json +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -8,16 +8,12 @@ "private": false, "publishConfig": { "access": "public", - "main": "dist/index.esm.js", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, "backstage": { - "role": "frontend-plugin" - }, - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "plugins/cicd-statistics-module-gitlab" + "role": "common-library" }, "keywords": [ "backstage", @@ -27,20 +23,20 @@ "scripts": { "build": "backstage-cli package build", "lint": "backstage-cli package lint", - "test": "backstage-cli package test" + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.9.10", - "@backstage/core-plugin-api": "^0.8.0", "@backstage/plugin-cicd-statistics": "^0.1.0", "@gitbeaker/browser": "34.2.0", "@gitbeaker/core": "^35.4.0", - "lodash": "^4.17.21", "luxon": "^2.0.2", "p-limit": "^4.0.0" }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "devDependencies": { + "@backstage/cli": "^0.15.2" }, "files": [ "dist" diff --git a/plugins/cicd-statistics-module-gitlab/src/setupTests.ts b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts index 9bb3e72355..8b9b6bd586 100644 --- a/plugins/cicd-statistics-module-gitlab/src/setupTests.ts +++ b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import '@testing-library/jest-dom'; -import 'cross-fetch/polyfill'; +export {}; From f65495894e583dedb5050791546c2210f167984a Mon Sep 17 00:00:00 2001 From: djamaile Date: Sat, 12 Mar 2022 19:44:35 +0100 Subject: [PATCH 100/144] fix: add missing dependencies to prevent build from failing Signed-off-by: djamaile --- plugins/cicd-statistics-module-gitlab/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json index e41e634dc2..f7121a42ae 100644 --- a/plugins/cicd-statistics-module-gitlab/package.json +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -33,7 +33,9 @@ "@gitbeaker/browser": "34.2.0", "@gitbeaker/core": "^35.4.0", "luxon": "^2.0.2", - "p-limit": "^4.0.0" + "p-limit": "^4.0.0", + "@backstage/core-plugin-api": "^0.8.0", + "@backstage/catalog-model": "^0.13.0" }, "devDependencies": { "@backstage/cli": "^0.15.2" From 9e4937dd836a4432801745961b21490f626ae313 Mon Sep 17 00:00:00 2001 From: djamaile Date: Sat, 12 Mar 2022 20:05:28 +0100 Subject: [PATCH 101/144] chore: add API report Signed-off-by: djamaile --- .../api-report.md | 28 +++++++++++++++++++ .../src/api/gitlab.ts | 5 ++++ 2 files changed, 33 insertions(+) create mode 100644 plugins/cicd-statistics-module-gitlab/api-report.md diff --git a/plugins/cicd-statistics-module-gitlab/api-report.md b/plugins/cicd-statistics-module-gitlab/api-report.md new file mode 100644 index 0000000000..c9e1e19016 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-cicd-statistics-module-gitlab" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CicdConfiguration } from '@backstage/plugin-cicd-statistics'; +import { CicdState } from '@backstage/plugin-cicd-statistics'; +import { CicdStatisticsApi } from '@backstage/plugin-cicd-statistics'; +import { Entity } from '@backstage/catalog-model'; +import { FetchBuildsOptions } from '@backstage/plugin-cicd-statistics'; +import { Gitlab } from '@gitbeaker/browser'; +import { OAuthApi } from '@backstage/core-plugin-api'; + +// @public +export class CicdStatisticsApiGitlab implements CicdStatisticsApi { + constructor(gitLabAuthApi: OAuthApi); + // Warning: (ae-forgotten-export) The symbol "GitlabClient" needs to be exported by the entry point index.d.ts + // + // (undocumented) + createGitlabApi(entity: Entity, scopes: string[]): Promise; + // (undocumented) + fetchBuilds(options: FetchBuildsOptions): Promise; + // (undocumented) + getConfiguration(): Promise>; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts index a28ce612d5..45b682302b 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -73,6 +73,11 @@ type GitlabClient = { owner: string; }; +/** + * Extracts the CI/CD statistics from a Gitlab repository + * + * @public + */ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { readonly #gitLabAuthApi: OAuthApi; From ff21a91645e4b7a69865d18e5ae73d61e0456bb1 Mon Sep 17 00:00:00 2001 From: djamaile Date: Sat, 12 Mar 2022 20:26:07 +0100 Subject: [PATCH 102/144] chore: update API report and remove links from README Signed-off-by: djamaile --- plugins/cicd-statistics-module-gitlab/README.md | 6 +----- plugins/cicd-statistics-module-gitlab/api-report.md | 8 ++++++-- plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts | 9 ++++++++- plugins/cicd-statistics-module-gitlab/src/index.ts | 1 + 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md index 85c297932e..3cce8b0cde 100644 --- a/plugins/cicd-statistics-module-gitlab/README.md +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -2,8 +2,4 @@ ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/cicd-statistics-module-gitlab](http://localhost:3000/cicd-statistics-module-gitlab). - -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +Will update in another commit diff --git a/plugins/cicd-statistics-module-gitlab/api-report.md b/plugins/cicd-statistics-module-gitlab/api-report.md index c9e1e19016..5e07255f54 100644 --- a/plugins/cicd-statistics-module-gitlab/api-report.md +++ b/plugins/cicd-statistics-module-gitlab/api-report.md @@ -14,8 +14,6 @@ import { OAuthApi } from '@backstage/core-plugin-api'; // @public export class CicdStatisticsApiGitlab implements CicdStatisticsApi { constructor(gitLabAuthApi: OAuthApi); - // Warning: (ae-forgotten-export) The symbol "GitlabClient" needs to be exported by the entry point index.d.ts - // // (undocumented) createGitlabApi(entity: Entity, scopes: string[]): Promise; // (undocumented) @@ -24,5 +22,11 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { getConfiguration(): Promise>; } +// @public +export type GitlabClient = { + api: InstanceType; + owner: string; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts index 45b682302b..1c4866557c 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -68,8 +68,15 @@ function jobsToStages(jobs: Array): Stage[] { }); } -type GitlabClient = { +/** + * This type represents a initialized gitlab client with gitbeaker + * + * @public + */ +export type GitlabClient = { + /* the actual API of gitbeaker */ api: InstanceType; + /* the owner the repository, retrieved from the entity source location */ owner: string; }; diff --git a/plugins/cicd-statistics-module-gitlab/src/index.ts b/plugins/cicd-statistics-module-gitlab/src/index.ts index 409c45eecd..c8d53d5bd2 100644 --- a/plugins/cicd-statistics-module-gitlab/src/index.ts +++ b/plugins/cicd-statistics-module-gitlab/src/index.ts @@ -15,3 +15,4 @@ */ export { CicdStatisticsApiGitlab } from './api'; +export type { GitlabClient } from './api'; From d9a4b672c2f81aed85cd0ed6e08107acfc2fc757 Mon Sep 17 00:00:00 2001 From: djamaile Date: Wed, 30 Mar 2022 17:10:16 +0200 Subject: [PATCH 103/144] test: add test cases to test if the util functions are working correctly Signed-off-by: djamaile --- .../cicd-statistics-module-gitlab/README.md | 35 +++++++++++- .../src/api/gitlab.ts | 56 +++---------------- 2 files changed, 41 insertions(+), 50 deletions(-) diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md index 3cce8b0cde..58c053167f 100644 --- a/plugins/cicd-statistics-module-gitlab/README.md +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -1,5 +1,38 @@ # cicd-statistics-module-gitlab +This is an extension module to the `cicd-statistics` plugin, providing a `CicdStatisticsApiGitlab` that you can use to extract the cicd statistics from your Gitlab repository. + ## Getting started -Will update in another commit +1. Install the `cicd-statistics` and `cicd-statistics-module-gitlab` plugins in the `app` package. + +2. Configure your ApiFactory: + +```tsx +// packages/app/src/apis.ts +import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; + +import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; +import { CicdStatisticsApiGitlab } from '@backstage plugin-cicd-statistics-module-gitlab'; + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: cicdStatisticsApiRef, + deps: { gitlabAuthApi: gitlabAuthApiRef }, + factory({ gitlabAuthApi }) { + return new CicdStatisticsApiGitlab(gitlabAuthApi); + }, + }), +]; +``` + +3. Add the component to your EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityCicdStatisticsContent } from '@backstage/plugin-cicd-statistics'; + + + +; +``` diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts index 1c4866557c..7439d1800c 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -19,54 +19,14 @@ import { CicdState, CicdConfiguration, Build, - FilterStatusType, FetchBuildsOptions, Stage, } from '@backstage/plugin-cicd-statistics'; import { Gitlab } from '@gitbeaker/browser'; import { OAuthApi } from '@backstage/core-plugin-api'; import limiterFactory from 'p-limit'; - -import { Types } from '@gitbeaker/core'; import { Entity, getEntitySourceLocation } from '@backstage/catalog-model'; - -const statusMap: Record = { - manual: 'unknown', - created: 'enqueued', - waiting_for_resource: 'stalled', - preparing: 'unknown', - pending: 'scheduled', - running: 'running', - success: 'succeeded', - failed: 'failed', - canceled: 'aborted', - skipped: 'aborted', - scheduled: 'scheduled', -}; - -function jobtToBuild(jobs: Array): Build[] { - return jobs.map(j => { - return { - id: j.id.toString(), - status: statusMap[j.status], - branchType: 'master', - duration: 0, // will get filled in later in a seperate API call - requestedAt: new Date(j.created_at), - stages: [], - }; - }); -} - -function jobsToStages(jobs: Array): Stage[] { - return jobs.map(j => { - const status = statusMap[j.status] ? statusMap[j.status] : 'unknown'; - return { - name: j.name, - status: status, - duration: j.duration ? ((j.duration * 1000) as number) : 0, - }; - }); -} +import { pipelinesToBuilds, jobsToStages } from './utils'; /** * This type represents a initialized gitlab client with gitbeaker @@ -127,10 +87,10 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { owner: string, build: Build, ): Promise { - const pipeline = (await gitbeaker.Pipelines.show( + const pipeline = await gitbeaker.Pipelines.show( owner, parseInt(build.id, 10), - )) as Types.PipelineExtendedSchema; + ); return parseInt(pipeline.duration as string, 10) * 1000; } @@ -139,7 +99,7 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { owner: string, ): Promise { const branches = await gitbeaker.Branches.all(owner); - return branches.find(b => b.default)?.name; + return branches.find(branch => branch.default)?.name; } public async fetchBuilds(options: FetchBuildsOptions): Promise { @@ -151,9 +111,7 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { filterStatus = ['all'], filterType = 'all', } = options; - const { api, owner } = await this.createGitlabApi(entity as Entity, [ - 'read_api', - ]); + const { api, owner } = await this.createGitlabApi(entity, ['read_api']); updateProgress(0, 0, 0); const branch = @@ -168,7 +126,7 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { }); const limiter = limiterFactory(10); - const builds = jobtToBuild(pipelines).map(async build => ({ + const builds = pipelinesToBuilds(pipelines).map(async build => ({ ...build, duration: await limiter(() => CicdStatisticsApiGitlab.getDurationOfBuild(api, owner, build), @@ -179,7 +137,7 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { })); const promisedBuilds = (await Promise.all(builds)).filter(b => filterStatus.includes(b.status), - ) as unknown as Build[]; + ); return { builds: promisedBuilds }; } From 0e8b8472fcca6434e39629f793b59cac9fdf88ce Mon Sep 17 00:00:00 2001 From: djamaile Date: Wed, 30 Mar 2022 17:34:33 +0200 Subject: [PATCH 104/144] feat: add trigger reason to give a better overview to the user Signed-off-by: djamaile --- .../src/api/utils.test.ts | 89 +++++++++++++++ .../src/api/utils.ts | 107 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts create mode 100644 plugins/cicd-statistics-module-gitlab/src/api/utils.ts diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts new file mode 100644 index 0000000000..338b92bf4f --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { pipelinesToBuilds, jobsToStages } from './utils'; +import { Types } from '@gitbeaker/core'; + +const pipelineMock: Types.PipelineSchema[] = [ + { + id: 1000, + iid: 1, + project_id: 1, + sha: 'd40', + ref: 'main', + status: 'success', + source: 'schedule', + created_at: '2022-03-30T13:03:09.846Z', + updated_at: '2022-03-30T13:07:49.248Z', + web_url: 'https://gitlab.com/backstage/app/-/pipelines/1000', + user: { + name: 'user', + avatar_url: 'avatar_user', + }, + }, +]; + +// cast to unknown so we can omit a lot unused vars but also keep the type +const jobMock = [ + { + id: 6962883, + status: 'success', + stage: 'build', + name: 'docker', + ref: 'refs/merge-requests/209/train', + tag: false, + allow_failure: false, + created_at: new Date('2022-03-30T08:35:15.394Z'), + started_at: new Date('2022-03-30T08:35:16.532Z'), + finished_at: new Date('2022-03-30T08:35:37.731Z'), + duration: 21.199465, + queued_duration: 0.976313, + }, +] as unknown as Array; + +describe('util functionality', () => { + it('transforms the pipeline object to the build object', () => { + const builds = pipelinesToBuilds(pipelineMock); + expect(builds).toEqual([ + { + id: '1000', + status: 'succeeded', + branchType: 'master', + duration: 0, + requestedAt: new Date('2022-03-30T13:03:09.846Z'), + stages: [], + }, + ]); + }); + + it('transforms the job object to the stage object', () => { + const stages = jobsToStages(jobMock); + expect(stages).toEqual([ + { + name: 'build', + status: 'succeeded', + duration: 21199.465, + stages: [ + { + name: 'docker', + status: 'succeeded', + duration: 21199.465, + }, + ], + }, + ]); + }); +}); diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts new file mode 100644 index 0000000000..acf2910704 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { + Build, + FilterStatusType, + TriggerReason, + Stage, +} from '@backstage/plugin-cicd-statistics'; +import { Types } from '@gitbeaker/core'; + +const statusMap: Record = { + manual: 'unknown', + created: 'enqueued', + waiting_for_resource: 'stalled', + preparing: 'unknown', + pending: 'scheduled', + running: 'running', + success: 'succeeded', + failed: 'failed', + canceled: 'aborted', + skipped: 'aborted', + scheduled: 'scheduled', +}; + +// all gitlab trigger reasons can be found here: https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines +const triggerReasonMap: Record = { + push: 'scm', + trigger: 'manual', + merge_request_event: 'scm', + schedule: 'internal', +}; + +/** + * Takes the Pipeline object from Gitlab and transforms it to the Build object + * + * @param pipelines - Pipeline object that gets returned from Gitlab + * + * @public + */ +export function pipelinesToBuilds( + pipelines: Array, +): Build[] { + return pipelines.map(pipeline => { + return { + id: pipeline.id.toString(), + status: statusMap[pipeline.status], + branchType: 'master', + duration: 0, // will get filled in later in a seperate API call + requestedAt: new Date(pipeline.created_at), + triggeredBy: triggerReasonMap[pipeline.source as string] ?? 'other', + stages: [], + }; + }); +} + +/** + * Takes the Job object from Gitlab and transforms it to the Stage object + * + * @param jobs - Job object that gets returned from Gitlab + * + * @public + * + * @remarks + * + * The Gitlab API can only return the job (sub-stage) of a pipeline and not a whole stage a pipeline + * The job does return from which stage it is + * So, for the stage name we use the parent stage name and in the sub-stages we add the current job + * In the end the cicd-statistics plugin will calculate the right durations for each stage + * + * Furthermore, we don't add the job to the sub-stage if it is has the same name as the parent stage + * We then assume that the stage has no sub-stages + */ +export function jobsToStages(jobs: Array): Stage[] { + return jobs.map(job => { + const status = statusMap[job.status] ? statusMap[job.status] : 'unknown'; + const duration = job.duration ? ((job.duration * 1000) as number) : 0; + return { + name: job.stage, + status, + duration, + stages: + job.name !== job.stage + ? [ + { + name: job.name, + status, + duration, + }, + ] + : [], + }; + }); +} From b891d32604cfa1774a7409c3aa76eae5f013ea16 Mon Sep 17 00:00:00 2001 From: djamaile Date: Wed, 30 Mar 2022 17:39:52 +0200 Subject: [PATCH 105/144] chore: clean up of test variables Signed-off-by: djamaile --- .changeset/proud-news-perform.md | 3 ++- packages/app/src/apis.ts | 11 +---------- packages/app/src/components/catalog/EntityPage.tsx | 5 ----- .../cicd-statistics-module-gitlab/src/api/utils.ts | 2 +- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.changeset/proud-news-perform.md b/.changeset/proud-news-perform.md index 20259c33e5..9d5cbae8ab 100644 --- a/.changeset/proud-news-perform.md +++ b/.changeset/proud-news-perform.md @@ -2,4 +2,5 @@ '@backstage/plugin-cicd-statistics-module-gitlab': minor --- -Will update this once the PR is ready to be merged +Created a module to extract the CI/CD statistics from a Gitlab repository. +Read the `README.md` in the `cicd-statistics-module-gitlab` plugin folder on how to set it up. diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 7b3232f42d..d587a88402 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -33,10 +33,7 @@ import { createApiFactory, errorApiRef, githubAuthApiRef, - gitlabAuthApiRef, } from '@backstage/core-plugin-api'; -import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; -import { CicdStatisticsApiGitlab } from '@backstage/plugin-cicd-statistics-module-gitlab'; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -65,12 +62,6 @@ export const apis: AnyApiFactory[] = [ }), ]), }), - createApiFactory({ - api: cicdStatisticsApiRef, - deps: { gitlabAuthApi: gitlabAuthApiRef }, - factory({ gitlabAuthApi }) { - return new CicdStatisticsApiGitlab(gitlabAuthApi); - }, - }), + createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), ]; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3ade051077..26068c6c8f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -136,7 +136,6 @@ import { EntityNewRelicDashboardCard, } from '@backstage/plugin-newrelic-dashboard'; import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; -import { EntityCicdStatisticsContent } from '@backstage/plugin-cicd-statistics'; import React, { ReactNode, useMemo, useState } from 'react'; @@ -445,10 +444,6 @@ const websiteEntityPage = ( {cicdContent} - - - - diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts index acf2910704..5f1d1cbeba 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/utils.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts @@ -76,7 +76,7 @@ export function pipelinesToBuilds( * * @remarks * - * The Gitlab API can only return the job (sub-stage) of a pipeline and not a whole stage a pipeline + * The Gitlab API can only return the job (sub-stage) of a pipeline and not a whole stage * The job does return from which stage it is * So, for the stage name we use the parent stage name and in the sub-stages we add the current job * In the end the cicd-statistics plugin will calculate the right durations for each stage From 6803844d0ba7decab21fa5570cdc1aef127c5276 Mon Sep 17 00:00:00 2001 From: djamaile Date: Wed, 30 Mar 2022 17:48:02 +0200 Subject: [PATCH 106/144] fix: add CI/CD to vale to prevent build from failing Signed-off-by: djamaile --- .github/styles/vocab.txt | 1 + plugins/cicd-statistics-module-gitlab/README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 4070e5600a..d2257f1b6f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -39,6 +39,7 @@ Changesets chanwit Chanwit ci +CI/CD classname cli cloudbuild diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md index 58c053167f..0b11f6950a 100644 --- a/plugins/cicd-statistics-module-gitlab/README.md +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -1,6 +1,6 @@ # cicd-statistics-module-gitlab -This is an extension module to the `cicd-statistics` plugin, providing a `CicdStatisticsApiGitlab` that you can use to extract the cicd statistics from your Gitlab repository. +This is an extension module to the `cicd-statistics` plugin, providing a `CicdStatisticsApiGitlab` that you can use to extract the CI/CD statistics from your Gitlab repository. ## Getting started From e2735e790233123ab1e5cd4ab19463c2a2fb31a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 9 Apr 2022 16:09:24 +0200 Subject: [PATCH 107/144] fix versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../package.json | 12 ++++----- yarn.lock | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json index f7121a42ae..280282dd04 100644 --- a/plugins/cicd-statistics-module-gitlab/package.json +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -29,16 +29,16 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/plugin-cicd-statistics": "^0.1.0", - "@gitbeaker/browser": "34.2.0", - "@gitbeaker/core": "^35.4.0", + "@backstage/plugin-cicd-statistics": "^0.1.6-next.0", + "@gitbeaker/browser": "^35.6.0", + "@gitbeaker/core": "^35.6.0", "luxon": "^2.0.2", "p-limit": "^4.0.0", - "@backstage/core-plugin-api": "^0.8.0", - "@backstage/catalog-model": "^0.13.0" + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/catalog-model": "^1.0.1-next.1" }, "devDependencies": { - "@backstage/cli": "^0.15.2" + "@backstage/cli": "^0.17.0-next.2" }, "files": [ "dist" diff --git a/yarn.lock b/yarn.lock index e469f8432b..efd79f7ac7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2186,6 +2186,16 @@ resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210" integrity sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw== +"@gitbeaker/browser@^35.6.0": + version "35.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/browser/-/browser-35.6.0.tgz#a639ae57eeb4d4829b73a634f757ef5841ad4116" + integrity sha512-cMkMG3r1HfVV7AUxVfp+PzDpISJYDQK5TnFfVT9vwVfW2uUDYALxMWYFwBLPkn3otqrOY1TkHXl/I9WgwsZMfg== + dependencies: + "@gitbeaker/core" "^35.6.0" + "@gitbeaker/requester-utils" "^35.6.0" + delay "^5.0.0" + ky "0.28.7" + "@gitbeaker/core@^35.5.0", "@gitbeaker/core@^35.6.0": version "35.6.0" resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-35.6.0.tgz#c46e15986081ef1e647434c450964eed5fb0e390" @@ -16369,6 +16379,11 @@ kuler@^2.0.0: resolved "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== +ky@0.28.7: + version "0.28.7" + resolved "https://registry.npmjs.org/ky/-/ky-0.28.7.tgz#10c42be863fb96c1846d6e71e229263ffb72eb15" + integrity sha512-a23i6qSr/ep15vdtw/zyEQIDLoUaKDg9Jf04CYl/0ns/wXNYna26zJpI+MeIFaPeDvkrjLPrKtKOiiI3IE53RQ== + language-subtag-registry@~0.3.2: version "0.3.21" resolved "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" @@ -19163,6 +19178,13 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" + integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== + dependencies: + yocto-queue "^1.0.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -25849,6 +25871,11 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yocto-queue@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" + integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + yup@^0.32.9: version "0.32.11" resolved "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" From 564724023795523030add1a8327e0cb9610fb6ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:12:39 +0000 Subject: [PATCH 108/144] build(deps): bump @testing-library/jest-dom from 5.16.3 to 5.16.4 Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.16.3 to 5.16.4. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v5.16.3...v5.16.4) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..d0224678e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5546,9 +5546,9 @@ pretty-format "^27.0.2" "@testing-library/jest-dom@^5.10.1": - version "5.16.3" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.3.tgz#b76851a909586113c20486f1679ffb4d8ec27bfa" - integrity sha512-u5DfKj4wfSt6akfndfu1eG06jsdyA/IUrlX2n3pyq5UXgXMhXY+NJb8eNK/7pqPWAhCKsCGWDdDO0zKMKAYkEA== + version "5.16.4" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd" + integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" From 2492b135e0d5284a4680580bb800e4e7145beff7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:13:17 +0000 Subject: [PATCH 109/144] build(deps): bump selfsigned from 2.0.0 to 2.0.1 Bumps [selfsigned](https://github.com/jfromaniello/selfsigned) from 2.0.0 to 2.0.1. - [Release notes](https://github.com/jfromaniello/selfsigned/releases) - [Commits](https://github.com/jfromaniello/selfsigned/compare/v2.0.0...v2.0.1) --- updated-dependencies: - dependency-name: selfsigned dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..2ae061d080 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18425,10 +18425,10 @@ node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node- dependencies: whatwg-url "^5.0.0" -node-forge@^1.0.0, node-forge@^1.2.0: - version "1.3.0" - resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.0.tgz#37a874ea723855f37db091e6c186e5b67a01d4b2" - integrity sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA== +node-forge@^1, node-forge@^1.0.0: + version "1.3.1" + resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== node-gyp@^5.0.2: version "5.1.0" @@ -22260,11 +22260,11 @@ select-hose@^2.0.0: integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= selfsigned@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.0.tgz#e927cd5377cbb0a1075302cff8df1042cc2bce5b" - integrity sha512-cUdFiCbKoa1mZ6osuJs2uDHrs0k0oprsKveFiiaBKCNq3SYyb5gs2HxhQyDNLCmL51ZZThqi4YNDpCK6GOP1iQ== + version "2.0.1" + resolved "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56" + integrity sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ== dependencies: - node-forge "^1.2.0" + node-forge "^1" semver-diff@^3.1.1: version "3.1.1" From 652c87a4839b829b896c1fb1615dac0ba195b266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:30:24 +0000 Subject: [PATCH 110/144] build(deps-dev): bump @types/supertest from 2.0.11 to 2.0.12 Bumps [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) from 2.0.11 to 2.0.12. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest) --- updated-dependencies: - dependency-name: "@types/supertest" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..a511002bea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6830,9 +6830,9 @@ "@types/node" "*" "@types/supertest@^2.0.8": - version "2.0.11" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz#2e70f69f220bc77b4f660d72c2e1a4231f44a77d" - integrity sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q== + version "2.0.12" + resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz#ddb4a0568597c9aadff8dbec5b2e8fddbe8692fc" + integrity sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ== dependencies: "@types/superagent" "*" From e1648038ccf235768d2ea7470f8b204fcde58aa2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:31:06 +0000 Subject: [PATCH 111/144] build(deps-dev): bump @changesets/cli from 2.21.0 to 2.22.0 Bumps [@changesets/cli](https://github.com/changesets/changesets) from 2.21.0 to 2.22.0. - [Release notes](https://github.com/changesets/changesets/releases) - [Changelog](https://github.com/changesets/changesets/blob/main/docs/modifying-changelog-format.md) - [Commits](https://github.com/changesets/changesets/compare/@changesets/cli@2.21.0...@changesets/cli@2.22.0) --- updated-dependencies: - dependency-name: "@changesets/cli" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 166 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 86 insertions(+), 80 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..620a03c6b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1617,16 +1617,16 @@ resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.0.tgz#fe364f025ba74f6de6c837a84ef44bdb1d61e68f" integrity sha512-mgmE7XBYY/21erpzhexk4Cj1cyTQ9LzvnTxtzM17BJ7ERMNE6W72mQRo0I1Ud8eFJ+RVVIcBNhLFZ3GX4XFz5w== -"@changesets/apply-release-plan@^5.0.5": - version "5.0.5" - resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-5.0.5.tgz#d67b1e022c876d18d887f3c475a3abcad9944b68" - integrity sha512-CxL9dkhzjHiVmXCyHgsLCQj7i/coFTMv/Yy0v6BC5cIWZkQml+lf7zvQqAcFXwY7b54HxRWZPku02XFB53Q0Uw== +"@changesets/apply-release-plan@^6.0.0": + version "6.0.0" + resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-6.0.0.tgz#6c663ff99d919bba3902343d76c35cbbbb046520" + integrity sha512-gp6nIdVdfYdwKww2+f8whckKmvfE4JEm4jJgBhTmooi0uzHWhnxvk6JIzQi89qEAMINN0SeVNnXiAtbFY0Mj3w== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/config" "^1.7.0" + "@changesets/config" "^2.0.0" "@changesets/get-version-range-type" "^0.3.2" - "@changesets/git" "^1.3.1" - "@changesets/types" "^4.1.0" + "@changesets/git" "^1.3.2" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" detect-indent "^6.0.0" fs-extra "^7.0.1" @@ -1636,44 +1636,44 @@ resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^5.1.0": - version "5.1.0" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-5.1.0.tgz#0fcb18253998e3bc037a554874de43bcc58c4840" - integrity sha512-iYlqffCMhcwZ+6Cv8cimf10OBGYXQKufBI7J6htpRgCV2nT99RKXEjbYOtrXWKQqzu0XxOsk15apSEwjZN0JRw== +"@changesets/assemble-release-plan@^5.1.2": + version "5.1.2" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-5.1.2.tgz#63ed3a00f62b5af08a82e83801a252ac9726c625" + integrity sha512-nOFyDw4APSkY/vh5WNwGEtThPgEjVShp03PKVdId6wZTJALVcAALCSLmDRfeqjE2z9EsGJb7hZdDlziKlnqZgw== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" - "@changesets/types" "^4.1.0" + "@changesets/get-dependents-graph" "^1.3.2" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" semver "^5.4.1" -"@changesets/changelog-git@^0.1.10": - version "0.1.10" - resolved "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.1.10.tgz#df616e92671082a7976381280b4af98ff3a7067d" - integrity sha512-4t7zqPOv3aDZp4Y+AyDhiOG2ypaUXDpOz+MT1wOk3uSZNv78AaDByam0hdk5kfYuH1RlMecWU4/U5lO1ZL5eaA== +"@changesets/changelog-git@^0.1.11": + version "0.1.11" + resolved "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.1.11.tgz#80eb45d3562aba2164f25ccc31ac97b9dcd1ded3" + integrity sha512-sWJvAm+raRPeES9usNpZRkooeEB93lOpUN0Lmjz5vhVAb7XGIZrHEJ93155bpE1S0c4oJ5Di9ZWgzIwqhWP/Wg== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@changesets/cli@^2.14.0": - version "2.21.0" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.21.0.tgz#b689f91ed908150efc06e0985e6b4cfbd9aea3a3" - integrity sha512-cJXRg28MmF9VbQrlwSjpY4AJA2xZUbXFCpQ3kFmX0IeppO7wknZ2QfocAhIqwM828t8d3R4Zpi5xnvJ/crIcQw== + version "2.22.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.22.0.tgz#3bdbfb4a7a81ef37f63114e77da84e23f906b763" + integrity sha512-4bA3YoBkd5cm5WUxmrR2N9WYE7EeQcM+R3bVYMUj2NvffkQVpU3ckAI+z8UICoojq+HRl2OEwtz+S5UBmYY4zw== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/apply-release-plan" "^5.0.5" - "@changesets/assemble-release-plan" "^5.1.0" - "@changesets/changelog-git" "^0.1.10" - "@changesets/config" "^1.7.0" + "@changesets/apply-release-plan" "^6.0.0" + "@changesets/assemble-release-plan" "^5.1.2" + "@changesets/changelog-git" "^0.1.11" + "@changesets/config" "^2.0.0" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" - "@changesets/get-release-plan" "^3.0.6" - "@changesets/git" "^1.3.1" + "@changesets/get-dependents-graph" "^1.3.2" + "@changesets/get-release-plan" "^3.0.8" + "@changesets/git" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/pre" "^1.0.10" - "@changesets/read" "^0.5.4" - "@changesets/types" "^4.1.0" - "@changesets/write" "^0.1.7" + "@changesets/pre" "^1.0.11" + "@changesets/read" "^0.5.5" + "@changesets/types" "^5.0.0" + "@changesets/write" "^0.1.8" "@manypkg/get-packages" "^1.1.3" "@types/is-ci" "^3.0.0" "@types/semver" "^6.0.0" @@ -1687,20 +1687,21 @@ outdent "^0.5.0" p-limit "^2.2.0" preferred-pm "^3.0.0" + resolve-from "^5.0.0" semver "^5.4.1" spawndamnit "^2.0.0" term-size "^2.1.0" tty-table "^2.8.10" -"@changesets/config@^1.7.0": - version "1.7.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.7.0.tgz#18353f88ea8153d7f1fb7c321a3fe8667035eddb" - integrity sha512-Ctk6ZO5Ay6oZ95bbKXyA2a1QG0jQUePaGCY6BKkZtUG4PgysesfmiQOPgOY5OsRMt8exJeo6l+DJ75YiKmh0rQ== +"@changesets/config@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-2.0.0.tgz#1770fdfeba2155cf07154c37e96b55cbd27969f0" + integrity sha512-r5bIFY6CN3K6SQ+HZbjyE3HXrBIopONR47mmX7zUbORlybQXtympq9rVAOzc0Oflbap8QeIexc+hikfZoREXDg== dependencies: "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" + "@changesets/get-dependents-graph" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" fs-extra "^7.0.1" micromatch "^4.0.2" @@ -1712,28 +1713,28 @@ dependencies: extendable-error "^0.1.5" -"@changesets/get-dependents-graph@^1.3.1": - version "1.3.1" - resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.3.1.tgz#f1ebadbd4e17bb2b987c4542a588e0ee9f2e829a" - integrity sha512-HwUs8U0XK/ZqCQon1/80jJEyswS8JVmTiHTZslrTpuavyhhhxrSpO1eVCdKgaVHBRalOw3gRzdS3uzkmqYsQSQ== +"@changesets/get-dependents-graph@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.3.2.tgz#f3ec7ce75f4afb6e3e4b6a87fde065f552c85998" + integrity sha512-tsqA6qZRB86SQuApSoDvI8yEWdyIlo/WLI4NUEdhhxLMJ0dapdeT6rUZRgSZzK1X2nv5YwR0MxQBbDAiDibKrg== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" chalk "^2.1.0" fs-extra "^7.0.1" semver "^5.4.1" -"@changesets/get-release-plan@^3.0.6": - version "3.0.6" - resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-3.0.6.tgz#15aac108b9d0f139841562c9372d8cfd738503dc" - integrity sha512-HpPyr8y6xkihy3rONLZ6OtfgYq88NotidPAuS3nwMeZjLHiIVLyejR2+/5q717f6HKcrATxAjTwMAcjl7X/uzA== +"@changesets/get-release-plan@^3.0.8": + version "3.0.8" + resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-3.0.8.tgz#2ac7c4f7903aedede3d27af66311ad1db7937e5d" + integrity sha512-TJYiWNuP0Lzu2dL/KHuk75w7TkiE5HqoYirrXF7SJIxkhlgH9toQf2C7IapiFTObtuF1qDN8HJAX1CuIOwXldg== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/assemble-release-plan" "^5.1.0" - "@changesets/config" "^1.7.0" - "@changesets/pre" "^1.0.10" - "@changesets/read" "^0.5.4" - "@changesets/types" "^4.1.0" + "@changesets/assemble-release-plan" "^5.1.2" + "@changesets/config" "^2.0.0" + "@changesets/pre" "^1.0.11" + "@changesets/read" "^0.5.5" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" "@changesets/get-version-range-type@^0.3.2": @@ -1741,14 +1742,14 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.3.1": - version "1.3.1" - resolved "https://registry.npmjs.org/@changesets/git/-/git-1.3.1.tgz#e86b4d2b28acdf9bc8949031027a9ac12420b99e" - integrity sha512-yg60QUi38VA0XGXdBy9SRYJhs8xJHE97Z1CaB/hFyByBlh5k1i+avFNBvvw66MsoT/aiml6y9scIG6sC8R5mfg== +"@changesets/git@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@changesets/git/-/git-1.3.2.tgz#336051d9a6d965806b1bc473559a9a2cc70773a6" + integrity sha512-p5UL+urAg0Nnpt70DLiBe2iSsMcDubTo9fTOD/61krmcJ466MGh71OHwdAwu1xG5+NKzeysdy1joRTg8CXcEXA== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" is-subdir "^1.1.1" spawndamnit "^2.0.0" @@ -1760,51 +1761,56 @@ dependencies: chalk "^2.1.0" -"@changesets/parse@^0.3.12": - version "0.3.12" - resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.12.tgz#60569bb39ad4ffe47fc01d431613ce5c42e6590f" - integrity sha512-FOBz2L1dT9PcvyQU1Qp2sQ0B4Jw7EgRDAKFVzAQwhzXqCq03TcE7vgKU6VSksCJAioMYDowdVVHNnv/Uak6yZQ== +"@changesets/parse@^0.3.13": + version "0.3.13" + resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.13.tgz#82788c1fc18da4750b07357a7a06142d0d975aa1" + integrity sha512-wh9Ifa0dungY6d2nMz6XxF6FZ/1I7j+mEgPAqrIyKS64nifTh1Ua82qKKMMK05CL7i4wiB2NYc3SfnnCX3RVeA== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" js-yaml "^3.13.1" -"@changesets/pre@^1.0.10": - version "1.0.10" - resolved "https://registry.npmjs.org/@changesets/pre/-/pre-1.0.10.tgz#e677031f271cdab8443b21e0b3cda036a3919c30" - integrity sha512-cZC1C1wTSC17/TcTWivAQ4LAXz5jEYDuy3UeZiBz1wnTTzMHyTHLLwJi60juhl4hawXunDLw0mwZkcpS8Ivitg== +"@changesets/pre@^1.0.11": + version "1.0.11" + resolved "https://registry.npmjs.org/@changesets/pre/-/pre-1.0.11.tgz#46a56790fdceabd03407559bbf91340c8e83fb6a" + integrity sha512-CXZnt4SV9waaC9cPLm7818+SxvLKIDHUxaiTXnJYDp1c56xIexx1BNfC1yMuOdzO2a3rAIcZua5Odxr3dwSKfg== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" fs-extra "^7.0.1" -"@changesets/read@^0.5.4": - version "0.5.4" - resolved "https://registry.npmjs.org/@changesets/read/-/read-0.5.4.tgz#c6dc6ab00c4f70f2ce6766433d92eebde1b00e7a" - integrity sha512-12dTx+p5ztFs9QgJDGHRHR6HzTIbHct9S4lK2I/i6Qkz1cNfAPVIbdoMCdbPIWeLank9muMUjiiFmCWJD7tQIg== +"@changesets/read@^0.5.5": + version "0.5.5" + resolved "https://registry.npmjs.org/@changesets/read/-/read-0.5.5.tgz#9ed90ef3e9f1ba3436ba5580201854a3f4163058" + integrity sha512-bzonrPWc29Tsjvgh+8CqJ0apQOwWim0zheeD4ZK44ApSa/GudnZJTODtA3yNOOuQzeZmL0NUebVoHIurtIkA7w== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/git" "^1.3.1" + "@changesets/git" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/parse" "^0.3.12" - "@changesets/types" "^4.1.0" + "@changesets/parse" "^0.3.13" + "@changesets/types" "^5.0.0" chalk "^2.1.0" fs-extra "^7.0.1" p-filter "^2.1.0" -"@changesets/types@^4.0.1", "@changesets/types@^4.1.0": +"@changesets/types@^4.0.1": version "4.1.0" resolved "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz#fb8f7ca2324fd54954824e864f9a61a82cb78fe0" integrity sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw== -"@changesets/write@^0.1.7": - version "0.1.7" - resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.7.tgz#671def0d871cf5970c5b2f766f0ac4b19ecf1ddb" - integrity sha512-6r+tc6u2l5BBIwEAh7ivRYWFir+XKiw0q/6Hx6NJA4dSN5fNu9uyWRQ+IMHCllD9dBcsh+e79sOepc+xT8l28g== +"@changesets/types@^5.0.0": + version "5.0.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-5.0.0.tgz#d5eb52d074bc0358ce47d54bca54370b907812a0" + integrity sha512-IT1kBLSbAgTS4WtpU6P5ko054hq12vk4tgeIFRVE7Vnm4a/wgbNvBalgiKP0MjEXbCkZbItiGQHkCGxYWR55sA== + +"@changesets/write@^0.1.8": + version "0.1.8" + resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.8.tgz#feed408f644c496bc52afc4dd1353670b4152ecb" + integrity sha512-oIHeFVMuP6jf0TPnKPpaFpvvAf3JBc+s2pmVChbeEgQTBTALoF51Z9kqxQfG4XONZPHZnqkmy564c7qohhhhTQ== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" fs-extra "^7.0.1" human-id "^1.0.2" prettier "^1.19.1" From 683dfa0c6403d4b1d87368bb835aa753e30dc641 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:39:10 +0000 Subject: [PATCH 112/144] build(deps-dev): bump @storybook/addon-links in /storybook Bumps [@storybook/addon-links](https://github.com/storybookjs/storybook/tree/HEAD/addons/links) from 6.4.20 to 6.4.21. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v6.4.21/addons/links) --- updated-dependencies: - dependency-name: "@storybook/addon-links" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- storybook/package.json | 2 +- storybook/yarn.lock | 117 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/storybook/package.json b/storybook/package.json index d5b54d193d..87c627ac4f 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@storybook/addon-a11y": "^6.4.20", "@storybook/addon-actions": "^6.4.20", - "@storybook/addon-links": "^6.4.20", + "@storybook/addon-links": "^6.4.21", "@storybook/addon-storysource": "^6.4.20", "@storybook/addons": "^6.4.20", "@storybook/react": "^6.4.20", diff --git a/storybook/yarn.lock b/storybook/yarn.lock index 82520ab5f5..fb7aa071fe 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -1356,16 +1356,16 @@ util-deprecate "^1.0.2" uuid-browser "^3.1.0" -"@storybook/addon-links@^6.4.20": - version "6.4.20" - resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.4.20.tgz#7e845a20deece65e7e684433d4c66a6ad61da52c" - integrity sha512-TyRuEd/3yRn2N9xasCKuE2bsY0dTRjAquGeg5WEtvHvr8V6QBLYAC4caXwPxIHSTcRQyO5IYYiVzEJ/+219neA== +"@storybook/addon-links@^6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.4.21.tgz#7251406c3060b63684f4de56799385f3675867c6" + integrity sha512-KajbsVAmCLVSKsrPnUEsfWuD5V0lbNBAtdil0EiOqWZU0r3ch92aSMh6H13zfT+lEPlh0PVLKamHur1js1iXGQ== dependencies: - "@storybook/addons" "6.4.20" - "@storybook/client-logger" "6.4.20" - "@storybook/core-events" "6.4.20" + "@storybook/addons" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" "@storybook/csf" "0.0.2--canary.87bc651.0" - "@storybook/router" "6.4.20" + "@storybook/router" "6.4.21" "@types/qs" "^6.9.5" core-js "^3.8.2" global "^4.4.0" @@ -1394,7 +1394,7 @@ react-syntax-highlighter "^13.5.3" regenerator-runtime "^0.13.7" -"@storybook/addons@6.4.20", "@storybook/addons@^6.4.20": +"@storybook/addons@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.20.tgz#bbf568b7c4c5a25ef296f285aef0299998ec5933" integrity sha512-NbsLjDSkE9v2fOr0M7r2hpdYnlYs789ALkXemdTz2y0NUYSPdRfzVVQNXWrgmXivWQRL0aJ3bOjCOc668PPYjg== @@ -1411,6 +1411,23 @@ global "^4.4.0" regenerator-runtime "^0.13.7" +"@storybook/addons@6.4.21", "@storybook/addons@^6.4.20": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.21.tgz#a0081d167eda8a30b2206ccabfe75abae0bb6b58" + integrity sha512-TFLv4FyqP5SBOHEqE6tiW+2++HngkyQ2KRbHICC7khQgRqDkrwvrdKZwzF29igseglhSmftpZrBLXyWbA7q1vg== + dependencies: + "@storybook/api" "6.4.21" + "@storybook/channels" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.21" + "@storybook/theming" "6.4.21" + "@types/webpack-env" "^1.16.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + "@storybook/api@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.20.tgz#65da720985b4b46998a405bddc42c9cef9bad7e4" @@ -1434,6 +1451,29 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/api@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.21.tgz#efee41ae7bde37f6fe43ee960fef1a261b1b1dd6" + integrity sha512-AULsLd7ew11IRCpzffyLFGl5cwt9BLMok33DcIlCyvXsiqLm4/OsbgM4sj6QqWVuxcFlWMQJHoRJyeFlULFvZA== + dependencies: + "@storybook/channels" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.21" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.4.21" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.12.0" + telejson "^5.3.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/builder-webpack4@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.4.20.tgz#e3b5d6b665fbf5a1ec75b7ef32c4c811897ef20d" @@ -1542,6 +1582,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/channels@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.4.21.tgz#0f1924963f77ec0c3d82aa643a246824ca9f5fca" + integrity sha512-qgy8z3Hp04Q4p+E/8V9MamYYJLW8z1uv1Z+rvosNkg+eAApPg+Qe08BSj59OAUwPLrr2vpBW7WZ/BYSieW1tUg== + dependencies: + core-js "^3.8.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-api@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.4.20.tgz#17a24af4bc047f7a6de647b9c1844ab4e40baf83" @@ -1576,6 +1625,14 @@ core-js "^3.8.2" global "^4.4.0" +"@storybook/client-logger@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.4.21.tgz#7df21cec4d5426669e828af59232ec44ea19c81a" + integrity sha512-XkVCQ5swyYDVh5U+87DGRBdC5utJBpVW7kU5P14TQKMnSc/yHbMcXWaA89K8WKDa/WGkGbc0bKi4WrUwHFg2FA== + dependencies: + core-js "^3.8.2" + global "^4.4.0" + "@storybook/components@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.4.20.tgz#d063b6a7e70e1be7c8aa79220bb2cd92be8057a1" @@ -1694,6 +1751,13 @@ dependencies: core-js "^3.8.2" +"@storybook/core-events@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.21.tgz#28fff8b10c0d564259edf4439ff8677615ce59c0" + integrity sha512-K6b9M1zYvW/Kfb1cnH6JDfmFvTYDMx/ot9zdl9O5SPH9glUwzOXSk8qKu6GmZTiW2YnC2nKbjaN20mfMsCBPGw== + dependencies: + core-js "^3.8.2" + "@storybook/core-server@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.4.20.tgz#6bdf6dd5d83713034df950a98f7638e23c64171c" @@ -1915,6 +1979,23 @@ react-router-dom "^6.0.0" ts-dedent "^2.0.0" +"@storybook/router@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.4.21.tgz#a18172601907918c1442a8a125c9c625d798d09b" + integrity sha512-otn3xYc017SNebeA95xLQ7P6elfyu9541QteXbLR5gFvrT+MB/8zMRZrVuD7n1xwpBgazlonzAdODC736Be9jQ== + dependencies: + "@storybook/client-logger" "6.4.21" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + history "5.0.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + qs "^6.10.0" + react-router "^6.0.0" + react-router-dom "^6.0.0" + ts-dedent "^2.0.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -1978,6 +2059,24 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" +"@storybook/theming@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.21.tgz#ea1a33be70c654cb31e5b38fae93f72171e88ef8" + integrity sha512-7pLNwmqbyqCeHXzjsacI69IdJcAZr6zoZA84iGqx+Na32OI8wtIpFczbwuYpVPN2jzgRYp23CgIv1Gz27yk/zw== + dependencies: + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.27" + "@storybook/client-logger" "6.4.21" + core-js "^3.8.2" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.27" + global "^4.4.0" + memoizerific "^1.11.3" + polished "^4.0.5" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + "@storybook/ui@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.4.20.tgz#30e8fba0877b66000841046133d3dc098a807d13" From 4b875fd55bdc2e80d89a163e56d778f9e99f1d2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:39:46 +0000 Subject: [PATCH 113/144] build(deps): bump graphiql from 1.8.3 to 1.8.4 Bumps [graphiql](https://github.com/graphql/graphiql) from 1.8.3 to 1.8.4. - [Release notes](https://github.com/graphql/graphiql/releases) - [Changelog](https://github.com/graphql/graphiql/blob/main/CHANGELOG.md) - [Commits](https://github.com/graphql/graphiql/compare/graphiql@1.8.3...graphql-language-service-types@1.8.4) --- updated-dependencies: - dependency-name: graphiql dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index e469f8432b..4919937660 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2296,10 +2296,10 @@ stream-events "^1.0.4" xdg-basedir "^4.0.0" -"@graphiql/toolkit@^0.4.2": - version "0.4.2" - resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.2.tgz#34de819add64672f3f7d4830dffb2094fb8d5366" - integrity sha512-14uG67QrONbRrhXwvBJFsMfcQfexmGhj7dgkputesx9xuPUkcCDNmVULnVA8sGYt8P/rSvjkfQYx3rtfW+GhAQ== +"@graphiql/toolkit@^0.4.3": + version "0.4.3" + resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.3.tgz#2653d045693d902f7faaf87b9047e8786397ec48" + integrity sha512-L0l6BezvTXmWZhtdmZxirhYwKzcZToAciQY0A13KRAWSpJ9bb/ZdkBpcz3fOXrsjuJHf0wBr6Vk9kztJ6lV7uA== dependencies: "@n1ru4l/push-pull-async-iterable-iterator" "^3.1.0" meros "^1.1.4" @@ -13535,11 +13535,11 @@ grapheme-splitter@^1.0.4: integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== graphiql@^1.5.12: - version "1.8.3" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.8.3.tgz#4696755cbba851c93aea9deacbcf4229044a705d" - integrity sha512-3qb3jdlzg8nqQCRMnch6lG11royrE6etP2eoDKLxdrSpdaxI0PgNNttbMAJJYfsqEJ0XHcXtUDt1jH6jkKOK4Q== + version "1.8.4" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.8.4.tgz#3a447eae85d839eb207a07fc3aabc6f3a10df46d" + integrity sha512-mEYtfympvlZ4E1VMEMZ0BG3NCLA9L69EEbnpHpAA12oWz3aul4HXWl59JS0aHZftyiPx1/gHjSsjctkhbazT9g== dependencies: - "@graphiql/toolkit" "^0.4.2" + "@graphiql/toolkit" "^0.4.3" codemirror "^5.58.2" codemirror-graphql "^1.2.15" copy-to-clipboard "^3.2.0" From 2fc0e86616246b02aa1f7812633ec294fbe272c5 Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Fri, 8 Apr 2022 16:30:54 +0200 Subject: [PATCH 114/144] Move refresh handling into KubernetesClustersSupplier implementations Signed-off-by: Luna Stadler --- .changeset/hot-items-smoke.md | 31 +++++++++++-------- docs/features/kubernetes/installation.md | 29 ++++++++++------- plugins/kubernetes-backend/api-report.md | 10 +++--- plugins/kubernetes-backend/package.json | 6 ++-- .../cluster-locator/ConfigClusterLocator.ts | 2 -- .../cluster-locator/GkeClusterLocator.test.ts | 7 +---- .../src/cluster-locator/GkeClusterLocator.ts | 24 ++++++++++++-- .../src/cluster-locator/index.test.ts | 1 - .../src/cluster-locator/index.ts | 20 ++++++------ .../MultiTenantServiceLocator.test.ts | 3 -- .../src/service/KubernetesBuilder.test.ts | 2 -- .../src/service/KubernetesBuilder.ts | 30 ++++++++---------- plugins/kubernetes-backend/src/types/types.ts | 11 ++----- 13 files changed, 93 insertions(+), 83 deletions(-) diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md index 52c5088d3e..c9b9c8a135 100644 --- a/.changeset/hot-items-smoke.md +++ b/.changeset/hot-items-smoke.md @@ -10,7 +10,7 @@ the `getClusters` method is now called whenever the list of clusters is needed. Existing `KubernetesClustersSupplier` implementations will need to ensure that `getClusters` can be called frequently and should return a cached result from `getClusters` instead. -For example, here's a simple example of this in `packages/backend/src/plugins/kubernetes.ts`: +For example, here's a simple example of a custom supplier in `packages/backend/src/plugins/kubernetes.ts`: ```diff -import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; @@ -21,9 +21,20 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; - ++import { Duration } from 'luxon'; ++ +export class CustomClustersSupplier implements KubernetesClustersSupplier { -+ private clusterDetails: ClusterDetails[] = []; ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } + + async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere @@ -33,7 +44,7 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku + return this.clusterDetails; + } +} -+ + export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -43,14 +54,8 @@ For example, here's a simple example of this in `packages/backend/src/plugins/ku config: env.config, - }).build(); + }); -+ -+ const clusterSupplier = new CustomClustersSupplier(); -+ builder.setClusterSupplier(clusterSupplier); -+ ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); + const { router } = await builder.build(); - return router; - } ``` - -If you need to adjust the refresh interval from the default once per hour -you can call `builder.setClusterRefreshInterval`. diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 4b4ba1139d..e920bd44ea 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -108,9 +108,20 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: +} from '@backstage/plugin-kubernetes-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; - ++import { Duration } from 'luxon'; ++ +export class CustomClustersSupplier implements KubernetesClustersSupplier { -+ private clusterDetails: ClusterDetails[] = []; ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } + + async refreshClusters(): Promise { + this.clusterDetails = []; // fetch from somewhere @@ -120,7 +131,7 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: + return this.clusterDetails; + } +} -+ + export default async function createPlugin( env: PluginEnvironment, ): Promise { @@ -130,18 +141,12 @@ Change the following in `packages/backend/src/plugin/kubernetes.ts`: config: env.config, - }).build(); + }); -+ -+ const clusterSupplier = new CustomClustersSupplier(); -+ builder.setClusterSupplier(clusterSupplier); -+ ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); + const { router } = await builder.build(); - return router; - } ``` -If you need to adjust the refresh interval from the default once per hour -you can call `builder.setClusterRefreshInterval`. - ## Running Backstage locally Start the frontend and the backend app by diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 573198acf0..691b9443e5 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; import type { JsonObject } from '@backstage/types'; @@ -85,7 +86,9 @@ export class KubernetesBuilder { // (undocumented) build(): KubernetesBuilderReturn; // (undocumented) - protected buildClusterSupplier(): KubernetesClustersSupplier; + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier; // (undocumented) protected buildCustomResources(): CustomResource[]; // (undocumented) @@ -125,10 +128,10 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) - setClusterRefreshInterval(refreshMs: number): this; - // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) + setDefaultClusterRefreshInterval(refreshInterval: Duration): this; + // (undocumented) setFetcher(fetcher?: KubernetesFetcher): this; // (undocumented) setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this; @@ -151,7 +154,6 @@ export type KubernetesBuilderReturn = Promise<{ // @public (undocumented) export interface KubernetesClustersSupplier { getClusters(): Promise; - refreshClusters(): Promise; } // Warning: (ae-missing-release-tag) "KubernetesEnvironment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index a55ec70780..f5eb974431 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -43,6 +43,7 @@ "@google-cloud/container": "^3.0.0", "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", + "@types/luxon": "^2.0.4", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", @@ -52,6 +53,7 @@ "fs-extra": "10.0.1", "helmet": "^5.0.2", "lodash": "^4.17.21", + "luxon": "^2.0.2", "morgan": "^1.10.0", "stream-buffers": "^3.0.2", "winston": "^3.2.1", @@ -60,8 +62,8 @@ "devDependencies": { "@backstage/cli": "^0.17.0-next.1", "@types/aws4": "^1.5.1", - "supertest": "^6.1.3", - "aws-sdk-mock": "^5.2.1" + "aws-sdk-mock": "^5.2.1", + "supertest": "^6.1.3" }, "files": [ "dist", diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 893db0f5d0..1bde1226dd 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -77,8 +77,6 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { ); } - async refreshClusters(): Promise {} - async getClusters(): Promise { return this.clusterDetails; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 51062066da..6bef8e0009 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -69,7 +69,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([]); @@ -101,7 +100,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -139,7 +137,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -182,7 +179,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ @@ -221,7 +217,7 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await expect(sut.refreshClusters()).rejects.toThrow( + await expect(sut.getClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', ); @@ -254,7 +250,6 @@ describe('GkeClusterLocator', () => { listClusters: mockedListClusters, } as any); - await sut.refreshClusters(); const result = await sut.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 8484f5006b..0eb25c2acd 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -17,6 +17,8 @@ import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; +import { Duration } from 'luxon'; +import { runPeriodically } from '../service/runPeriodically'; import { ClusterDetails, GKEClusterDetails, @@ -36,11 +38,13 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, private clusterDetails: GKEClusterDetails[] | undefined = undefined, + private hasClusterDetails: boolean = false, ) {} static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, + refreshInterval: Duration | undefined = undefined, ): GkeClusterLocator { const options = { projectId: config.getString('projectId'), @@ -50,17 +54,32 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { config.getOptionalBoolean('skipMetricsLookup') ?? false, exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, }; - return new GkeClusterLocator(options, client); + const gkeClusterLocator = new GkeClusterLocator(options, client); + if (refreshInterval) { + runPeriodically( + () => gkeClusterLocator.refreshClusters(), + refreshInterval.toMillis(), + ); + } + return gkeClusterLocator; } - static fromConfig(config: Config): GkeClusterLocator { + static fromConfig( + config: Config, + refreshInterval: Duration | undefined = undefined, + ): GkeClusterLocator { return GkeClusterLocator.fromConfigWithClient( config, new container.v1.ClusterManagerClient(), + refreshInterval, ); } async getClusters(): Promise { + if (!this.hasClusterDetails) { + // refresh at least once when first called, when retries are disabled and in tests + await this.refreshClusters(); + } return this.clusterDetails ?? []; } @@ -97,6 +116,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } : {}), })); + this.hasClusterDetails = true; } catch (e) { throw new ForwardedError( `There was an error retrieving clusters from GKE for projectId=${projectId} region=${region}`, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 2bdc37c31c..cc7e7d07d6 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -46,7 +46,6 @@ describe('getCombinedClusterSupplier', () => { ); const clusterSupplier = getCombinedClusterSupplier(config); - await clusterSupplier.refreshClusters(); const result = await clusterSupplier.getClusters(); expect(result).toStrictEqual([ diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index fec92d06d7..53aeb44f8b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,18 +15,16 @@ */ import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; class CombinedClustersSupplier implements KubernetesClustersSupplier { - constructor( - readonly clusterSuppliers: KubernetesClustersSupplier[], - private clusterDetails: ClusterDetails[] | undefined = undefined, - ) {} + constructor(readonly clusterSuppliers: KubernetesClustersSupplier[]) {} - async refreshClusters(): Promise { - this.clusterDetails = await Promise.all( + async getClusters(): Promise { + return await Promise.all( this.clusterSuppliers.map(supplier => supplier.getClusters()), ) .then(res => { @@ -36,14 +34,11 @@ class CombinedClustersSupplier implements KubernetesClustersSupplier { throw e; }); } - - async getClusters(): Promise { - return this.clusterDetails ?? []; - } } export const getCombinedClusterSupplier = ( rootConfig: Config, + refreshInterval: Duration | undefined = undefined, ): KubernetesClustersSupplier => { const clusterSuppliers = rootConfig .getConfigArray('kubernetes.clusterLocatorMethods') @@ -53,7 +48,10 @@ export const getCombinedClusterSupplier = ( case 'config': return ConfigClusterLocator.fromConfig(clusterLocatorMethod); case 'gke': - return GkeClusterLocator.fromConfig(clusterLocatorMethod); + return GkeClusterLocator.fromConfig( + clusterLocatorMethod, + refreshInterval, + ); default: throw new Error( `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index 974f554f45..0dce2305bf 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -20,7 +20,6 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => [], }); @@ -31,7 +30,6 @@ describe('MultiTenantConfigClusterLocator', () => { it('one clusters returns one cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => { return [ { @@ -58,7 +56,6 @@ describe('MultiTenantConfigClusterLocator', () => { it('two clusters returns two cluster details', async () => { const sut = new MultiTenantServiceLocator({ - refreshClusters: async () => {}, getClusters: async () => { return [ { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index c3afa4fb6a..37dad8c3e4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -59,7 +59,6 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { - async refreshClusters() {}, async getClusters() { return clusters; }, @@ -180,7 +179,6 @@ describe('KubernetesBuilder', () => { }, ]; const clusterSupplier: KubernetesClustersSupplier = { - async refreshClusters() {}, async getClusters() { return clusters; }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 9cdef9f00d..a3dfc8b005 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -17,6 +17,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { Duration } from 'luxon'; import { getCombinedClusterSupplier } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { @@ -36,7 +37,6 @@ import { KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; -import { runPeriodically } from './runPeriodically'; export interface KubernetesEnvironment { logger: Logger; @@ -59,7 +59,9 @@ export type KubernetesBuilderReturn = Promise<{ export class KubernetesBuilder { private clusterSupplier?: KubernetesClustersSupplier; - private clusterRefreshMs: number = 60 * 60 * 1000; // defaults to once per hour + private defaultClusterRefreshInterval: Duration = Duration.fromObject({ + minutes: 60, + }); private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; @@ -91,17 +93,9 @@ export class KubernetesBuilder { const fetcher = this.fetcher ?? this.buildFetcher(); - const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); - - // we cannot use the regular scheduler here because all instances need this info - // and it is not persisted anywhere. - runPeriodically(async () => { - try { - await clusterSupplier.refreshClusters(); - } catch (e) { - logger.warn(`Failed to refresh kubernetes clusters: ${e}`); - } - }, this.clusterRefreshMs); + const clusterSupplier = + this.clusterSupplier ?? + this.buildClusterSupplier(this.defaultClusterRefreshInterval); const serviceLocator = this.serviceLocator ?? @@ -134,8 +128,8 @@ export class KubernetesBuilder { return this; } - public setClusterRefreshInterval(refreshMs: number) { - this.clusterRefreshMs = refreshMs; + public setDefaultClusterRefreshInterval(refreshInterval: Duration) { + this.defaultClusterRefreshInterval = refreshInterval; return this; } @@ -173,9 +167,11 @@ export class KubernetesBuilder { return customResources; } - protected buildClusterSupplier(): KubernetesClustersSupplier { + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier { const config = this.env.config; - return getCombinedClusterSupplier(config); + return getCombinedClusterSupplier(config, refreshInterval); } protected buildObjectsProvider( diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 512d916639..2d7e2d3da7 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,16 +80,11 @@ export type KubernetesObjectTypes = // Used to load cluster details from different sources export interface KubernetesClustersSupplier { - /** - * Refreshes the list of cluster from the source. - * - * This will be called periodically on a schedule to refresh the list - * of clusters. - */ - refreshClusters(): Promise; - /** * Returns the cached list of clusters. + * + * Implementations _should_ cache the clusters and refresh them periodically, + * as getClusters is called whenever the list of clusters is needed. */ getClusters(): Promise; } From c4baa2400946979d04dc9c35ac6eb2e003db0b38 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 11:29:56 +0200 Subject: [PATCH 115/144] introduce new search-react package Signed-off-by: Emma Indal --- plugins/search-react/.eslintrc.js | 1 + plugins/search-react/README.md | 1 + plugins/search-react/api-report.md | 64 ++++ plugins/search-react/package.json | 53 ++++ plugins/search-react/src/api.ts | 32 ++ .../src/context/SearchContext.test.tsx | 287 ++++++++++++++++++ .../src/context/SearchContext.tsx | 144 +++++++++ .../SearchContextForStorybook.stories.tsx | 52 ++++ plugins/search-react/src/context/index.tsx | 26 ++ plugins/search-react/src/index.ts | 18 ++ plugins/search-react/src/setupTests.ts | 17 ++ 11 files changed, 695 insertions(+) create mode 100644 plugins/search-react/.eslintrc.js create mode 100644 plugins/search-react/README.md create mode 100644 plugins/search-react/api-report.md create mode 100644 plugins/search-react/package.json create mode 100644 plugins/search-react/src/api.ts create mode 100644 plugins/search-react/src/context/SearchContext.test.tsx create mode 100644 plugins/search-react/src/context/SearchContext.tsx create mode 100644 plugins/search-react/src/context/SearchContextForStorybook.stories.tsx create mode 100644 plugins/search-react/src/context/index.tsx create mode 100644 plugins/search-react/src/index.ts create mode 100644 plugins/search-react/src/setupTests.ts diff --git a/plugins/search-react/.eslintrc.js b/plugins/search-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/search-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/search-react/README.md b/plugins/search-react/README.md new file mode 100644 index 0000000000..9683a4a5f7 --- /dev/null +++ b/plugins/search-react/README.md @@ -0,0 +1 @@ +# search react diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md new file mode 100644 index 0000000000..ee8c7b1224 --- /dev/null +++ b/plugins/search-react/api-report.md @@ -0,0 +1,64 @@ +## API Report File for "@backstage/plugin-search-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { ComponentProps } from 'react'; +import { JsonObject } from '@backstage/types'; +import { PropsWithChildren } from 'react'; +import { default as React_2 } from 'react'; +import { SearchQuery } from '@backstage/plugin-search-common'; +import { SearchResultSet } from '@backstage/plugin-search-common'; + +// @public (undocumented) +export interface SearchApi { + // (undocumented) + query(query: SearchQuery): Promise; +} + +// Warning: (ae-forgotten-export) The symbol "QueryResultProps" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "SearchApiProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function SearchApiProviderForStorybook( + props: PropsWithChildren, +): JSX.Element; + +// @public (undocumented) +export const searchApiRef: ApiRef; + +// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export const SearchContext: React_2.Context; + +// @public (undocumented) +export const SearchContextProvider: ({ + initialState, + children, +}: React_2.PropsWithChildren<{ + initialState?: SearchContextState | undefined; +}>) => JSX.Element; + +// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const SearchContextProviderForStorybook: ( + props: ComponentProps & QueryResultProps, +) => JSX.Element; + +// @public +export type SearchContextState = { + term: string; + types: string[]; + filters: JsonObject; + pageCursor?: string; +}; + +// @public (undocumented) +export const useSearch: () => SearchContextValue; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json new file mode 100644 index 0000000000..66b6bdfa46 --- /dev/null +++ b/plugins/search-react/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-search-react", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/search-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean", + "start": "backstage-cli package start" + }, + "dependencies": { + "@backstage/plugin-search-common": "^0.3.2", + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/core-app-api": "^1.0.1-next.0", + "react-use": "^17.3.2", + "@backstage/types": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/test-utils": "^1.0.0", + "@testing-library/react": "^13.0.0", + "@testing-library/react-hooks": "^8.0.0", + "@testing-library/jest-dom": "^5.16.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/search-react/src/api.ts b/plugins/search-react/src/api.ts new file mode 100644 index 0000000000..eb8c9c23db --- /dev/null +++ b/plugins/search-react/src/api.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; +import { createApiRef } from '@backstage/core-plugin-api'; + +/** + * @public + */ +export const searchApiRef = createApiRef({ + id: 'plugin.search.queryservice', +}); + +/** + * @public + */ +export interface SearchApi { + query(query: SearchQuery): Promise; +} diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx new file mode 100644 index 0000000000..b19953f6a5 --- /dev/null +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -0,0 +1,287 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { useApi } from '@backstage/core-plugin-api'; +import { render, screen, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { SearchContextProvider, useSearch } from './SearchContext'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +describe('SearchContext', () => { + const query = jest.fn(); + + const wrapper = ({ children, initialState }: any) => ( + + {children} + + ); + + const initialState = { + term: '', + filters: {}, + types: ['*'], + }; + + beforeEach(() => { + query.mockResolvedValue({}); + (useApi as jest.Mock).mockReturnValue({ query: query }); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('Passes children', async () => { + const text = 'text'; + + render( + + {text} + , + ); + + await waitFor(() => { + expect(screen.getByText(text)).toBeInTheDocument(); + }); + }); + + it('Throws error when no context is set', () => { + const { result } = renderHook(() => useSearch()); + + expect(result.error).toEqual( + Error('useSearch must be used within a SearchContextProvider'), + ); + }); + + it('Uses initial state values', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual(expect.objectContaining(initialState)); + }); + + it('Resets cursor when term is set (and different from previous)', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState: { + ...initialState, + pageCursor: 'SOMEPAGE', + }, + }, + }); + + await waitForNextUpdate(); + + expect(result.current.pageCursor).toEqual('SOMEPAGE'); + + act(() => { + result.current.setTerm('first term'); + }); + + act(() => { + result.current.setPageCursor('OTHERPAGE'); + }); + + await waitForNextUpdate(); + + expect(result.current.pageCursor).toEqual('OTHERPAGE'); + + act(() => { + result.current.setTerm('second term'); + }); + + await waitForNextUpdate(); + + expect(result.current.pageCursor).toEqual(undefined); + }); + + describe('Performs search (and sets results)', () => { + it('When term is set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const term = 'term'; + + act(() => { + result.current.setTerm(term); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + filters: {}, + types: ['*'], + term, + }); + }); + + it('When filters are set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const filters = { filter: 'filter' }; + + act(() => { + result.current.setFilters(filters); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + filters, + types: ['*'], + term: '', + }); + }); + + it('When page is set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + act(() => { + result.current.setPageCursor('SOMEPAGE'); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + filters: {}, + types: ['*'], + pageCursor: 'SOMEPAGE', + term: '', + }); + }); + + it('When types is set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const types = ['type']; + + act(() => { + result.current.setTypes(types); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + types, + filters: {}, + term: '', + }); + }); + + it('provides function for fetch the next page', async () => { + query.mockResolvedValue({ + results: [], + nextPageCursor: 'NEXT', + }); + + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + expect(result.current.fetchNextPage).toBeDefined(); + expect(result.current.fetchPreviousPage).toBeUndefined(); + + act(() => { + result.current.fetchNextPage!(); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + types: ['*'], + filters: {}, + term: '', + pageCursor: 'NEXT', + }); + }); + + it('provides function for fetch the previous page', async () => { + query.mockResolvedValue({ + results: [], + previousPageCursor: 'PREVIOUS', + }); + + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + expect(result.current.fetchNextPage).toBeUndefined(); + expect(result.current.fetchPreviousPage).toBeDefined(); + + act(() => { + result.current.fetchPreviousPage!(); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + types: ['*'], + filters: {}, + term: '', + pageCursor: 'PREVIOUS', + }); + }); + }); +}); diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx new file mode 100644 index 0000000000..217c94cca2 --- /dev/null +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { JsonObject } from '@backstage/types'; +import { useApi, AnalyticsContext } from '@backstage/core-plugin-api'; +import { SearchResultSet } from '@backstage/plugin-search-common'; +import React, { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; +import usePrevious from 'react-use/lib/usePrevious'; +import { searchApiRef } from '../api'; + +type SearchContextValue = { + result: AsyncState; + setTerm: React.Dispatch>; + setTypes: React.Dispatch>; + setFilters: React.Dispatch>; + setPageCursor: React.Dispatch>; + fetchNextPage?: React.DispatchWithoutAction; + fetchPreviousPage?: React.DispatchWithoutAction; +} & SearchContextState; + +/** + * The initial state of `SearchContextProvider`. + * + * @public + */ +export type SearchContextState = { + term: string; + types: string[]; + filters: JsonObject; + pageCursor?: string; +}; + +/** + * @public + */ +export const SearchContext = createContext( + undefined, +); + +const searchInitialState: SearchContextState = { + term: '', + pageCursor: undefined, + filters: {}, + types: [], +}; + +/** + * @public + */ +export const SearchContextProvider = ({ + initialState = searchInitialState, + children, +}: PropsWithChildren<{ initialState?: SearchContextState }>) => { + const searchApi = useApi(searchApiRef); + const [pageCursor, setPageCursor] = useState( + initialState.pageCursor, + ); + const [filters, setFilters] = useState(initialState.filters); + const [term, setTerm] = useState(initialState.term); + const [types, setTypes] = useState(initialState.types); + + const prevTerm = usePrevious(term); + + const result = useAsync( + () => + searchApi.query({ + term, + filters, + pageCursor, + types, + }), + [term, filters, types, pageCursor], + ); + + const hasNextPage = + !result.loading && !result.error && result.value?.nextPageCursor; + const hasPreviousPage = + !result.loading && !result.error && result.value?.previousPageCursor; + const fetchNextPage = useCallback(() => { + setPageCursor(result.value?.nextPageCursor); + }, [result.value?.nextPageCursor]); + const fetchPreviousPage = useCallback(() => { + setPageCursor(result.value?.previousPageCursor); + }, [result.value?.previousPageCursor]); + + useEffect(() => { + // Any time a term is reset, we want to start from page 0. + if (term && prevTerm && term !== prevTerm) { + setPageCursor(undefined); + } + }, [term, prevTerm, initialState.pageCursor]); + + const value: SearchContextValue = { + result, + filters, + setFilters, + term, + setTerm, + types, + setTypes, + pageCursor, + setPageCursor, + fetchNextPage: hasNextPage ? fetchNextPage : undefined, + fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined, + }; + + return ( + + + + ); +}; + +/** + * @public + */ +export const useSearch = () => { + const context = useContext(SearchContext); + if (context === undefined) { + throw new Error('useSearch must be used within a SearchContextProvider'); + } + return context; +}; diff --git a/plugins/search-react/src/context/SearchContextForStorybook.stories.tsx b/plugins/search-react/src/context/SearchContextForStorybook.stories.tsx new file mode 100644 index 0000000000..51297c965f --- /dev/null +++ b/plugins/search-react/src/context/SearchContextForStorybook.stories.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { ApiProvider } from '@backstage/core-app-api'; +import { SearchResultSet } from '@backstage/plugin-search-common'; +import { TestApiRegistry } from '@backstage/test-utils'; +import React, { ComponentProps, PropsWithChildren } from 'react'; +import { searchApiRef } from '../api'; +import { SearchContextProvider as RealSearchContextProvider } from './SearchContext'; + +type QueryResultProps = { + mockedResults?: SearchResultSet; +}; + +/** + * Utility context provider only for use in Storybook stories. You should use + * the real `` exported by `@backstage/plugin-search-react` in + * your app instead of this! In some cases (like the search page) it may + * already be provided on your behalf. + */ +export const SearchContextProvider = ( + props: ComponentProps & QueryResultProps, +) => { + return ( + + + + ); +}; + +/** + * Utility api provider only for use in Storybook stories. + * + */ +export function SearchApiProvider(props: PropsWithChildren) { + const { mockedResults, children } = props; + const query: any = () => Promise.resolve(mockedResults || {}); + const apiRegistry = TestApiRegistry.from([searchApiRef, { query }]); + return ; +} diff --git a/plugins/search-react/src/context/index.tsx b/plugins/search-react/src/context/index.tsx new file mode 100644 index 0000000000..f2ea486e9d --- /dev/null +++ b/plugins/search-react/src/context/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { + SearchContextProvider, + SearchContext, + useSearch, +} from './SearchContext'; +export type { SearchContextState } from './SearchContext'; +export { + SearchContextProvider as SearchContextProviderForStorybook, + SearchApiProvider as SearchApiProviderForStorybook, +} from './SearchContextForStorybook.stories'; diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts new file mode 100644 index 0000000000..498e4d6ce3 --- /dev/null +++ b/plugins/search-react/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +export * from './api'; +export * from './context'; diff --git a/plugins/search-react/src/setupTests.ts b/plugins/search-react/src/setupTests.ts new file mode 100644 index 0000000000..992b60d3a4 --- /dev/null +++ b/plugins/search-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 '@testing-library/jest-dom'; From bc31ff1bbd192a5d21b7b02f63d82536cbb636ca Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 11:38:19 +0200 Subject: [PATCH 116/144] move home default template story to the app Signed-off-by: Emma Indal --- .../home}/templates/DefaultTemplate.stories.tsx | 12 ++++-------- .../home}/templates/TemplateBackstageLogo.tsx | 0 .../home}/templates/TemplateBackstageLogoIcon.tsx | 0 .../app/src/components/home}/templates/index.ts | 0 storybook/.storybook/main.js | 1 + 5 files changed, 5 insertions(+), 8 deletions(-) rename {plugins/home/src => packages/app/src/components/home}/templates/DefaultTemplate.stories.tsx (95%) rename {plugins/home/src => packages/app/src/components/home}/templates/TemplateBackstageLogo.tsx (100%) rename {plugins/home/src => packages/app/src/components/home}/templates/TemplateBackstageLogoIcon.tsx (100%) rename {plugins/home/src => packages/app/src/components/home}/templates/index.ts (100%) diff --git a/plugins/home/src/templates/DefaultTemplate.stories.tsx b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx similarity index 95% rename from plugins/home/src/templates/DefaultTemplate.stories.tsx rename to packages/app/src/components/home/templates/DefaultTemplate.stories.tsx index e2e570dcf6..b2f6034709 100644 --- a/plugins/home/src/templates/DefaultTemplate.stories.tsx +++ b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx @@ -20,8 +20,8 @@ import { HomePageToolkit, HomePageCompanyLogo, HomePageStarredEntities, -} from '../plugin'; -import { wrapInTestApp, TestApiProvider} from '@backstage/test-utils'; +} from '@backstage/plugin-home'; +import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { Content, Page, InfoCard } from '@backstage/core-components'; import { starredEntitiesApiRef, @@ -32,10 +32,9 @@ import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigReader } from '@backstage/config'; import { HomePageSearchBar, - SearchContextProvider, - searchApiRef, searchPlugin, } from '@backstage/plugin-search'; +import { searchApiRef, SearchContextProvider } from '@backstage/plugin-search-react'; import { HomePageStackOverflowQuestions } from '@backstage/plugin-stack-overflow'; import { Grid, makeStyles } from '@material-ui/core'; import React, { ComponentType } from 'react'; @@ -54,10 +53,7 @@ export default { <> Promise.resolve({ results: [] }) }], [ configApiRef, diff --git a/plugins/home/src/templates/TemplateBackstageLogo.tsx b/packages/app/src/components/home/templates/TemplateBackstageLogo.tsx similarity index 100% rename from plugins/home/src/templates/TemplateBackstageLogo.tsx rename to packages/app/src/components/home/templates/TemplateBackstageLogo.tsx diff --git a/plugins/home/src/templates/TemplateBackstageLogoIcon.tsx b/packages/app/src/components/home/templates/TemplateBackstageLogoIcon.tsx similarity index 100% rename from plugins/home/src/templates/TemplateBackstageLogoIcon.tsx rename to packages/app/src/components/home/templates/TemplateBackstageLogoIcon.tsx diff --git a/plugins/home/src/templates/index.ts b/packages/app/src/components/home/templates/index.ts similarity index 100% rename from plugins/home/src/templates/index.ts rename to packages/app/src/components/home/templates/index.ts diff --git a/storybook/.storybook/main.js b/storybook/.storybook/main.js index 2356e4e41b..8ed4949cf3 100644 --- a/storybook/.storybook/main.js +++ b/storybook/.storybook/main.js @@ -6,6 +6,7 @@ const WebpackPluginFailBuildOnWarning = require('./webpack-plugin-fail-build-on- */ const BACKSTAGE_CORE_STORIES = [ 'packages/core-components', + 'packages/app', 'plugins/org', 'plugins/search', 'plugins/home', From 09cba0bdaee0b077befe8e46790740298c6fae6a Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 11:41:59 +0200 Subject: [PATCH 117/144] deprecate search api ref, search api interface and search context in plugin-search Signed-off-by: Emma Indal --- plugins/search/src/apis.ts | 7 + .../SearchContext/SearchContext.test.tsx | 287 ------------------ .../SearchContext/SearchContext.tsx | 9 + .../SearchContextForStorybook.stories.tsx | 48 --- 4 files changed, 16 insertions(+), 335 deletions(-) delete mode 100644 plugins/search/src/components/SearchContext/SearchContext.test.tsx delete mode 100644 plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 908942d87d..62f65dbe4a 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -21,12 +21,19 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; + import qs from 'qs'; +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export const searchApiRef = createApiRef({ id: 'plugin.search.queryservice', }); +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export interface SearchApi { query(query: SearchQuery): Promise; } diff --git a/plugins/search/src/components/SearchContext/SearchContext.test.tsx b/plugins/search/src/components/SearchContext/SearchContext.test.tsx deleted file mode 100644 index 6513dc21bb..0000000000 --- a/plugins/search/src/components/SearchContext/SearchContext.test.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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 { useApi } from '@backstage/core-plugin-api'; -import { render, screen, waitFor } from '@testing-library/react'; -import { act, renderHook } from '@testing-library/react-hooks'; -import React from 'react'; -import { SearchContextProvider, useSearch } from './SearchContext'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -describe('SearchContext', () => { - const query = jest.fn(); - - const wrapper = ({ children, initialState }: any) => ( - - {children} - - ); - - const initialState = { - term: '', - filters: {}, - types: ['*'], - }; - - beforeEach(() => { - query.mockResolvedValue({}); - (useApi as jest.Mock).mockReturnValue({ query: query }); - }); - - afterAll(() => { - jest.resetAllMocks(); - }); - - it('Passes children', async () => { - const text = 'text'; - - render( - - {text} - , - ); - - await waitFor(() => { - expect(screen.getByText(text)).toBeInTheDocument(); - }); - }); - - it('Throws error when no context is set', () => { - const { result } = renderHook(() => useSearch()); - - expect(result.error).toEqual( - Error('useSearch must be used within a SearchContextProvider'), - ); - }); - - it('Uses initial state values', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - expect(result.current).toEqual(expect.objectContaining(initialState)); - }); - - it('Resets cursor when term is set (and different from previous)', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState: { - ...initialState, - pageCursor: 'SOMEPAGE', - }, - }, - }); - - await waitForNextUpdate(); - - expect(result.current.pageCursor).toEqual('SOMEPAGE'); - - act(() => { - result.current.setTerm('first term'); - }); - - act(() => { - result.current.setPageCursor('OTHERPAGE'); - }); - - await waitForNextUpdate(); - - expect(result.current.pageCursor).toEqual('OTHERPAGE'); - - act(() => { - result.current.setTerm('second term'); - }); - - await waitForNextUpdate(); - - expect(result.current.pageCursor).toEqual(undefined); - }); - - describe('Performs search (and sets results)', () => { - it('When term is set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - const term = 'term'; - - act(() => { - result.current.setTerm(term); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - filters: {}, - types: ['*'], - term, - }); - }); - - it('When filters are set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - const filters = { filter: 'filter' }; - - act(() => { - result.current.setFilters(filters); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - filters, - types: ['*'], - term: '', - }); - }); - - it('When page is set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - act(() => { - result.current.setPageCursor('SOMEPAGE'); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - filters: {}, - types: ['*'], - pageCursor: 'SOMEPAGE', - term: '', - }); - }); - - it('When types is set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - const types = ['type']; - - act(() => { - result.current.setTypes(types); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - types, - filters: {}, - term: '', - }); - }); - - it('provides function for fetch the next page', async () => { - query.mockResolvedValue({ - results: [], - nextPageCursor: 'NEXT', - }); - - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - expect(result.current.fetchNextPage).toBeDefined(); - expect(result.current.fetchPreviousPage).toBeUndefined(); - - act(() => { - result.current.fetchNextPage!(); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - types: ['*'], - filters: {}, - term: '', - pageCursor: 'NEXT', - }); - }); - - it('provides function for fetch the previous page', async () => { - query.mockResolvedValue({ - results: [], - previousPageCursor: 'PREVIOUS', - }); - - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - expect(result.current.fetchNextPage).toBeUndefined(); - expect(result.current.fetchPreviousPage).toBeDefined(); - - act(() => { - result.current.fetchPreviousPage!(); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - types: ['*'], - filters: {}, - term: '', - pageCursor: 'PREVIOUS', - }); - }); - }); -}); diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index 100d8a3ac3..d52e43af88 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -51,6 +51,9 @@ export type SearchContextState = { pageCursor?: string; }; +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export const SearchContext = createContext( undefined, ); @@ -62,6 +65,9 @@ const searchInitialState: SearchContextState = { types: [], }; +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export const SearchContextProvider = ({ initialState = searchInitialState, children, @@ -126,6 +132,9 @@ export const SearchContextProvider = ({ ); }; +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export const useSearch = () => { const context = useContext(SearchContext); if (context === undefined) { diff --git a/plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx b/plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx deleted file mode 100644 index 7d6c35b00c..0000000000 --- a/plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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 { ApiProvider } from '@backstage/core-app-api'; -import { SearchResultSet } from '@backstage/plugin-search-common'; -import { TestApiRegistry } from '@backstage/test-utils'; -import React, { ComponentProps, PropsWithChildren } from 'react'; -import { searchApiRef } from '../../apis'; -import { SearchContextProvider as RealSearchContextProvider } from './SearchContext'; - -type QueryResultProps = { - mockedResults?: SearchResultSet; -}; - -/** - * Utility context provider only for use in Storybook stories. You should use - * the real `` exported by `@backstage/plugin-search` in - * your app instead of this! In some cases (like the search page) it may - * already be provided on your behalf. - */ -export const SearchContextProvider = ( - props: ComponentProps & QueryResultProps, -) => { - return ( - - - - ); -}; - -export function SearchApiProvider(props: PropsWithChildren) { - const { mockedResults, children } = props; - const query: any = () => Promise.resolve(mockedResults || {}); - const apiRegistry = TestApiRegistry.from([searchApiRef, { query }]); - return ; -} From 301b4606e3823665e1b60b9cbc9ac1ab488f5954 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 11:42:57 +0200 Subject: [PATCH 118/144] import from new search-react package Signed-off-by: Emma Indal --- plugins/home/package.json | 2 +- plugins/search/package.json | 1 + .../SearchBar/SearchBar.stories.tsx | 6 +- .../SearchFilter/SearchFilter.stories.tsx | 6 +- .../SearchModal/SearchModal.stories.tsx | 6 +- .../SearchResult/SearchResult.stories.tsx | 7 +- .../SearchType/SearchType.stories.tsx | 6 +- plugins/techdocs/package.json | 2 +- .../src/reader/components/Reader.test.tsx | 2 +- .../components/TechDocsReaderPage.test.tsx | 2 +- .../search/components/TechDocsSearch.test.tsx | 2 +- .../src/search/components/TechDocsSearch.tsx | 5 +- yarn.lock | 99 +++++++++++++++++++ 13 files changed, 125 insertions(+), 21 deletions(-) diff --git a/plugins/home/package.json b/plugins/home/package.json index 43b474704a..e59e7a39bb 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -38,7 +38,7 @@ "@backstage/core-components": "^0.9.3-next.1", "@backstage/core-plugin-api": "^1.0.0", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/theme": "^0.2.15", "@backstage/config": "^1.0.0", diff --git a/plugins/search/package.json b/plugins/search/package.json index c32e830950..4ac891775e 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -39,6 +39,7 @@ "@backstage/core-plugin-api": "^1.0.0", "@backstage/errors": "^1.0.0", "@backstage/plugin-catalog-react": "^1.0.1-next.1", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-search-common": "^0.3.3-next.1", "@backstage/theme": "^0.2.15", "@backstage/types": "^1.0.0", diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx index c0d4b07965..72c7deb06b 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx @@ -16,7 +16,7 @@ import { Grid, makeStyles, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchBar } from './SearchBar'; export default { @@ -24,13 +24,13 @@ export default { component: SearchBar, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx index c43753e8d2..3c49b76699 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx @@ -16,7 +16,7 @@ import { Grid, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchFilter } from './SearchFilter'; export default { @@ -24,13 +24,13 @@ export default { component: SearchFilter, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx index aa0ad0479a..a1d9517674 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx @@ -18,7 +18,7 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { Button } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { rootRouteRef } from '../../plugin'; -import { SearchApiProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchApiProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchModal } from './SearchModal'; import { useSearchModal } from './useSearchModal'; @@ -57,9 +57,9 @@ export default { decorators: [ (Story: ComponentType<{}>) => wrapInTestApp( - + - , + , { mountedRoutes: { '/search': rootRouteRef } }, ), ], diff --git a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx index c1685df2c1..4a3d950164 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx @@ -19,7 +19,8 @@ import { List, ListItem } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router'; import { DefaultResultListItem } from '../DefaultResultListItem'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; + +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchResult } from './SearchResult'; const mockResults = { @@ -57,9 +58,9 @@ export default { decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], diff --git a/plugins/search/src/components/SearchType/SearchType.stories.tsx b/plugins/search/src/components/SearchType/SearchType.stories.tsx index 596c4a027a..b56f4c089c 100644 --- a/plugins/search/src/components/SearchType/SearchType.stories.tsx +++ b/plugins/search/src/components/SearchType/SearchType.stories.tsx @@ -19,7 +19,7 @@ import CatalogIcon from '@material-ui/icons/MenuBook'; import DocsIcon from '@material-ui/icons/Description'; import UsersGroupsIcon from '@material-ui/icons/Person'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchType } from './SearchType'; export default { @@ -27,13 +27,13 @@ export default { component: SearchType, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 8516c09783..b6901e8edd 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -43,7 +43,7 @@ "@backstage/integration": "^1.1.0-next.1", "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx index 6e8609694d..09cd33e286 100644 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ b/plugins/techdocs/src/reader/components/Reader.test.tsx @@ -25,7 +25,7 @@ import React from 'react'; import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api'; import { Reader } from './Reader'; import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx index 55637806a3..9fbf5c2195 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx @@ -30,7 +30,7 @@ import { TechDocsStorageApi, } from '../../api'; import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx index 06d5ac6aac..5957aecc4d 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.tsx index 30cc692b7c..f11fa38948 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearch.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearch.tsx @@ -15,7 +15,10 @@ */ import { CompoundEntityRef } from '@backstage/catalog-model'; -import { SearchContextProvider, useSearch } from '@backstage/plugin-search'; +import { + SearchContextProvider, + useSearch, +} from '@backstage/plugin-search-react'; import { makeStyles, CircularProgress, diff --git a/yarn.lock b/yarn.lock index 4919937660..b5f327b6e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,6 +1478,22 @@ lodash "^4.17.21" uuid "^8.0.0" +"@backstage/core-app-api@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-1.0.0.tgz#2dae97b050b2f2e5ec1ea42b3d95c57e8bf434d6" + integrity sha512-hmoFMPCxAfHgDPQTHbf6rquiG0SCSycWTUrScpYeLwkH3UOekgX8o8ThKT0t3w7WPx83LwT0NqcbSH6zqI9nag== + dependencies: + "@backstage/config" "^1.0.0" + "@backstage/core-plugin-api" "^1.0.0" + "@backstage/types" "^1.0.0" + "@backstage/version-bridge" "^1.0.0" + "@types/prop-types" "^15.7.3" + prop-types "^15.7.2" + react-router-dom "6.0.0-beta.0" + react-use "^17.2.4" + zen-observable "^0.8.15" + zod "^3.11.6" + "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -1602,6 +1618,36 @@ react-use "^17.2.4" swr "^1.1.2" +"@backstage/plugin-search-common@^0.3.2": + version "0.3.2" + resolved "https://registry.npmjs.org/@backstage/plugin-search-common/-/plugin-search-common-0.3.2.tgz#15984ba4c14f8a9119168e8c79344ef8101863dc" + integrity sha512-7vcpRo+5MB/QW/M77zPfcqxw0LzcQCHNXql0uxF+qBwVPJSHz9QB+YBuzGyaAlqfm5UPFXuweLQGqtoB+0DMLg== + dependencies: + "@backstage/plugin-permission-common" "^0.5.3" + "@backstage/types" "^1.0.0" + +"@backstage/test-utils@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@backstage/test-utils/-/test-utils-1.0.0.tgz#dafac18065591a7dda584811cb00812495292ee8" + integrity sha512-dHtIjhoq2b+rpsnwVQnWA/2sDxFMt2HL0OxoyKqG2NRum16A7cTQxgrG3UC3p4dqFYKREg7+aTFIjHBa+Tk/PA== + dependencies: + "@backstage/config" "^1.0.0" + "@backstage/core-app-api" "^1.0.0" + "@backstage/core-plugin-api" "^1.0.0" + "@backstage/plugin-permission-common" "^0.5.3" + "@backstage/plugin-permission-react" "^0.3.4" + "@backstage/theme" "^0.2.15" + "@backstage/types" "^1.0.0" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.11.2" + "@testing-library/jest-dom" "^5.10.1" + "@testing-library/react" "^12.1.3" + "@testing-library/user-event" "^13.1.8" + cross-fetch "^3.1.5" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + zen-observable "^0.8.15" + "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -5545,6 +5591,20 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/dom@^8.5.0": + version "8.13.0" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5" + integrity sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.4.4" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.10.1": version "5.16.3" resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.3.tgz#b76851a909586113c20486f1679ffb4d8ec27bfa" @@ -5560,6 +5620,21 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/jest-dom@^5.16.4": + version "5.16.4" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd" + integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA== + dependencies: + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react-hooks@^7.0.2": version "7.0.2" resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" @@ -5571,6 +5646,14 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" +"@testing-library/react-hooks@^8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf" + integrity sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.1.3": version "12.1.4" resolved "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" @@ -5580,6 +5663,22 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "*" +"@testing-library/react@^13.0.0": + version "13.0.0" + resolved "https://registry.npmjs.org/@testing-library/react/-/react-13.0.0.tgz#8cdaf4667c6c2b082eb0513731551e9db784e8bc" + integrity sha512-p0lYA1M7uoEmk2LnCbZLGmHJHyH59sAaZVXChTXlyhV/PRW9LoIh4mdf7tiXsO8BoNG+vN8UnFJff1hbZeXv+w== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.5.0" + "@types/react-dom" "*" + +"@testing-library/user-event@^13.1.8": + version "13.5.0" + resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" + integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/user-event@^14.0.0": version "14.0.0" resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.0.0.tgz#3906aa6f0e56fd012d73559f5f05c02e63ba18dd" From a39a931b39605fe3de3e035b7a0a69c729c54d24 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 11:43:22 +0200 Subject: [PATCH 119/144] search api report Signed-off-by: Emma Indal --- plugins/search/api-report.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 1bdd918802..f524652f51 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -81,17 +81,19 @@ export type HomePageSearchBarProps = Partial< // @public (undocumented) export const Router: () => JSX.Element; +// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "SearchApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export interface SearchApi { // (undocumented) query(query: SearchQuery): Promise; } +// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "searchApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const searchApiRef: ApiRef; // @public (undocumented) @@ -138,9 +140,10 @@ export const SearchBarNext: ({ // @public export type SearchBarProps = Partial; +// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const SearchContextProvider: ({ initialState, children, @@ -322,10 +325,11 @@ export type SidebarSearchProps = { icon?: IconComponent; }; +// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const useSearch: () => SearchContextValue; // @public From 6a4f081128a47bc86fbcf4d9885303ca2e8025fe Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 12:08:29 +0200 Subject: [PATCH 120/144] let template logos live in home plugin for now Signed-off-by: Emma Indal --- .../home/templates/DefaultTemplate.stories.tsx | 4 ++-- plugins/home/api-report.md | 13 +++++++++++++ .../home/src/assets}/TemplateBackstageLogo.tsx | 2 +- .../home/src/assets}/TemplateBackstageLogoIcon.tsx | 1 - .../templates => plugins/home/src/assets}/index.ts | 6 +++--- .../CompanyLogo/CompanyLogo.stories.tsx | 2 +- .../homePageComponents/Toolkit/Toolkit.stories.tsx | 2 +- plugins/home/src/index.ts | 1 + 8 files changed, 22 insertions(+), 9 deletions(-) rename {packages/app/src/components/home/templates => plugins/home/src/assets}/TemplateBackstageLogo.tsx (99%) rename {packages/app/src/components/home/templates => plugins/home/src/assets}/TemplateBackstageLogoIcon.tsx (99%) rename {packages/app/src/components/home/templates => plugins/home/src/assets}/index.ts (76%) diff --git a/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx index b2f6034709..8b9c5100b1 100644 --- a/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx +++ b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import { TemplateBackstageLogo } from './TemplateBackstageLogo'; -import { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon'; import { HomePageToolkit, HomePageCompanyLogo, HomePageStarredEntities, + TemplateBackstageLogo, + TemplateBackstageLogoIcon } from '@backstage/plugin-home'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { Content, Page, InfoCard } from '@backstage/core-components'; diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 9bd577da7c..21c5592636 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -119,11 +119,24 @@ export const SettingsModal: (props: { children: JSX.Element; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "TemplateBackstageLogo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const TemplateBackstageLogo: (props: { + classes: Classes; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "TemplateBackstageLogoIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const TemplateBackstageLogoIcon: () => JSX.Element; + // @public export const WelcomeTitle: () => JSX.Element; // Warnings were encountered during analysis: // +// src/assets/TemplateBackstageLogo.d.ts:7:5 - (ae-forgotten-export) The symbol "Classes" needs to be exported by the entry point index.d.ts // src/extensions.d.ts:6:5 - (ae-forgotten-export) The symbol "RendererProps" needs to be exported by the entry point index.d.ts // src/extensions.d.ts:27:5 - (ae-forgotten-export) The symbol "ComponentParts" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/app/src/components/home/templates/TemplateBackstageLogo.tsx b/plugins/home/src/assets/TemplateBackstageLogo.tsx similarity index 99% rename from packages/app/src/components/home/templates/TemplateBackstageLogo.tsx rename to plugins/home/src/assets/TemplateBackstageLogo.tsx index edce9bb02e..0fc908ee39 100644 --- a/packages/app/src/components/home/templates/TemplateBackstageLogo.tsx +++ b/plugins/home/src/assets/TemplateBackstageLogo.tsx @@ -19,7 +19,7 @@ import React from 'react'; type Classes = { svg: string; path: string; -} +}; export const TemplateBackstageLogo = (props: { classes: Classes }) => { return ( diff --git a/packages/app/src/components/home/templates/TemplateBackstageLogoIcon.tsx b/plugins/home/src/assets/TemplateBackstageLogoIcon.tsx similarity index 99% rename from packages/app/src/components/home/templates/TemplateBackstageLogoIcon.tsx rename to plugins/home/src/assets/TemplateBackstageLogoIcon.tsx index 2116b48784..09c4405286 100644 --- a/packages/app/src/components/home/templates/TemplateBackstageLogoIcon.tsx +++ b/plugins/home/src/assets/TemplateBackstageLogoIcon.tsx @@ -43,4 +43,3 @@ export const TemplateBackstageLogoIcon = () => { ); }; - diff --git a/packages/app/src/components/home/templates/index.ts b/plugins/home/src/assets/index.ts similarity index 76% rename from packages/app/src/components/home/templates/index.ts rename to plugins/home/src/assets/index.ts index bfebe73cd8..79de43ca99 100644 --- a/packages/app/src/components/home/templates/index.ts +++ b/plugins/home/src/assets/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2020 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,5 +14,5 @@ * limitations under the License. */ -export { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon'; -export { TemplateBackstageLogo } from './TemplateBackstageLogo' +export * from './TemplateBackstageLogo'; +export * from './TemplateBackstageLogoIcon'; diff --git a/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx b/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx index ab71eea54e..741ad2e977 100644 --- a/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx +++ b/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateBackstageLogo } from '../../templates'; +import { TemplateBackstageLogo } from '../../assets'; import { HomePageCompanyLogo } from '../../plugin'; import { rootRouteRef } from '../../routes'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; diff --git a/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx b/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx index cad188c5d6..9a82301e49 100644 --- a/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx +++ b/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx @@ -20,7 +20,7 @@ import { Grid } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { ComponentAccordion } from '../../componentRenderers'; import { HomePageToolkit } from '../../plugin'; -import { TemplateBackstageLogoIcon } from '../../templates'; +import { TemplateBackstageLogoIcon } from '../../assets'; export default { title: 'Plugins/Home/Components/Toolkit', diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index d30b76a7fc..8f137d589b 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -33,6 +33,7 @@ export { WelcomeTitle, } from './plugin'; export { SettingsModal, HeaderWorldClock } from './components'; +export * from './assets'; export type { ClockConfig } from './components'; export { createCardExtension } from './extensions'; export type { ComponentRenderer } from './extensions'; From 57f05a2c294ed06db24c9e056be466466592756d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Apr 2022 13:36:06 +0200 Subject: [PATCH 121/144] bump webpack-dev-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- yarn.lock | 119 ++++++++++++++++++++---------------------------------- 1 file changed, 43 insertions(+), 76 deletions(-) diff --git a/yarn.lock b/yarn.lock index fb3bb08459..b9cf5fdd60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3488,6 +3488,11 @@ underscore "^1.9.1" ws "^7.3.1" +"@leichtgewicht/ip-codec@^2.0.1": + version "2.0.3" + resolved "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0" + integrity sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg== + "@lerna/add@4.0.0": version "4.0.0" resolved "https://registry.npmjs.org/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f" @@ -6982,10 +6987,10 @@ dependencies: "@types/node" "*" -"@types/ws@^8.0.0", "@types/ws@^8.2.2": - version "8.2.2" - resolved "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz#7c5be4decb19500ae6b3d563043cd407bf366c21" - integrity sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg== +"@types/ws@^8.0.0", "@types/ws@^8.5.1": + version "8.5.3" + resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== dependencies: "@types/node" "*" @@ -7853,7 +7858,7 @@ array-flatten@1.1.1: resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= -array-flatten@^2.1.0: +array-flatten@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== @@ -8510,17 +8515,15 @@ body-parser@1.19.2, body-parser@^1.19.0: raw-body "2.4.3" type-is "~1.6.18" -bonjour@^3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" - integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= +bonjour-service@^1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.11.tgz#5418e5c1ac91c89a406f853a942e7892829c0d89" + integrity sha512-drMprzr2rDTCtgEE3VgdA9uUFaUHF+jXduwYSThHJnKMYM+FhI9Z3ph+TX3xy0LtgYHae6CHYPJ/2UnK8nQHcA== dependencies: - array-flatten "^2.1.0" - deep-equal "^1.0.1" + array-flatten "^2.1.2" dns-equal "^1.0.0" - dns-txt "^2.0.2" - multicast-dns "^6.0.1" - multicast-dns-service-types "^1.1.0" + fast-deep-equal "^3.1.3" + multicast-dns "^7.2.4" boolbase@^1.0.0: version "1.0.0" @@ -8727,11 +8730,6 @@ buffer-indexof-polyfill@~1.0.0: resolved "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== -buffer-indexof@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" - integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== - buffer-writer@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" @@ -10864,18 +10862,6 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-equal@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" - integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== - dependencies: - is-arguments "^1.0.4" - is-date-object "^1.0.1" - is-regex "^1.0.4" - object-is "^1.0.1" - object-keys "^1.1.1" - regexp.prototype.flags "^1.2.0" - deep-extend@0.6.0, deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -11150,20 +11136,12 @@ dns-equal@^1.0.0: resolved "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= -dns-packet@^1.3.1: - version "1.3.4" - resolved "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz#e3455065824a2507ba886c55a89963bb107dec6f" - integrity sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA== +dns-packet@^5.2.2: + version "5.3.1" + resolved "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz#eb94413789daec0f0ebe2fcc230bdc9d7c91b43d" + integrity sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw== dependencies: - ip "^1.1.0" - safe-buffer "^5.0.1" - -dns-txt@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" - integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= - dependencies: - buffer-indexof "^1.0.0" + "@leichtgewicht/ip-codec" "^2.0.1" docker-compose@^0.23.17: version "0.23.17" @@ -12384,7 +12362,7 @@ express-xml-bodyparser@^0.3.0: dependencies: xml2js "^0.4.11" -express@^4.17.1: +express@^4.17.1, express@^4.17.3: version "4.17.3" resolved "https://registry.npmjs.org/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== @@ -14083,7 +14061,7 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-proxy-middleware@^2.0.0: +http-proxy-middleware@^2.0.0, http-proxy-middleware@^2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz#03af0f4676d172ae775cb5c33f592f40e1a4e07a" integrity sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg== @@ -14515,7 +14493,7 @@ ioredis@^4.28.5: redis-parser "^3.0.0" standard-as-callback "^2.1.0" -ip@^1.1.0, ip@^1.1.5: +ip@^1.1.5: version "1.1.5" resolved "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= @@ -14956,7 +14934,7 @@ is-reference@^1.2.1: dependencies: "@types/estree" "*" -is-regex@^1.0.4, is-regex@^1.1.4: +is-regex@^1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -18232,17 +18210,12 @@ msw@^0.36.3: type-fest "^1.2.2" yargs "^17.3.0" -multicast-dns-service-types@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" - integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= - -multicast-dns@^6.0.1: - version "6.2.3" - resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" - integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== +multicast-dns@^7.2.4: + version "7.2.4" + resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz#cf0b115c31e922aeb20b64e6556cbeb34cf0dd19" + integrity sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw== dependencies: - dns-packet "^1.3.1" + dns-packet "^5.2.2" thunky "^1.0.2" multimatch@^5.0.0: @@ -18831,11 +18804,6 @@ object-inspect@^1.11.0, object-inspect@^1.12.0, object-inspect@^1.9.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== -object-is@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" - integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ== - object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -21596,7 +21564,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.1: +regexp.prototype.flags@^1.3.1: version "1.4.1" resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz#b3f4c0059af9e47eca9f3f660e51d81307e72307" integrity sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ== @@ -22265,7 +22233,7 @@ select-hose@^2.0.0: resolved "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selfsigned@^2.0.0: +selfsigned@^2.0.0, selfsigned@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56" integrity sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ== @@ -23307,7 +23275,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.0, strip-ansi@^7.0.1: +strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== @@ -25155,38 +25123,37 @@ webpack-dev-middleware@^5.3.1: schema-utils "^4.0.0" webpack-dev-server@^4.7.3: - version "4.7.4" - resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.7.4.tgz#d0ef7da78224578384e795ac228d8efb63d5f945" - integrity sha512-nfdsb02Zi2qzkNmgtZjkrMOcXnYZ6FLKcQwpxT7MvmHKc+oTtDsBju8j+NMyAygZ9GW1jMEUpy3itHtqgEhe1A== + version "4.8.1" + resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.8.1.tgz#58f9d797710d6e25fa17d6afab8708f958c11a29" + integrity sha512-dwld70gkgNJa33czmcj/PlKY/nOy/BimbrgZRaR9vDATBQAYgLzggR0nxDtPLJiLrMgZwbE6RRfJ5vnBBasTyg== dependencies: "@types/bonjour" "^3.5.9" "@types/connect-history-api-fallback" "^1.3.5" "@types/express" "^4.17.13" "@types/serve-index" "^1.9.1" "@types/sockjs" "^0.3.33" - "@types/ws" "^8.2.2" + "@types/ws" "^8.5.1" ansi-html-community "^0.0.8" - bonjour "^3.5.0" + bonjour-service "^1.0.11" chokidar "^3.5.3" colorette "^2.0.10" compression "^1.7.4" connect-history-api-fallback "^1.6.0" default-gateway "^6.0.3" - del "^6.0.0" - express "^4.17.1" + express "^4.17.3" graceful-fs "^4.2.6" html-entities "^2.3.2" - http-proxy-middleware "^2.0.0" + http-proxy-middleware "^2.0.3" ipaddr.js "^2.0.1" open "^8.0.9" p-retry "^4.5.0" portfinder "^1.0.28" + rimraf "^3.0.2" schema-utils "^4.0.0" - selfsigned "^2.0.0" + selfsigned "^2.0.1" serve-index "^1.9.1" sockjs "^0.3.21" spdy "^4.0.2" - strip-ansi "^7.0.0" webpack-dev-middleware "^5.3.1" ws "^8.4.2" From c64c33f74ba07b14c1abaf4638be0d66ff84c623 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 13:16:38 +0200 Subject: [PATCH 122/144] dependency and docs fixups Signed-off-by: Emma Indal --- packages/app/package.json | 3 ++ plugins/home/README.md | 2 +- plugins/search-react/package.json | 6 +-- yarn.lock | 79 ++----------------------------- 4 files changed, 10 insertions(+), 80 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 4522cabaf1..f8dc6127c8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "@backstage/app-defaults": "^1.0.1-next.1", "@backstage/catalog-model": "^1.0.1-next.0", "@backstage/cli": "^0.17.0-next.1", + "@backstage/config": "^1.0.0", "@backstage/core-app-api": "^1.0.1-next.0", "@backstage/core-components": "^0.9.3-next.0", "@backstage/core-plugin-api": "^1.0.0", @@ -47,9 +48,11 @@ "@backstage/plugin-rollbar": "^0.4.4-next.0", "@backstage/plugin-scaffolder": "^1.0.1-next.1", "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-search-common": "^0.3.3-next.1", "@backstage/plugin-sentry": "^0.3.42-next.0", "@backstage/plugin-shortcuts": "^0.2.5-next.0", + "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/plugin-tech-radar": "^0.5.11-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", "@backstage/plugin-todo": "^0.2.6-next.0", diff --git a/plugins/home/README.md b/plugins/home/README.md index a85994b3cb..0cd307b706 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -93,4 +93,4 @@ Additionally, the API is at a very early state, so contributing with additional ### Homepage Templates -We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/plugins/home/src/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion. +We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion. diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 66b6bdfa46..15251b4ac7 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -31,7 +31,7 @@ "start": "backstage-cli package start" }, "dependencies": { - "@backstage/plugin-search-common": "^0.3.2", + "@backstage/plugin-search-common": "^0.3.3-next.1", "@backstage/core-plugin-api": "^1.0.0", "@backstage/core-app-api": "^1.0.1-next.0", "react-use": "^17.3.2", @@ -42,8 +42,8 @@ "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { - "@backstage/test-utils": "^1.0.0", - "@testing-library/react": "^13.0.0", + "@backstage/test-utils": "^1.0.1-next.1", + "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^8.0.0", "@testing-library/jest-dom": "^5.16.4" }, diff --git a/yarn.lock b/yarn.lock index b5f327b6e4..223c451347 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,22 +1478,6 @@ lodash "^4.17.21" uuid "^8.0.0" -"@backstage/core-app-api@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@backstage/core-app-api/-/core-app-api-1.0.0.tgz#2dae97b050b2f2e5ec1ea42b3d95c57e8bf434d6" - integrity sha512-hmoFMPCxAfHgDPQTHbf6rquiG0SCSycWTUrScpYeLwkH3UOekgX8o8ThKT0t3w7WPx83LwT0NqcbSH6zqI9nag== - dependencies: - "@backstage/config" "^1.0.0" - "@backstage/core-plugin-api" "^1.0.0" - "@backstage/types" "^1.0.0" - "@backstage/version-bridge" "^1.0.0" - "@types/prop-types" "^15.7.3" - prop-types "^15.7.2" - react-router-dom "6.0.0-beta.0" - react-use "^17.2.4" - zen-observable "^0.8.15" - zod "^3.11.6" - "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -1618,36 +1602,6 @@ react-use "^17.2.4" swr "^1.1.2" -"@backstage/plugin-search-common@^0.3.2": - version "0.3.2" - resolved "https://registry.npmjs.org/@backstage/plugin-search-common/-/plugin-search-common-0.3.2.tgz#15984ba4c14f8a9119168e8c79344ef8101863dc" - integrity sha512-7vcpRo+5MB/QW/M77zPfcqxw0LzcQCHNXql0uxF+qBwVPJSHz9QB+YBuzGyaAlqfm5UPFXuweLQGqtoB+0DMLg== - dependencies: - "@backstage/plugin-permission-common" "^0.5.3" - "@backstage/types" "^1.0.0" - -"@backstage/test-utils@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@backstage/test-utils/-/test-utils-1.0.0.tgz#dafac18065591a7dda584811cb00812495292ee8" - integrity sha512-dHtIjhoq2b+rpsnwVQnWA/2sDxFMt2HL0OxoyKqG2NRum16A7cTQxgrG3UC3p4dqFYKREg7+aTFIjHBa+Tk/PA== - dependencies: - "@backstage/config" "^1.0.0" - "@backstage/core-app-api" "^1.0.0" - "@backstage/core-plugin-api" "^1.0.0" - "@backstage/plugin-permission-common" "^0.5.3" - "@backstage/plugin-permission-react" "^0.3.4" - "@backstage/theme" "^0.2.15" - "@backstage/types" "^1.0.0" - "@material-ui/core" "^4.12.2" - "@material-ui/icons" "^4.11.2" - "@testing-library/jest-dom" "^5.10.1" - "@testing-library/react" "^12.1.3" - "@testing-library/user-event" "^13.1.8" - cross-fetch "^3.1.5" - react-router "6.0.0-beta.0" - react-router-dom "6.0.0-beta.0" - zen-observable "^0.8.15" - "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -5591,20 +5545,6 @@ lz-string "^1.4.4" pretty-format "^27.0.2" -"@testing-library/dom@^8.5.0": - version "8.13.0" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5" - integrity sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^4.2.0" - aria-query "^5.0.0" - chalk "^4.1.0" - dom-accessibility-api "^0.5.9" - lz-string "^1.4.4" - pretty-format "^27.0.2" - "@testing-library/jest-dom@^5.10.1": version "5.16.3" resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.3.tgz#b76851a909586113c20486f1679ffb4d8ec27bfa" @@ -5663,22 +5603,6 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "*" -"@testing-library/react@^13.0.0": - version "13.0.0" - resolved "https://registry.npmjs.org/@testing-library/react/-/react-13.0.0.tgz#8cdaf4667c6c2b082eb0513731551e9db784e8bc" - integrity sha512-p0lYA1M7uoEmk2LnCbZLGmHJHyH59sAaZVXChTXlyhV/PRW9LoIh4mdf7tiXsO8BoNG+vN8UnFJff1hbZeXv+w== - dependencies: - "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.5.0" - "@types/react-dom" "*" - -"@testing-library/user-event@^13.1.8": - version "13.5.0" - resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" - integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== - dependencies: - "@babel/runtime" "^7.12.5" - "@testing-library/user-event@^14.0.0": version "14.0.0" resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.0.0.tgz#3906aa6f0e56fd012d73559f5f05c02e63ba18dd" @@ -12267,6 +12191,7 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/app-defaults" "^1.0.1-next.1" "@backstage/catalog-model" "^1.0.1-next.0" "@backstage/cli" "^0.17.0-next.1" + "@backstage/config" "^1.0.0" "@backstage/core-app-api" "^1.0.1-next.0" "@backstage/core-components" "^0.9.3-next.0" "@backstage/core-plugin-api" "^1.0.0" @@ -12305,8 +12230,10 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/plugin-scaffolder" "^1.0.1-next.1" "@backstage/plugin-search" "^0.7.5-next.0" "@backstage/plugin-search-common" "^0.3.3-next.1" + "@backstage/plugin-search-react" "^0.0.0" "@backstage/plugin-sentry" "^0.3.42-next.0" "@backstage/plugin-shortcuts" "^0.2.5-next.0" + "@backstage/plugin-stack-overflow" "^0.1.0-next.0" "@backstage/plugin-tech-insights" "^0.1.14-next.0" "@backstage/plugin-tech-radar" "^0.5.11-next.1" "@backstage/plugin-techdocs" "^1.0.1-next.1" From f3bd34aa3b9b1a6c25b4e5722e98200e2ab34405 Mon Sep 17 00:00:00 2001 From: Jose Badeau Date: Wed, 6 Apr 2022 09:05:20 +0200 Subject: [PATCH 123/144] Added SIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jose Badeau Signed-off-by: Fredrik Adelöw --- ADOPTERS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 006dc071cd..c184d00267 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -26,7 +26,7 @@ _If you're using Backstage in your organization, please try to add your company | [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | | [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | | [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Matt Waldron](https://github.com/daftgopher) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [Peloton](https://www.onepeloton.com/) | [Matt Waldron](https://github.com/daftgopher) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | | [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | | [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | | [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | @@ -34,7 +34,7 @@ _If you're using Backstage in your organization, please try to add your company | [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | | [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | | [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | -| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process 🌕🚀🧑‍🚀 | +| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process 🌕🚀🧑‍🚀 | | [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | | [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | | [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | @@ -110,3 +110,4 @@ _If you're using Backstage in your organization, please try to add your company | [Beez Innovation Labs Pvt. Ltd](https://www.beezlabs.com/) | [Karthikeyan Venkatesan](https://github.com/karthikeyan23) | Developer portal with software catalog, scaffolding, tech docs, templates, and infra. | | [Agorapulse](https://www.agorapulse.com/) | [@jvdrean](https://github.com/jvdrean) | Developer portal with software catalog, documentation, monitoring, runbooks, tech radar and more. | | [Wistia](https://wistia.com/) | [@qrush](https://github.com/qrush), [@okize](https://github.com/okize) | Internal Developer Portal, service catalog, tech docs and more | +| [SIX](https://www.six-group.com/) | [@jbadeau](https://github.com/jbadeau), [@tomassatka](https://github.com/tomassatka) | Internal DevOps portal hosting our software and dataset catalog, as well as custom plugins for observability, service virtualization, deployments, incident managment and quality metrics. | From de6045a8645f6c6be56d8c451c107ea99f658ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 9 Apr 2022 14:33:41 +0200 Subject: [PATCH 124/144] add RBI as an adopter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index c184d00267..f6529e505c 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -111,3 +111,4 @@ _If you're using Backstage in your organization, please try to add your company | [Agorapulse](https://www.agorapulse.com/) | [@jvdrean](https://github.com/jvdrean) | Developer portal with software catalog, documentation, monitoring, runbooks, tech radar and more. | | [Wistia](https://wistia.com/) | [@qrush](https://github.com/qrush), [@okize](https://github.com/okize) | Internal Developer Portal, service catalog, tech docs and more | | [SIX](https://www.six-group.com/) | [@jbadeau](https://github.com/jbadeau), [@tomassatka](https://github.com/tomassatka) | Internal DevOps portal hosting our software and dataset catalog, as well as custom plugins for observability, service virtualization, deployments, incident managment and quality metrics. | +| [Raiffeisen Bank International](https://www.rbinternational.com/) | [Daniel Baumgartner](https://github.com/dabarbi) | From developers for developers: software catalog, techdocs and heavy use of scaffolder to drive reuse on engineering level forward. Part of inner source initiative. Multi national setup coming. | From d96e7f2f764046eba7a6a290cdc465de7bd59bee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 04:27:26 +0000 Subject: [PATCH 125/144] build(deps): bump @microsoft/api-extractor-model from 7.16.0 to 7.16.1 Bumps [@microsoft/api-extractor-model](https://github.com/microsoft/rushstack/tree/HEAD/libraries/api-extractor-model) from 7.16.0 to 7.16.1. - [Release notes](https://github.com/microsoft/rushstack/releases) - [Changelog](https://github.com/microsoft/rushstack/blob/main/libraries/api-extractor-model/CHANGELOG.md) - [Commits](https://github.com/microsoft/rushstack/commits/@microsoft/api-extractor-model_v7.16.1/libraries/api-extractor-model) --- updated-dependencies: - dependency-name: "@microsoft/api-extractor-model" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- yarn.lock | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index b9cf5fdd60..9b08e78fe5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4369,7 +4369,7 @@ "@microsoft/tsdoc-config" "~0.15.2" "@rushstack/node-core-library" "3.45.0" -"@microsoft/api-extractor-model@7.16.0", "@microsoft/api-extractor-model@^7.16.0": +"@microsoft/api-extractor-model@7.16.0": version "7.16.0" resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.0.tgz#3db7360897115f26a857f1f684fb5af82b0ef9f6" integrity sha512-0FOrbNIny8mzBrzQnSIkEjAXk0JMSnPmWYxt3ZDTPVg9S8xIPzB6lfgTg9+Mimu0RKCpGKBpd+v2WcR5vGzyUQ== @@ -4378,6 +4378,15 @@ "@microsoft/tsdoc-config" "~0.15.2" "@rushstack/node-core-library" "3.45.1" +"@microsoft/api-extractor-model@^7.16.0": + version "7.16.1" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.1.tgz#f6e53614e90c8a6be47bad5b91d49cc6d8c5c2bf" + integrity sha512-+1mlvy/ji+mUuH7WdVZ6fTo/aCKfS6m37aAFVOFWLfkMvmR+I9SjPfiv9qOg83If7GOrk2HPiHHibv6kA80VTg== + dependencies: + "@microsoft/tsdoc" "0.13.2" + "@microsoft/tsdoc-config" "~0.15.2" + "@rushstack/node-core-library" "3.45.2" + "@microsoft/api-extractor@^7.19.4": version "7.19.4" resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.19.4.tgz#95d43d410a1dfb28a02062c4693bcb9c52afe9eb" @@ -5270,6 +5279,21 @@ timsort "~0.3.0" z-schema "~5.0.2" +"@rushstack/node-core-library@3.45.2": + version "3.45.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.2.tgz#68fc89c5bea4007359fa4ff203bf3eca27037f40" + integrity sha512-MJKdB6mxOoIkks3htGVCo7aiTzllm2I6Xua+KbTSb0cp7rBp8gTCOF/4d8R4HFMwpRdEdwzKgqMM6k9rAK73iw== + dependencies: + "@types/node" "12.20.24" + colors "~1.2.1" + fs-extra "~7.0.1" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.17.0" + semver "~7.3.0" + timsort "~0.3.0" + z-schema "~5.0.2" + "@rushstack/rig-package@0.3.7": version "0.3.7" resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.7.tgz#3fa564b1d129d28689dd4309502792b15e84bf81" From e80ecad93c3b3dd3cebd7a293a72bb52b98d91d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Apr 2022 13:42:15 +0200 Subject: [PATCH 126/144] bump all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/clever-pumpkins-tease.md | 5 ++ package.json | 8 +- packages/cli/package.json | 2 +- yarn.lock | 113 ++++++++-------------------- 4 files changed, 40 insertions(+), 88 deletions(-) create mode 100644 .changeset/clever-pumpkins-tease.md diff --git a/.changeset/clever-pumpkins-tease.md b/.changeset/clever-pumpkins-tease.md new file mode 100644 index 0000000000..d0784361ff --- /dev/null +++ b/.changeset/clever-pumpkins-tease.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Bump the `rushstack` api generator libraries to their latest versions diff --git a/package.json b/package.json index dc72b8974b..6fea4e081a 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,10 @@ "version": "1.1.0-next.2", "dependencies": { "@manypkg/get-packages": "^1.1.3", - "@microsoft/api-documenter": "^7.17.0", - "@microsoft/api-extractor": "^7.19.4", - "@microsoft/api-extractor-model": "^7.16.0", - "@microsoft/tsdoc": "^0.13.2" + "@microsoft/api-documenter": "^7.17.5", + "@microsoft/api-extractor": "^7.21.2", + "@microsoft/api-extractor-model": "^7.16.1", + "@microsoft/tsdoc": "^0.14.1" }, "devDependencies": { "@changesets/cli": "^2.14.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index e362caf40e..c9818e48b7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -153,7 +153,7 @@ "ts-node": "^10.0.0" }, "peerDependencies": { - "@microsoft/api-extractor": "^7.19.2" + "@microsoft/api-extractor": "^7.21.2" }, "peerDependenciesMeta": { "@microsoft/api-extractor": { diff --git a/yarn.lock b/yarn.lock index 9b08e78fe5..76e6ada9c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4347,38 +4347,20 @@ dependencies: "@types/gapi.client" "*" -"@microsoft/api-documenter@^7.17.0": - version "7.17.0" - resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.17.0.tgz#7d2b7775b37775fc7491b94cb5fa13ee8271fcc3" - integrity sha512-rIZcfDkBsWsflvN1n0tVgIUdivV7slBIsN0WyK8gUG1MjK7dv+rbAB5QPLhg3dPvbXLgPLanJCFwoO2yFdxs+A== +"@microsoft/api-documenter@^7.17.5": + version "7.17.5" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.17.5.tgz#cdaec54ad04d66b6f4c6f797f4120ff8c844a82d" + integrity sha512-zyJS3qORQP33S8EU0YPZb/JLIbMoslzj1bg8VmgfYCnxhX+3/FmlrTqDCwA9h2TR4bu1r7aQ26AfjUbBUz9+EA== dependencies: - "@microsoft/api-extractor-model" "7.16.0" + "@microsoft/api-extractor-model" "7.16.1" "@microsoft/tsdoc" "0.13.2" - "@rushstack/node-core-library" "3.45.1" - "@rushstack/ts-command-line" "4.10.7" + "@rushstack/node-core-library" "3.45.2" + "@rushstack/ts-command-line" "4.10.8" colors "~1.2.1" js-yaml "~3.13.1" resolve "~1.17.0" -"@microsoft/api-extractor-model@7.15.3": - version "7.15.3" - resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.15.3.tgz#cf76deeeb2733d974da678f530c2dbaceb18a065" - integrity sha512-NkSjolmSI7NGvbdz0Y7kjQfdpD+j9E5CwXTxEyjDqxd10MI7GXV8DnAsQ57GFJcgHKgTjf2aUnYfMJ9w3aMicw== - dependencies: - "@microsoft/tsdoc" "0.13.2" - "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.0" - -"@microsoft/api-extractor-model@7.16.0": - version "7.16.0" - resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.0.tgz#3db7360897115f26a857f1f684fb5af82b0ef9f6" - integrity sha512-0FOrbNIny8mzBrzQnSIkEjAXk0JMSnPmWYxt3ZDTPVg9S8xIPzB6lfgTg9+Mimu0RKCpGKBpd+v2WcR5vGzyUQ== - dependencies: - "@microsoft/tsdoc" "0.13.2" - "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.1" - -"@microsoft/api-extractor-model@^7.16.0": +"@microsoft/api-extractor-model@7.16.1", "@microsoft/api-extractor-model@^7.16.1": version "7.16.1" resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.1.tgz#f6e53614e90c8a6be47bad5b91d49cc6d8c5c2bf" integrity sha512-+1mlvy/ji+mUuH7WdVZ6fTo/aCKfS6m37aAFVOFWLfkMvmR+I9SjPfiv9qOg83If7GOrk2HPiHHibv6kA80VTg== @@ -4387,17 +4369,17 @@ "@microsoft/tsdoc-config" "~0.15.2" "@rushstack/node-core-library" "3.45.2" -"@microsoft/api-extractor@^7.19.4": - version "7.19.4" - resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.19.4.tgz#95d43d410a1dfb28a02062c4693bcb9c52afe9eb" - integrity sha512-iehC6YA3DGJvxTUaK7HUtQmP6hAQU07+Q/OR8TG4dVR6KpqCi9UPEVk8AgCvQkiK+6FbVEFQTx0qLuYk4EeuHg== +"@microsoft/api-extractor@^7.21.2": + version "7.21.2" + resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.21.2.tgz#0f524e7e1cf7b000924772fb8f6b5f60cf42fba8" + integrity sha512-m0+YPaXVou01O/V9swugZG7Gn4mw6HSWY+uisf0j2JPRZcoEDyoYe4hg0ERKXOEf0hByOnMLT28nQ82v8ig9Yw== dependencies: - "@microsoft/api-extractor-model" "7.15.3" + "@microsoft/api-extractor-model" "7.16.1" "@microsoft/tsdoc" "0.13.2" "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.0" - "@rushstack/rig-package" "0.3.7" - "@rushstack/ts-command-line" "4.10.6" + "@rushstack/node-core-library" "3.45.2" + "@rushstack/rig-package" "0.3.9" + "@rushstack/ts-command-line" "4.10.8" colors "~1.2.1" lodash "~4.17.15" resolve "~1.17.0" @@ -4425,11 +4407,16 @@ jju "~1.4.0" resolve "~1.19.0" -"@microsoft/tsdoc@0.13.2", "@microsoft/tsdoc@^0.13.2": +"@microsoft/tsdoc@0.13.2": version "0.13.2" resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.13.2.tgz#3b0efb6d3903bd49edb073696f60e90df08efb26" integrity sha512-WrHvO8PDL8wd8T2+zBGKrMwVL5IyzR3ryWUsl0PXgEV0QHup4mTLi0QcATefGI6Gx9Anu7vthPyyyLpY0EpiQg== +"@microsoft/tsdoc@^0.14.1": + version "0.14.1" + resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd" + integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw== + "@mswjs/cookies@^0.1.6", "@mswjs/cookies@^0.1.7": version "0.1.7" resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.7.tgz#d334081b2c51057a61c1dd7b76ca3cac02251651" @@ -5249,36 +5236,6 @@ estree-walker "^2.0.1" picomatch "^2.2.2" -"@rushstack/node-core-library@3.45.0": - version "3.45.0" - resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.0.tgz#8c86b39271b6d84260b1e70db87e1e265b54f620" - integrity sha512-YMuIJl19vQT1+g/OU9mLY6T5ZBT9uDlmeXExDQACpGuxTJW+LHNbk/lRX+eCApQI2eLBlaL4U68r3kZlqwbdmw== - dependencies: - "@types/node" "12.20.24" - colors "~1.2.1" - fs-extra "~7.0.1" - import-lazy "~4.0.0" - jju "~1.4.0" - resolve "~1.17.0" - semver "~7.3.0" - timsort "~0.3.0" - z-schema "~5.0.2" - -"@rushstack/node-core-library@3.45.1": - version "3.45.1" - resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.1.tgz#787361b61a48d616eb4b059641721a3dc138f001" - integrity sha512-BwdssTNe007DNjDBxJgInHg8ePytIPyT0La7ZZSQZF9+rSkT42AygXPGvbGsyFfEntjr4X37zZSJI7yGzL16cQ== - dependencies: - "@types/node" "12.20.24" - colors "~1.2.1" - fs-extra "~7.0.1" - import-lazy "~4.0.0" - jju "~1.4.0" - resolve "~1.17.0" - semver "~7.3.0" - timsort "~0.3.0" - z-schema "~5.0.2" - "@rushstack/node-core-library@3.45.2": version "3.45.2" resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.2.tgz#68fc89c5bea4007359fa4ff203bf3eca27037f40" @@ -5294,28 +5251,18 @@ timsort "~0.3.0" z-schema "~5.0.2" -"@rushstack/rig-package@0.3.7": - version "0.3.7" - resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.7.tgz#3fa564b1d129d28689dd4309502792b15e84bf81" - integrity sha512-pzMsTSeTC8IiZ6EJLr53gGMvhT4oLWH+hxD7907cHyWuIUlEXFtu/2pK25vUQT13nKp5DJCWxXyYoGRk/h6rtA== +"@rushstack/rig-package@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.9.tgz#5e10ada5a8348f886b6ebe3eed436492d6ccf70c" + integrity sha512-z3Oxpfb4n9mGXwseX+ifpkmUf9B8Fy8oieVwg8eFgpCbzllkgOwEiwLKEnRWVQ8owFcd46NCKz+7ICH35CRsAw== dependencies: resolve "~1.17.0" strip-json-comments "~3.1.1" -"@rushstack/ts-command-line@4.10.6": - version "4.10.6" - resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.6.tgz#5669e481e4339ceb4e1428183eb0937d3bc3841b" - integrity sha512-Y3GkUag39sTIlukDg9mUp8MCHrrlJ27POrBNRQGc/uF+VVgX8M7zMzHch5zP6O1QVquWgD7Engdpn2piPYaS/g== - dependencies: - "@types/argparse" "1.0.38" - argparse "~1.0.9" - colors "~1.2.1" - string-argv "~0.3.1" - -"@rushstack/ts-command-line@4.10.7": - version "4.10.7" - resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.7.tgz#21e3757a756cbd4f7eeab8f89ec028a64d980efc" - integrity sha512-CjS+DfNXUSO5Ab2wD1GBGtUTnB02OglRWGqfaTcac9Jn45V5MeUOsq/wA8wEeS5Y/3TZ2P1k+IWdVDiuOFP9Og== +"@rushstack/ts-command-line@4.10.8": + version "4.10.8" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.8.tgz#f4bec690e7f4838e6f91028d8a557e67c3a75f68" + integrity sha512-G7CQYY/m3aZU5fVxbebv35yDeua7sSumrDAB2pJp0d60ZEsxGkUQW8771CeMcGWwSKqT9PxPzKpmIakiWv54sA== dependencies: "@types/argparse" "1.0.38" argparse "~1.0.9" From ab230a433f02c0a6c1cf92169839fe826bc40a2c Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 14:19:48 +0200 Subject: [PATCH 127/144] add changesets Signed-off-by: Emma Indal --- .changeset/breezy-mugs-build.md | 5 +++++ .changeset/clever-buckets-doubt.md | 5 +++++ .changeset/rude-bees-rest.md | 5 +++++ .changeset/thirty-sloths-knock.md | 11 +++++++++++ plugins/home/package.json | 1 - 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/breezy-mugs-build.md create mode 100644 .changeset/clever-buckets-doubt.md create mode 100644 .changeset/rude-bees-rest.md create mode 100644 .changeset/thirty-sloths-knock.md diff --git a/.changeset/breezy-mugs-build.md b/.changeset/breezy-mugs-build.md new file mode 100644 index 0000000000..026932e971 --- /dev/null +++ b/.changeset/breezy-mugs-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Export template logos `TemplateBackstageLogo` and `TemplateBackstageLogoIcon` from package. diff --git a/.changeset/clever-buckets-doubt.md b/.changeset/clever-buckets-doubt.md new file mode 100644 index 0000000000..751db72bf8 --- /dev/null +++ b/.changeset/clever-buckets-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +imports from `@backstage/plugin-search-react` instead of `@backstage/plugin-search` diff --git a/.changeset/rude-bees-rest.md b/.changeset/rude-bees-rest.md new file mode 100644 index 0000000000..793b130b35 --- /dev/null +++ b/.changeset/rude-bees-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +New search package to hold things the search plugin itself and other frontend plugins (e.g. techdocs, home) depend on. diff --git a/.changeset/thirty-sloths-knock.md b/.changeset/thirty-sloths-knock.md new file mode 100644 index 0000000000..1ad559e368 --- /dev/null +++ b/.changeset/thirty-sloths-knock.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-search': patch +--- + +The following exports has been moved to `@backstage/plugin-search-react` and will be removed in the next release. import from `@backstage/plugin-search-react` instead. + +- `SearchApi` interface. +- `searchApiRef` +- `SearchContext` +- `SearchContextProvider` +- `useSearch` diff --git a/plugins/home/package.json b/plugins/home/package.json index e59e7a39bb..aac39c397a 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -38,7 +38,6 @@ "@backstage/core-components": "^0.9.3-next.1", "@backstage/core-plugin-api": "^1.0.0", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/theme": "^0.2.15", "@backstage/config": "^1.0.0", From b4c5585234bf914a7b8902a096aac7c4a8e0d698 Mon Sep 17 00:00:00 2001 From: djamaile Date: Mon, 11 Apr 2022 14:42:13 +0200 Subject: [PATCH 128/144] test: add triggeredBy to testcases to prevent build from breaking Signed-off-by: djamaile --- plugins/cicd-statistics-module-gitlab/package.json | 4 ++-- plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json index 280282dd04..b36bcf2476 100644 --- a/plugins/cicd-statistics-module-gitlab/package.json +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -13,11 +13,11 @@ "types": "dist/index.d.ts" }, "backstage": { - "role": "common-library" + "role": "frontend-plugin-module" }, "keywords": [ "backstage", - "cicd statestics", + "cicd statistics", "gitlab" ], "scripts": { diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts index 338b92bf4f..fda5e68540 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts @@ -64,6 +64,7 @@ describe('util functionality', () => { branchType: 'master', duration: 0, requestedAt: new Date('2022-03-30T13:03:09.846Z'), + triggeredBy: 'internal', stages: [], }, ]); From f3a72b172a2e79eb9caf335aaa9cdc0c44e7cf9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Apr 2022 14:57:12 +0200 Subject: [PATCH 129/144] bump moment to fix vulnerability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b9cf5fdd60..4a252715d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18118,9 +18118,9 @@ moment-timezone@^0.5.x: moment ">= 2.9.0" "moment@>= 2.9.0", moment@>=2.14.0, moment@^2.27.0, moment@^2.29.1: - version "2.29.1" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + version "2.29.2" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== morgan@^1.10.0: version "1.10.0" From f4cdf4cac1a8f9c1f86efb0cc66f907758b1d66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Apr 2022 15:06:35 +0200 Subject: [PATCH 130/144] Defensively encode URL parameters when fetching ELB keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/ninety-islands-report.md | 5 +++++ plugins/auth-backend/src/providers/aws-alb/provider.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/ninety-islands-report.md diff --git a/.changeset/ninety-islands-report.md b/.changeset/ninety-islands-report.md new file mode 100644 index 0000000000..4a515b75cd --- /dev/null +++ b/.changeset/ninety-islands-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Defensively encode URL parameters when fetching ELB keys diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 12f7c7f4b4..5206dc125c 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -211,8 +211,10 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { if (optionalCacheKey) { return crypto.createPublicKey(optionalCacheKey); } - const keyText: string = await fetch( - `https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`, + const keyText = await fetch( + `https://public-keys.auth.elb.${encodeURIComponent( + this.region, + )}.amazonaws.com/${encodeURIComponent(keyId)}`, ).then(response => response.text()); const keyValue = crypto.createPublicKey(keyText); this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' })); From 612810586b4c62538c642ca36000bbf07b4c90fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Apr 2022 15:15:10 +0200 Subject: [PATCH 131/144] bump ansi-regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b9cf5fdd60..b61448cad9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7551,9 +7551,9 @@ ansi-regex@^2.0.0: integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + version "3.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== ansi-regex@^4.1.0: version "4.1.0" From f8b0197fc2aaa8d5e780f0199a11654218fe3e64 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 11 Apr 2022 15:18:21 +0200 Subject: [PATCH 132/144] docs: add section to help decide where to place code Signed-off-by: Patrik Oldsberg --- .../package-decision.drawio.svg | 269 ++++++++++++++++++ docs/overview/architecture-overview.md | 12 + 2 files changed, 281 insertions(+) create mode 100644 docs/assets/architecture-overview/package-decision.drawio.svg diff --git a/docs/assets/architecture-overview/package-decision.drawio.svg b/docs/assets/architecture-overview/package-decision.drawio.svg new file mode 100644 index 0000000000..e5199adf3f --- /dev/null +++ b/docs/assets/architecture-overview/package-decision.drawio.svg @@ -0,0 +1,269 @@ + + + + + + + + + +
+
+
+ No +
+
+
+
+ + No + +
+
+ + + + + +
+
+
+ Yes +
+
+
+
+ + Yes + +
+
+ + + + +
+
+
+ Is the new addition public API? +
+ i.e. exported from the package +
+
+
+
+ + Is the new addition public API?... + +
+
+ + + + + + +
+
+
+ In what plugin package should I put my code? +
+
+
+
+ + In what plugin package sho... + +
+
+ + + + +
+
+
+ Put it in the package +
+ that uses it +
+
+
+
+ + Put it in the package... + +
+
+ + + + + +
+
+
+ Only app/backend +
+
+
+
+ + Only app/backend + +
+
+ + + + + + +
+
+
+ Is the export supposed +
+ to be used by other plugins or just app/backend packages? +
+
+
+
+ + Is the export supposed... + +
+
+ + + + +
+
+
+ Put it in the frontend or backend plugin package +
+
+
+
+ + Put it in the frontend or... + +
+
+ + + + + +
+
+
+ No +
+
+
+
+ + No + +
+
+ + + + + +
+
+
+ Yes +
+
+
+
+ + Yes + +
+
+ + + + +
+
+
+ Should the export be +
+ usable by both Node.js and browser packages? +
+
+
+
+ + Should the export be... + +
+
+ + + +
+
+
+ Yes, used by other plugins +
+
+
+
+ + Yes, used by other plugins + +
+
+ + + + +
+
+
+ Put frontend exports in <plugin>-react, and backend exports in <plugin>-node +
+
+
+
+ + Put frontend exports in <p... + +
+
+ + + + +
+
+
+ Add it to <plugin>-common, but be sure to support both Node.js and web environments +
+
+
+
+ + Add it to <plugin>-common, but be... + +
+
+ +
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 5b6b81cf10..382399e603 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -256,6 +256,18 @@ The Backstage CLI is in a category of its own and is depended on by virtually all other packages. It's not a library in itself though, and must always be a development dependency only. +### Deciding where you place your code + +It can sometimes be difficult to decide where to place your plugin code. For example +should it go directly in the `-backend` plugin package or in the `-node` package? +As a rule of thumb you should try to keep the exposure of your code as low +as possible. If it doesn't need to be public API, it's best to avoid. If you don't +need it to be used by other plugins, then keep it directly in the plugin packages. + +Below is a chart to help you decide where to place your code. + +![Package decision](../assets/architecture-overview/package-decision.drawio.svg) + ## Databases As we have seen, both the `lighthouse-audit-service` and `catalog-backend` From 43759dd789e0bf750fb939cf864a96a048731de2 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 11 Apr 2022 15:43:43 +0200 Subject: [PATCH 133/144] create-app: Remove octokit/rest from dependencies section Signed-off-by: Johan Haals --- .changeset/light-dragons-crash.md | 5 +++++ .../templates/default-app/packages/backend/package.json.hbs | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/light-dragons-crash.md diff --git a/.changeset/light-dragons-crash.md b/.changeset/light-dragons-crash.md new file mode 100644 index 0000000000..869e590783 --- /dev/null +++ b/.changeset/light-dragons-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Remove `@octokit/rest` from dependencies. diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index b91366dee8..5b5e865541 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -36,7 +36,6 @@ "@backstage/plugin-search-backend-node": "^{{version '@backstage/plugin-search-backend-node'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", "@gitbeaker/node": "^34.6.0", - "@octokit/rest": "^18.5.3", "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", From 47f0af3410774b8a4b7288f1fb21aa78a528e2c1 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 11 Apr 2022 16:11:44 +0200 Subject: [PATCH 134/144] remove gitbeaker node, update changeset Signed-off-by: Johan Haals --- .changeset/light-dragons-crash.md | 10 +++++++++- .../default-app/packages/backend/package.json.hbs | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/light-dragons-crash.md b/.changeset/light-dragons-crash.md index 869e590783..35dea73ae6 100644 --- a/.changeset/light-dragons-crash.md +++ b/.changeset/light-dragons-crash.md @@ -2,4 +2,12 @@ '@backstage/create-app': patch --- -Remove `@octokit/rest` from dependencies. +Removed `@octokit/rest` and `@gitbeaker/node` from backend dependencies as these are unused in the default app. + +To apply these changes to your existing app, remove the following lines from the `dependencies` section of `packages/backend/package.json` + +```diff + "@backstage/plugin-techdocs-backend": "^1.0.0", +- "@gitbeaker/node": "^34.6.0", +- "@octokit/rest": "^18.5.3", +``` diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 5b5e865541..939b13bf86 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -35,7 +35,6 @@ {{/if}} "@backstage/plugin-search-backend-node": "^{{version '@backstage/plugin-search-backend-node'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", - "@gitbeaker/node": "^34.6.0", "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", From 0504eec0d67d3a988a34056b080a3c7e184d6e0a Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Mon, 11 Apr 2022 16:02:59 +0200 Subject: [PATCH 135/144] Add Spread Group to adopters Signed-off-by: Luna Stadler --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index f6529e505c..1a1c3686d4 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -112,3 +112,4 @@ _If you're using Backstage in your organization, please try to add your company | [Wistia](https://wistia.com/) | [@qrush](https://github.com/qrush), [@okize](https://github.com/okize) | Internal Developer Portal, service catalog, tech docs and more | | [SIX](https://www.six-group.com/) | [@jbadeau](https://github.com/jbadeau), [@tomassatka](https://github.com/tomassatka) | Internal DevOps portal hosting our software and dataset catalog, as well as custom plugins for observability, service virtualization, deployments, incident managment and quality metrics. | | [Raiffeisen Bank International](https://www.rbinternational.com/) | [Daniel Baumgartner](https://github.com/dabarbi) | From developers for developers: software catalog, techdocs and heavy use of scaffolder to drive reuse on engineering level forward. Part of inner source initiative. Multi national setup coming. | +| [Spread Group](https://www.spreadgroup.com/) | [Luna Stadler](https://github.com/heyLu), [Iván González](https://github.com/ivangonzalezacuna) | Internal Developer Portal, an overview of all running software, architecture documentation and more; replacing and unifying a variety of internal tools. | From b7d035cc29ea5db46fe36f5fd4799cdbaa849c18 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 11 Apr 2022 16:40:47 +0200 Subject: [PATCH 136/144] feedback fixups Signed-off-by: Emma Indal --- .changeset/rude-bees-rest.md | 2 +- plugins/home/api-report.md | 8 +++---- .../home/src/assets/TemplateBackstageLogo.tsx | 6 ++++- plugins/search-react/README.md | 4 +++- plugins/search-react/api-report.md | 9 +++----- plugins/search-react/package.json | 4 ++-- .../src/context/SearchContext.tsx | 5 +++- plugins/search-react/src/index.ts | 11 +++++++-- plugins/search/api-report.md | 3 --- plugins/search/src/apis.ts | 4 ++-- .../SearchContext/SearchContext.tsx | 4 ++-- yarn.lock | 23 ------------------- 12 files changed, 35 insertions(+), 48 deletions(-) diff --git a/.changeset/rude-bees-rest.md b/.changeset/rude-bees-rest.md index 793b130b35..7ca4e7492c 100644 --- a/.changeset/rude-bees-rest.md +++ b/.changeset/rude-bees-rest.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-search-react': patch +'@backstage/plugin-search-react': minor --- New search package to hold things the search plugin itself and other frontend plugins (e.g. techdocs, home) depend on. diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 21c5592636..a65f8cda5c 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -119,12 +119,13 @@ export const SettingsModal: (props: { children: JSX.Element; }) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "TemplateBackstageLogoProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "TemplateBackstageLogo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const TemplateBackstageLogo: (props: { - classes: Classes; -}) => JSX.Element; +export const TemplateBackstageLogo: ( + props: TemplateBackstageLogoProps, +) => JSX.Element; // Warning: (ae-missing-release-tag) "TemplateBackstageLogoIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -136,7 +137,6 @@ export const WelcomeTitle: () => JSX.Element; // Warnings were encountered during analysis: // -// src/assets/TemplateBackstageLogo.d.ts:7:5 - (ae-forgotten-export) The symbol "Classes" needs to be exported by the entry point index.d.ts // src/extensions.d.ts:6:5 - (ae-forgotten-export) The symbol "RendererProps" needs to be exported by the entry point index.d.ts // src/extensions.d.ts:27:5 - (ae-forgotten-export) The symbol "ComponentParts" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/home/src/assets/TemplateBackstageLogo.tsx b/plugins/home/src/assets/TemplateBackstageLogo.tsx index 0fc908ee39..9088cfa58c 100644 --- a/plugins/home/src/assets/TemplateBackstageLogo.tsx +++ b/plugins/home/src/assets/TemplateBackstageLogo.tsx @@ -21,7 +21,11 @@ type Classes = { path: string; }; -export const TemplateBackstageLogo = (props: { classes: Classes }) => { +type TemplateBackstageLogoProps = { + classes: Classes; +}; + +export const TemplateBackstageLogo = (props: TemplateBackstageLogoProps) => { return ( ; -// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export const SearchContext: React_2.Context; - // @public (undocumented) export const SearchContextProvider: ({ initialState, @@ -49,7 +44,7 @@ export const SearchContextProviderForStorybook: ( props: ComponentProps & QueryResultProps, ) => JSX.Element; -// @public +// @public (undocumented) export type SearchContextState = { term: string; types: string[]; @@ -57,6 +52,8 @@ export type SearchContextState = { pageCursor?: string; }; +// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts +// // @public (undocumented) export const useSearch: () => SearchContextValue; diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 15251b4ac7..151660943e 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -44,8 +44,8 @@ "devDependencies": { "@backstage/test-utils": "^1.0.1-next.1", "@testing-library/react": "^12.1.3", - "@testing-library/react-hooks": "^8.0.0", - "@testing-library/jest-dom": "^5.16.4" + "@testing-library/react-hooks": "^7.0.2", + "@testing-library/jest-dom": "^5.10.1" }, "files": [ "dist" diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index 217c94cca2..d6245d5ccf 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -40,7 +40,6 @@ type SearchContextValue = { } & SearchContextState; /** - * The initial state of `SearchContextProvider`. * * @public */ @@ -58,6 +57,10 @@ export const SearchContext = createContext( undefined, ); +/** + * The initial state of `SearchContextProvider`. + * + */ const searchInitialState: SearchContextState = { term: '', pageCursor: undefined, diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts index 498e4d6ce3..934cf2e9f1 100644 --- a/plugins/search-react/src/index.ts +++ b/plugins/search-react/src/index.ts @@ -14,5 +14,12 @@ * limitations under the License. */ -export * from './api'; -export * from './context'; +export { searchApiRef } from './api'; +export type { SearchApi } from './api'; +export { + SearchContextProvider, + useSearch, + SearchContextProviderForStorybook, + SearchApiProviderForStorybook, +} from './context'; +export type { SearchContextState } from './context'; diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index f524652f51..d85158c925 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -81,7 +81,6 @@ export type HomePageSearchBarProps = Partial< // @public (undocumented) export const Router: () => JSX.Element; -// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "SearchApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) @@ -90,7 +89,6 @@ export interface SearchApi { query(query: SearchQuery): Promise; } -// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "searchApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) @@ -140,7 +138,6 @@ export const SearchBarNext: ({ // @public export type SearchBarProps = Partial; -// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 62f65dbe4a..096b11bb5e 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -25,14 +25,14 @@ import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; import qs from 'qs'; /** - * @deprecated import from "@backstage/plugin-search-react" instead + * @deprecated import from `@backstage/plugin-search-react` instead */ export const searchApiRef = createApiRef({ id: 'plugin.search.queryservice', }); /** - * @deprecated import from "@backstage/plugin-search-react" instead + * @deprecated import from `@backstage/plugin-search-react` instead */ export interface SearchApi { query(query: SearchQuery): Promise; diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index d52e43af88..c87295ab99 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -52,7 +52,7 @@ export type SearchContextState = { }; /** - * @deprecated import from "@backstage/plugin-search-react" instead + * @deprecated import from `@backstage/plugin-search-react` instead */ export const SearchContext = createContext( undefined, @@ -66,7 +66,7 @@ const searchInitialState: SearchContextState = { }; /** - * @deprecated import from "@backstage/plugin-search-react" instead + * @deprecated import from `@backstage/plugin-search-react` instead */ export const SearchContextProvider = ({ initialState = searchInitialState, diff --git a/yarn.lock b/yarn.lock index 223c451347..076b9f222a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5560,21 +5560,6 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/jest-dom@^5.16.4": - version "5.16.4" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd" - integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA== - dependencies: - "@babel/runtime" "^7.9.2" - "@types/testing-library__jest-dom" "^5.9.1" - aria-query "^5.0.0" - chalk "^3.0.0" - css "^3.0.0" - css.escape "^1.5.1" - dom-accessibility-api "^0.5.6" - lodash "^4.17.15" - redent "^3.0.0" - "@testing-library/react-hooks@^7.0.2": version "7.0.2" resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" @@ -5586,14 +5571,6 @@ "@types/react-test-renderer" ">=16.9.0" react-error-boundary "^3.1.0" -"@testing-library/react-hooks@^8.0.0": - version "8.0.0" - resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf" - integrity sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw== - dependencies: - "@babel/runtime" "^7.12.5" - react-error-boundary "^3.1.0" - "@testing-library/react@^12.1.3": version "12.1.4" resolved "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" From cc41a55cec6c86b101af4215b56b288a5634fec3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 05:48:59 +0000 Subject: [PATCH 137/144] build(deps): bump @microsoft/microsoft-graph-types from 2.16.0 to 2.18.0 Bumps [@microsoft/microsoft-graph-types](https://github.com/microsoftgraph/msgraph-typescript-typings) from 2.16.0 to 2.18.0. - [Release notes](https://github.com/microsoftgraph/msgraph-typescript-typings/releases) - [Commits](https://github.com/microsoftgraph/msgraph-typescript-typings/compare/2.16.0...2.18.0) --- updated-dependencies: - dependency-name: "@microsoft/microsoft-graph-types" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 46db212a0d..b9222f3014 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4402,9 +4402,9 @@ integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA== "@microsoft/microsoft-graph-types@^2.6.0": - version "2.16.0" - resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-2.16.0.tgz#5329890d230c4fdd9f57f39e26dfade0882c94f3" - integrity sha512-Qvxv9mpXb/F4xlESEkSLjREHj3dAixTkH3LVO6Ct6sllc5RWrQxPxaSGqW9IpcLU6jI49f2XNSGLotVef3Irdg== + version "2.18.0" + resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-2.18.0.tgz#b2ebdd55c149dfc11cca41cbf1f2082a56242450" + integrity sha512-cWiK0oaz+RrcL6EKfBeoai28L8jWJ1n+nS5cGUgRR4+POk5W5/8DV3Vs3gUNaFwmAqfYrhskyb/SNyytgAC78Q== "@microsoft/tsdoc-config@~0.15.2": version "0.15.2" From 3c26b2edb543abfccdb555b4d1753e60cf4b482a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 05:49:56 +0000 Subject: [PATCH 138/144] build(deps): bump npm-packlist from 3.0.0 to 5.0.0 Bumps [npm-packlist](https://github.com/npm/npm-packlist) from 3.0.0 to 5.0.0. - [Release notes](https://github.com/npm/npm-packlist/releases) - [Changelog](https://github.com/npm/npm-packlist/blob/main/CHANGELOG.md) - [Commits](https://github.com/npm/npm-packlist/compare/v3.0.0...v5.0.0) --- updated-dependencies: - dependency-name: npm-packlist dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .changeset/dependabot-cccf2f0.md | 5 +++++ packages/cli/package.json | 2 +- yarn.lock | 27 ++++++++++++++++++++++----- 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 .changeset/dependabot-cccf2f0.md diff --git a/.changeset/dependabot-cccf2f0.md b/.changeset/dependabot-cccf2f0.md new file mode 100644 index 0000000000..6b420f9a48 --- /dev/null +++ b/.changeset/dependabot-cccf2f0.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +build(deps): bump `npm-packlist` from 3.0.0 to 5.0.0 diff --git a/packages/cli/package.json b/packages/cli/package.json index e362caf40e..b793f683db 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -93,7 +93,7 @@ "mini-css-extract-plugin": "^2.4.2", "minimatch": "5.0.1", "node-libs-browser": "^2.2.1", - "npm-packlist": "^3.0.0", + "npm-packlist": "^5.0.0", "ora": "^5.3.0", "postcss": "^8.1.0", "process": "^0.11.10", diff --git a/yarn.lock b/yarn.lock index 46db212a0d..b0ccad00f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14217,6 +14217,13 @@ ignore-walk@^4.0.1: dependencies: minimatch "^3.0.4" +ignore-walk@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-5.0.1.tgz#5f199e23e1288f518d90358d461387788a154776" + integrity sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw== + dependencies: + minimatch "^5.0.1" + ignore@^3.3.5: version "3.3.10" resolved "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -17932,7 +17939,7 @@ minimatch@3.0.4: dependencies: brace-expansion "^1.1.7" -minimatch@5.0.1, minimatch@^5.0.0: +minimatch@5.0.1, minimatch@^5.0.0, minimatch@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== @@ -18592,10 +18599,10 @@ normalize-url@^6.0.1: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== -npm-bundled@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== +npm-bundled@^1.1.1, npm-bundled@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" + integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ== dependencies: npm-normalize-package-bin "^1.0.1" @@ -18654,6 +18661,16 @@ npm-packlist@^3.0.0: npm-bundled "^1.1.1" npm-normalize-package-bin "^1.0.1" +npm-packlist@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-5.0.0.tgz#74795ebbbf91bd5a2db6ecff4d6fe1f1c1a07e11" + integrity sha512-uU20UwM4Hogfab1Q7htJbhcyafM9lGHxOrDjkKvR2S3z7Ds0uRaESk0cXctczk+ABT4DZWNwjB10xlurFdEwZg== + dependencies: + glob "^7.2.0" + ignore-walk "^5.0.1" + npm-bundled "^1.1.2" + npm-normalize-package-bin "^1.0.1" + npm-pick-manifest@^6.0.0, npm-pick-manifest@^6.1.0, npm-pick-manifest@^6.1.1: version "6.1.1" resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-6.1.1.tgz#7b5484ca2c908565f43b7f27644f36bb816f5148" From bde10e093d4dba1665de5659f45f6e81e5461038 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 05:51:17 +0000 Subject: [PATCH 139/144] build(deps): bump react-hook-form from 7.28.1 to 7.29.0 Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.28.1 to 7.29.0. - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.28.1...v7.29.0) --- updated-dependencies: - dependency-name: react-hook-form dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 46db212a0d..ec2f0533bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20943,9 +20943,9 @@ react-helmet@6.1.0: react-side-effect "^2.1.0" react-hook-form@^7.12.2, react-hook-form@^7.13.0: - version "7.28.1" - resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.28.1.tgz#95fc37be6c6b9d57212eb7eca055fb36d079b3b7" - integrity sha512-mgwxvXuvt3FMY/mdnWbPc++Zf1U5xYzkhOaL05mtFMLvXc9MvUhMUlKtUVuO12sOrgT3nPXBgVFawtiJ4ONrgg== + version "7.29.0" + resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.29.0.tgz#5e7e41a483b70731720966ed8be52163ea1fecf1" + integrity sha512-NcJqWRF6el5HMW30fqZRt27s+lorvlCCDbTpAyHoodQeYWXgQCvZJJQLC1kRMKdrJknVH0NIg3At6TUzlZJFOQ== react-hot-loader@^4.13.0: version "4.13.0" From 7336fcf2fb7f88c6b3c30c42357feb3f5d112002 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Apr 2022 05:53:14 +0000 Subject: [PATCH 140/144] build(deps): bump @google-cloud/storage from 5.18.3 to 5.19.1 Bumps [@google-cloud/storage](https://github.com/googleapis/nodejs-storage) from 5.18.3 to 5.19.1. - [Release notes](https://github.com/googleapis/nodejs-storage/releases) - [Changelog](https://github.com/googleapis/nodejs-storage/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/nodejs-storage/compare/v5.18.3...v5.19.1) --- updated-dependencies: - dependency-name: "@google-cloud/storage" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/yarn.lock b/yarn.lock index 46db212a0d..6bb92b8ac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2224,21 +2224,6 @@ qs "^6.10.1" xcase "^2.0.1" -"@google-cloud/common@^3.8.1": - version "3.10.0" - resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.10.0.tgz#454d1155bb512109cd83c6183aabbd39f9aabda7" - integrity sha512-XMbJYMh/ZSaZnbnrrOFfR/oQrb0SxG4qh6hDisWCoEbFcBHV0qHQo4uXfeMCzolx2Mfkh6VDaOGg+hyJsmxrlw== - dependencies: - "@google-cloud/projectify" "^2.0.0" - "@google-cloud/promisify" "^2.0.0" - arrify "^2.0.1" - duplexify "^4.1.1" - ent "^2.2.0" - extend "^3.0.2" - google-auth-library "^7.14.0" - retry-request "^4.2.2" - teeny-request "^7.0.0" - "@google-cloud/container@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@google-cloud/container/-/container-3.0.0.tgz#b28d086152ac19f22f2574b7e0a39774379fb885" @@ -2275,12 +2260,12 @@ integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw== "@google-cloud/storage@^5.6.0", "@google-cloud/storage@^5.8.0": - version "5.18.3" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.18.3.tgz#becacb909b2abf4bb54500a0efd65ceb51ef8eab" - integrity sha512-573qJ0ECoy3nkY5YaMWcVf4/46n/zdvfNgAyjaLQywl/eL38uxDhs7YVJd3pcgslaMUwKKsd/eD3St+Pq2iPew== + version "5.19.1" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.19.1.tgz#7252c4eb65e6014bf525a76f3af0774caa3a946d" + integrity sha512-bRTf/AD00+lPTamJdpihXC3AFtAnJFWNh/zQAor972VpuATF7u4V1anwWp0V6rKuKE3BwNM+xWxuuW/nAwEgTA== dependencies: - "@google-cloud/common" "^3.8.1" "@google-cloud/paginator" "^3.0.7" + "@google-cloud/projectify" "^2.0.0" "@google-cloud/promisify" "^2.0.0" abort-controller "^3.0.0" arrify "^2.0.0" @@ -2289,17 +2274,20 @@ configstore "^5.0.0" date-and-time "^2.0.0" duplexify "^4.0.0" + ent "^2.2.0" extend "^3.0.2" gaxios "^4.0.0" get-stream "^6.0.0" - google-auth-library "^7.0.0" + google-auth-library "^7.14.1" hash-stream-validation "^0.2.2" mime "^3.0.0" mime-types "^2.0.8" p-limit "^3.0.1" pumpify "^2.0.0" + retry-request "^4.2.2" snakeize "^0.1.0" stream-events "^1.0.4" + teeny-request "^7.1.3" xdg-basedir "^4.0.0" "@graphiql/toolkit@^0.4.3": @@ -13433,7 +13421,7 @@ globby@^7.1.1: pify "^3.0.0" slash "^1.0.0" -google-auth-library@^7.0.0, google-auth-library@^7.14.0, google-auth-library@^7.6.1: +google-auth-library@^7.14.1, google-auth-library@^7.6.1: version "7.14.1" resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== @@ -14043,7 +14031,7 @@ http-parser-js@>=0.5.1: resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== -http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1: +http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== @@ -23718,12 +23706,12 @@ tdigest@^0.1.1: react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -teeny-request@^7.0.0: - version "7.0.1" - resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz#bdd41fdffea5f8fbc0d29392cb47bec4f66b2b4c" - integrity sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw== +teeny-request@^7.1.3: + version "7.2.0" + resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" + integrity sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw== dependencies: - http-proxy-agent "^4.0.0" + http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0" node-fetch "^2.6.1" stream-events "^1.0.5" From 86a7c4bc5c1ae6ad08c597ae40974f68fdba240e Mon Sep 17 00:00:00 2001 From: Shivam bisht Date: Mon, 11 Apr 2022 16:48:04 +0530 Subject: [PATCH 141/144] added input label for GitlabRepoPicker Signed-off-by: Shivam bisht --- .../src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx index 1c63852866..e2052b792f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -65,7 +65,8 @@ export const GitlabRepoPicker = (props: { )} - The organization, user or project that this repo will belong to + The organization, groups, subgroups, user, project (also known as + namespaces in gitlab), that this repo will belong to Date: Mon, 11 Apr 2022 16:59:46 +0530 Subject: [PATCH 142/144] added changeset Signed-off-by: Shivam bisht --- .changeset/orange-dragons-brake.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/orange-dragons-brake.md diff --git a/.changeset/orange-dragons-brake.md b/.changeset/orange-dragons-brake.md new file mode 100644 index 0000000000..125dbfb9a2 --- /dev/null +++ b/.changeset/orange-dragons-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +modified input label for owner field in GitlabRepoPicker From 946af407db29ea4d576fc81bcb8b78d579a9ae99 Mon Sep 17 00:00:00 2001 From: Shivam bisht Date: Mon, 11 Apr 2022 17:36:37 +0530 Subject: [PATCH 143/144] added changeset Signed-off-by: Shivam bisht --- .changeset/few-hotels-approve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/few-hotels-approve.md diff --git a/.changeset/few-hotels-approve.md b/.changeset/few-hotels-approve.md new file mode 100644 index 0000000000..49ca040ef7 --- /dev/null +++ b/.changeset/few-hotels-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Changed input label for owner field in GitlabRepoPicker From 3a1ab7617cb86d7c4abcb0a5aeb8437df199dbbf Mon Sep 17 00:00:00 2001 From: Shivam bisht Date: Mon, 11 Apr 2022 18:00:08 +0530 Subject: [PATCH 144/144] removed unnecssary changeset file Signed-off-by: Shivam bisht --- .changeset/orange-dragons-brake.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/orange-dragons-brake.md diff --git a/.changeset/orange-dragons-brake.md b/.changeset/orange-dragons-brake.md deleted file mode 100644 index 125dbfb9a2..0000000000 --- a/.changeset/orange-dragons-brake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -modified input label for owner field in GitlabRepoPicker