From 5d8339cfee72a399a748e80b025f40009d409559 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 9 Apr 2021 14:26:37 +0200 Subject: [PATCH 01/88] some more tests Signed-off-by: blam --- cypress/cypress.json | 2 +- cypress/src/integration/integrations.ts | 45 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 cypress/src/integration/integrations.ts diff --git a/cypress/cypress.json b/cypress/cypress.json index ebbbe59901..59f7516c47 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:7000", + "baseUrl": "http://localhost:3000", "integrationFolder": "./src/integration", "supportFile": "./src/support", "fixturesFolder": "./src/fixures", diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts new file mode 100644 index 0000000000..76d0b6b28a --- /dev/null +++ b/cypress/src/integration/integrations.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Integrations', () => { + describe('ReadTree', () => { + // it('should work for azure', () => { + // cy.loginAsGuest(); + + // cy.visit('/catalog-import'); + + // cy.get('input[name=url]').type( + // 'https://dev.azure.com/backstage-verification/_git/test-repo?path=%2Fnested%2F*', + // ); + + // cy.contains('Analyze').click(); + // }); + + it('should work for github', () => { + cy.loginAsGuest(); + + cy.visit('/catalog-import'); + + cy.get('input[name=url]').type( + 'https://github.com/backstage-verification/test-repo', + ); + + cy.contains('Analyze').click(); + }); + }); +}); From 382531e50195a22cbde71b91e2107a184a40b09c Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 12 Apr 2021 10:21:42 +0200 Subject: [PATCH 02/88] chore: more work towards fixing Signed-off-by: blam --- cypress/src/integration/integrations.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts index 76d0b6b28a..839159d34e 100644 --- a/cypress/src/integration/integrations.ts +++ b/cypress/src/integration/integrations.ts @@ -38,6 +38,11 @@ describe('Integrations', () => { cy.get('input[name=url]').type( 'https://github.com/backstage-verification/test-repo', ); + cy.request('/api/catalog/locations', { + target: + 'https://github.com/backstage-verification/test-repo/blob/main/**/*', + type: 'url', + }); cy.contains('Analyze').click(); }); From 546172674279917bfa49ce621207925e760a6da5 Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Thu, 22 Apr 2021 09:08:46 -0600 Subject: [PATCH 03/88] [Fixes 5420] Update component example in documentation Currently the entity component example towards the top of the page has metadata.labels.system set https://github.com/backstage/backstage/blame/master/docs/features/software-catalog/descriptor-format.md#L47 but further down on the page it's spec.system https://github.com/backstage/backstage/blame/master/docs/features/software-catalog/descriptor-format.md#L423. This updates them to both use the correct spec.system key/value, as well as add an example label to get a sense for what labels look like as well Signed-off-by: jrusso1020 --- docs/features/software-catalog/descriptor-format.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index d3b14dd888..2506e2e9c1 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -44,7 +44,7 @@ metadata: name: artist-web description: The place to be, for great artists labels: - system: public-websites + backstage.io/custom: ValueStuff annotations: example.com/service-discovery: artistweb circleci.com/project-slug: github/example-org/artist-website @@ -58,6 +58,7 @@ spec: type: website lifecycle: production owner: artist-relations-team + system: public-websites ``` This is the same entity as returned in JSON from the software catalog API: @@ -76,7 +77,7 @@ This is the same entity as returned in JSON from the software catalog API: "etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk", "generation": 1, "labels": { - "system": "public-websites" + "backstage.io/custom": "ValueStuff" }, "links": [{ "url": "https://admin.example-org.com", @@ -90,7 +91,8 @@ This is the same entity as returned in JSON from the software catalog API: "spec": { "lifecycle": "production", "owner": "artist-relations-team", - "type": "website" + "type": "website", + "system": "public-websites" } } ``` From 8bd92cff3f0c9c5d794252bb51dda4859829d8bf Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Thu, 22 Apr 2021 17:30:08 +0200 Subject: [PATCH 04/88] Update nginx deployment to use 1.19 features - There is no need to override the `CMD` on the base image to get the custom scripts to run. This can be done simply by adding the custom configuration script to the `/docker-entrypoint.d` folder. See https://github.com/nginxinc/docker-nginx/blob/master/mainline/debian/docker-entrypoint.sh. - With the above change made, we can now also reap the benefit of support for `envsubst` having been added in 1.19 - as long as the config is placed in the folder `/etc/nginx/templates/`. See https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration-new-in-119 and https://github.com/nginxinc/docker-nginx/blob/master/mainline/debian/20-envsubst-on-templates.sh for more info. Signed-off-by: Brian Fox --- contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild | 5 ++--- contrib/docker/frontend-with-nginx/Dockerfile.hostbuild | 6 +++--- .../docker/{run.sh => inject-config.sh} | 8 -------- 3 files changed, 5 insertions(+), 14 deletions(-) rename contrib/docker/frontend-with-nginx/docker/{run.sh => inject-config.sh} (80%) diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild index dda9779eb0..41f931544f 100644 --- a/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild +++ b/contrib/docker/frontend-with-nginx/Dockerfile.dockerbuild @@ -51,9 +51,8 @@ FROM nginx:mainline RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* COPY --from=build /app/packages/app/dist /usr/share/nginx/html -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template -CMD run.sh +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh ENV PORT 80 diff --git a/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild index 6e5912dd67..1e0134017f 100644 --- a/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild +++ b/contrib/docker/frontend-with-nginx/Dockerfile.hostbuild @@ -33,9 +33,9 @@ FROM nginx:mainline RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* COPY packages/app/dist /usr/share/nginx/html -COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template -COPY docker/run.sh /usr/local/bin/run.sh +COPY docker/default.conf.template /etc/nginx/templates/default.conf.template -CMD run.sh +COPY docker/inject-config.sh /docker-entrypoint.d/40-inject-config.sh ENV PORT 80 + diff --git a/contrib/docker/frontend-with-nginx/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/inject-config.sh similarity index 80% rename from contrib/docker/frontend-with-nginx/docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/inject-config.sh index fdb1742a07..a0cea96ff3 100755 --- a/contrib/docker/frontend-with-nginx/docker/run.sh +++ b/contrib/docker/frontend-with-nginx/docker/inject-config.sh @@ -2,13 +2,6 @@ set -Eeuo pipefail -# Run nginx as root -sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf - -# Write selected env vars to nginx config -envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Inject runtime config into the client function inject_config() { # Read runtime config from env in the same way as the @backstage/config-loader package local config @@ -41,4 +34,3 @@ function inject_config() { inject_config -exec nginx -g 'daemon off;' From 8e067f00bec02b3666b063cf2f0b8a96ce08f9f6 Mon Sep 17 00:00:00 2001 From: azhurbilo Date: Thu, 22 Apr 2021 20:22:45 +0300 Subject: [PATCH 05/88] add ability to disable some helm lighthouse resources Signed-off-by: azhurbilo --- .../backstage/templates/lighthouse-config.yaml | 4 +++- ... => postgresql-password-backend-secret.yaml} | 0 .../postgresql-password-lighthouse-secret.yaml | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) rename contrib/chart/backstage/templates/{postgresql-password-secret.yaml => postgresql-password-backend-secret.yaml} (100%) create mode 100644 contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml diff --git a/contrib/chart/backstage/templates/lighthouse-config.yaml b/contrib/chart/backstage/templates/lighthouse-config.yaml index fdf4bd8b06..7b8cad42dc 100644 --- a/contrib/chart/backstage/templates/lighthouse-config.yaml +++ b/contrib/chart/backstage/templates/lighthouse-config.yaml @@ -1,3 +1,4 @@ +{{- if .Values.lighthouse.enabled }} apiVersion: v1 kind: ConfigMap metadata: @@ -7,4 +8,5 @@ data: PGUSER: {{ include "lighthouse.postgresql.user" . | quote }} PGPORT: {{ include "lighthouse.postgresql.port" . | quote }} PGHOST: {{ include "lighthouse.postgresql.host" . | quote }} - PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} \ No newline at end of file + PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} +{{- end }} \ No newline at end of file diff --git a/contrib/chart/backstage/templates/postgresql-password-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml similarity index 100% rename from contrib/chart/backstage/templates/postgresql-password-secret.yaml rename to contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml diff --git a/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml new file mode 100644 index 0000000000..e7374e70db --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-password-lighthouse-secret.yaml @@ -0,0 +1,17 @@ +{{- if .Values.lighthouse.enabled }} +{{- if not .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ include "lighthouse.postgresql.passwordSecret" . }} + labels: + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install,pre-upgrade" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + postgresql-password: {{ .Values.lighthouse.database.connection.password | b64enc }} +{{- end }} +{{- end }} From 041575f25741c5924ec83e3cc9a967a93ab11122 Mon Sep 17 00:00:00 2001 From: azhurbilo Date: Thu, 22 Apr 2021 20:51:37 +0300 Subject: [PATCH 06/88] ability to change components service type Signed-off-by: azhurbilo --- contrib/chart/backstage/templates/backend-deployment.yaml | 6 +++++- contrib/chart/backstage/templates/frontend-deployment.yaml | 2 +- .../chart/backstage/templates/lighthouse-deployment.yaml | 2 +- contrib/chart/backstage/values.yaml | 3 +++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 144fc93e62..7009a22de0 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -49,16 +49,20 @@ spec: name: {{ include "backend.postgresql.passwordSecret" .}} key: postgresql-password volumeMounts: + {{- if .Values.postgresql.enabled }} - name: postgres-ca mountPath: {{ include "backstage.backend.postgresCaDir" . }} + {{- end }} - name: app-config mountPath: {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) }} subPath: {{ include "backstage.appConfigFilename" . }} volumes: + {{- if .Values.postgresql.enabled }} - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} - name: app-config configMap: name: {{ include "backstage.fullname" . }}-app-config @@ -83,5 +87,5 @@ spec: app: backstage component: backend - type: ClusterIP + type: {{ .Values.backend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml index 9c674cc160..f74cf0a1e9 100644 --- a/contrib/chart/backstage/templates/frontend-deployment.yaml +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -62,5 +62,5 @@ spec: app: backstage component: frontend - type: ClusterIP + type: {{ .Values.frontend.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml index d6e1ce19dd..6104d4e73c 100644 --- a/contrib/chart/backstage/templates/lighthouse-deployment.yaml +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -78,5 +78,5 @@ spec: app: backstage component: lighthouse-audit-service - type: ClusterIP + type: {{ .Values.lighthouse.serviceType }} {{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index bd80bc22b6..e21148b04b 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -10,6 +10,7 @@ frontend: tag: test1 pullPolicy: IfNotPresent containerPort: 80 + serviceType: ClusterIP resources: requests: memory: 128Mi @@ -26,6 +27,7 @@ backend: tag: test1 pullPolicy: IfNotPresent containerPort: 7000 + serviceType: ClusterIP resources: requests: memory: 512Mi @@ -40,6 +42,7 @@ lighthouse: tag: latest pullPolicy: IfNotPresent containerPort: 3003 + serviceType: ClusterIP resources: requests: memory: 128Mi From 23769512a393445391f020b676ee5941ffbedcd6 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Fri, 23 Apr 2021 15:03:30 +0200 Subject: [PATCH 07/88] Support anyOf schemas in the scaffolder template Signed-off-by: Dominik Henneke --- .changeset/tough-planes-jump.md | 5 + .../MultistepJsonForm/schema.test.ts | 120 ++++++++++++++++++ .../components/MultistepJsonForm/schema.ts | 35 +++-- 3 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 .changeset/tough-planes-jump.md diff --git a/.changeset/tough-planes-jump.md b/.changeset/tough-planes-jump.md new file mode 100644 index 0000000000..a30315e9e4 --- /dev/null +++ b/.changeset/tough-planes-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Support anyOf schemas in the scaffolder template diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts index b83725c2bb..edfde0ea90 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -70,4 +70,124 @@ describe('transformSchemaToProps', () => { uiSchema: expectedUiSchema, }); }); + + it('transforms schema with anyOf fields', () => { + const inputSchema = { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + 'ui:readonly': true, + }, + }, + }, + ], + properties: { + field1: { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + 'ui:readonly': true, + }, + }, + }, + ], + }, + field2: { + type: 'string', + 'ui:derp': 'xerp', + }, + }, + }; + const expectedSchema = { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + }, + }, + }, + ], + properties: { + field1: { + type: 'object', + anyOf: [ + { + properties: { + field3: { + type: 'string', + default: 'Value 1', + }, + }, + }, + { + properties: { + field3: { + type: 'string', + default: 'Value 2', + }, + }, + }, + ], + }, + field2: { + type: 'string', + }, + }, + }; + const expectedUiSchema = { + field3: { + 'ui:readonly': true, + }, + field1: { + field3: { + 'ui:readonly': true, + }, + }, + field2: { + 'ui:derp': 'xerp', + }, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); }); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index e591589bd8..c189bbf1a7 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { return; } - const { properties } = schema; + const { properties, anyOf } = schema; for (const propName in schema) { if (!schema.hasOwnProperty(propName)) { @@ -39,22 +39,29 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { } } - if (!isObject(properties)) { - return; + if (isObject(properties)) { + for (const propName in properties) { + if (!properties.hasOwnProperty(propName)) { + continue; + } + + const schemaNode = properties[propName]; + if (!isObject(schemaNode)) { + continue; + } + const innerUiSchema = {}; + uiSchema[propName] = innerUiSchema; + extractUiSchema(schemaNode, innerUiSchema); + } } - for (const propName in properties) { - if (!properties.hasOwnProperty(propName)) { - continue; + if (Array.isArray(anyOf)) { + for (const schemaNode of anyOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); } - - const schemaNode = properties[propName]; - if (!isObject(schemaNode)) { - continue; - } - const innerUiSchema = {}; - uiSchema[propName] = innerUiSchema; - extractUiSchema(schemaNode, innerUiSchema); } } From 6f015b7689694bdbe2e90709b93b675cc66d9715 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 19:38:51 +0200 Subject: [PATCH 08/88] e2e: added read tree tests to add github, gitlab and azure read URLs Signed-off-by: blam --- cypress/cypress.json | 2 +- cypress/src/integration/integrations.ts | 64 +++++++++++++++++-------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/cypress/cypress.json b/cypress/cypress.json index 59f7516c47..ebbbe59901 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:3000", + "baseUrl": "http://localhost:7000", "integrationFolder": "./src/integration", "supportFile": "./src/support", "fixturesFolder": "./src/fixures", diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts index 839159d34e..5ee077502a 100644 --- a/cypress/src/integration/integrations.ts +++ b/cypress/src/integration/integrations.ts @@ -18,33 +18,59 @@ import 'os'; describe('Integrations', () => { describe('ReadTree', () => { - // it('should work for azure', () => { - // cy.loginAsGuest(); - - // cy.visit('/catalog-import'); - - // cy.get('input[name=url]').type( - // 'https://dev.azure.com/backstage-verification/_git/test-repo?path=%2Fnested%2F*', - // ); - - // cy.contains('Analyze').click(); - // }); - it('should work for github', () => { cy.loginAsGuest(); - cy.visit('/catalog-import'); - - cy.get('input[name=url]').type( - 'https://github.com/backstage-verification/test-repo', - ); - cy.request('/api/catalog/locations', { + cy.request('POST', '/api/catalog/locations', { target: 'https://github.com/backstage-verification/test-repo/blob/main/**/*', type: 'url', }); - cy.contains('Analyze').click(); + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'github-repo'); + cy.get('table').should('contain', 'github-repo-nested'); + }); + + // it('should work for azure', () => { + // cy.loginAsGuest(); + + // cy.request('POST', '/api/catalog/locations', { + // target: + // 'https://dev.azure.com/backstage-verification/_git/test-repo?path=*', + // type: 'url', + // }); + // }); + + it('should work for gitlab', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://gitlab.com/backstage-verification/test-repo/-/tree/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'gitlab-repo'); + cy.get('table').should('contain', 'gitlab-repo-nested'); + }); + + it('should work for bitbucket', () => { + cy.loginAsGuest(); + + cy.request('POST', '/api/catalog/locations', { + target: + 'https://bitbucket.org/backstage-verification/test-repo/src/master/**/*', + type: 'url', + }); + + cy.visit('/catalog'); + cy.contains('All').click(); + cy.get('table').should('contain', 'bitbucket-repo'); + cy.get('table').should('contain', 'bitbucket-repo-nested'); }); }); }); From e0c9ed759d9e6ef12ce8b04a1fe53ae77b4796f0 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Fri, 23 Apr 2021 16:18:07 -0400 Subject: [PATCH 09/88] feat(EntityLayout): add 'if' prop to Route component Signed-off-by: Phil Kuang --- .changeset/small-ways-hunt.md | 6 ++ .../core/src/components/TabbedLayout/index.ts | 1 + plugins/catalog/package.json | 1 + .../EntityLayout/EntityLayout.test.tsx | 35 ++++++++++ .../components/EntityLayout/EntityLayout.tsx | 67 +++++++++++++++++-- 5 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 .changeset/small-ways-hunt.md diff --git a/.changeset/small-ways-hunt.md b/.changeset/small-ways-hunt.md new file mode 100644 index 0000000000..f0b8dc88cc --- /dev/null +++ b/.changeset/small-ways-hunt.md @@ -0,0 +1,6 @@ +--- +'@backstage/core': patch +'@backstage/plugin-catalog': patch +--- + +Add `if` prop to `EntityLayout.Route` to conditionally render tabs diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts index 744b56959e..fe72b199ec 100644 --- a/packages/core/src/components/TabbedLayout/index.ts +++ b/packages/core/src/components/TabbedLayout/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { TabbedLayout } from './TabbedLayout'; +export { RoutedTabs } from './RoutedTabs'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index b6b306c260..d87ca5b7d9 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/core": "^0.7.6", + "@backstage/core-api": "^0.2.17", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 7356ccc27b..c5b51b8334 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -101,4 +101,39 @@ describe('EntityLayout', () => { expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); }); + + it('should conditionally render tabs', async () => { + const shouldRenderTab = (e: Entity) => e.metadata.name === 'my-entity'; + const shouldNotRenderTab = (e: Entity) => e.metadata.name === 'some-entity'; + + const rendered = await renderInTestApp( + + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+ +
tabbed-test-content-3
+
+
+
+
, + ); + + expect(rendered.queryByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-title-2')).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-title-3')).toBeInTheDocument(); + }); }); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 6a9fa38e73..e3a8a89b71 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -26,7 +26,7 @@ import { IconComponent, Page, Progress, - TabbedLayout, + RoutedTabs, } from '@backstage/core'; import { EntityContext, @@ -34,14 +34,70 @@ import { getEntityRelations, useEntityCompoundName, } from '@backstage/plugin-catalog-react'; -import { Box } from '@material-ui/core'; +import { attachComponentData } from '@backstage/core-api'; +import { Box, TabProps } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { default as React, useContext, useState } from 'react'; +import { + default as React, + Children, + Fragment, + isValidElement, + useContext, + useState, +} from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +type SubRoute = { + path: string; + title: string; + children: JSX.Element; + if?: (entity: Entity) => boolean; + tabProps?: TabProps; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +function createSubRoutesFromChildren( + childrenProps: React.ReactNode, + entity: Entity | undefined, +): SubRoute[] { + // Directly comparing child.type with Route will not work with in + // combination with react-hot-loader in storybook + // https://github.com/gaearon/react-hot-loader/issues/304 + const routeType = ( + +
+ + ).type; + + return Children.toArray(childrenProps).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children, entity); + } + + if (child.type !== routeType) { + throw new Error('Child of EntityLayout must be an EntityLayout.Route'); + } + + const { path, title, children, if: condition, tabProps } = child.props; + if (condition && entity && !condition(entity)) { + return []; + } + + return [{ path, title, children, tabProps }]; + }); +} + const EntityLayoutTitle = ({ entity, title, @@ -139,6 +195,7 @@ export const EntityLayout = ({ const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); + const routes = createSubRoutesFromChildren(children, entity); const { headerTitle, headerType } = headerProps( kind, namespace, @@ -175,7 +232,7 @@ export const EntityLayout = ({ {loading && } - {entity && {children}} + {entity && } {error && ( @@ -192,4 +249,4 @@ export const EntityLayout = ({ ); }; -EntityLayout.Route = TabbedLayout.Route; +EntityLayout.Route = Route; From 43db3d6892115eded93b3e5a655889658554c0a6 Mon Sep 17 00:00:00 2001 From: Martina Iglesias Fernandez Date: Fri, 23 Apr 2021 19:02:01 +0200 Subject: [PATCH 10/88] [docker] Fix error in docker build Before: ``` Step 7/8 : RUN tar xzf bundle.tar.gz && rm bundle.tar.gz ---> Running in 91107ee4a847 tar (child): bundle.tar.gz: Cannot open: No such file or directory tar (child): Error is not recoverable: exiting now tar: Child returned status 2 tar: Error is not recoverable: exiting now ``` Signed-off-by: Martina Iglesias Fernandez --- packages/backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 02aab45609..31231a3a4a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -22,7 +22,7 @@ RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" # Then copy the rest of the backend bundle, along with any other files we might want. -ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./ +COPY packages/backend/dist/bundle.tar.gz app-config.yaml ./ RUN tar xzf bundle.tar.gz && rm bundle.tar.gz CMD ["node", "packages/backend", "--config", "app-config.yaml"] From a3f090b2f391d47c9a36e8aeb53041798a9e02ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Apr 2021 19:58:02 +0200 Subject: [PATCH 11/88] catalog-backend: WIP EntityProvider mutation interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../next/DefaultCatalogProcessingEngine.ts | 15 ++-- .../src/next/DefaultLocationStore.ts | 68 ++++++++++--------- .../src/next/NextCatalogBuilder.ts | 3 +- plugins/catalog-backend/src/next/types.ts | 21 +++--- 4 files changed, 55 insertions(+), 52 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 934ab3a1cc..b423b61041 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -19,6 +19,8 @@ import { CatalogProcessingEngine, EntityProvider, EntityMessage, + EntityProviderConnection, + EntityProviderMutation, ProcessingStateManager, CatalogProcessingOrchestrator, } from './types'; @@ -27,8 +29,13 @@ import { Logger } from 'winston'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { Stitcher } from './Stitcher'; +class Connection implements EntityProviderConnection { + constructor(private readonly stateManager: ProcessingStateManager) {} + + async applyMutation(mutation: EntityProviderMutation): Promise {} +} + export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { - private subscriptions: Subscription[] = []; private running: boolean = false; constructor( @@ -41,11 +48,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - const id = 'databaseProvider'; - const subscription = provider - .entityChange$() - .subscribe({ next: m => this.onNext(id, m) }); - this.subscriptions.push(subscription); + provider.connect(new Connection(this.stateManager)); } this.running = true; diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index b21fe9da02..a6b4adcca4 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -16,24 +16,29 @@ import { LocationSpec, Location } from '@backstage/catalog-model'; import { Database } from '../database'; -import { LocationStore } from './types'; +import { + LocationStore, + EntityProvider, + EntityProviderConnection, +} from './types'; import { v4 as uuidv4 } from 'uuid'; +import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; -import { Observable } from '@backstage/core'; -import ObservableImpl from 'zen-observable'; export type LocationMessage = | { all: Location[] } | { added: Location[]; removed: Location[] }; -export class DefaultLocationStore implements LocationStore { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); +export class DefaultLocationStore implements LocationStore, EntityProvider { + private _connection: EntityProviderConnection | undefined; constructor(private readonly db: Database) {} createLocation(spec: LocationSpec): Promise { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. @@ -55,7 +60,11 @@ export class DefaultLocationStore implements LocationStore { target: spec.target, }); - this.notifyAddition(location); + await this.connection.applyMutation({ + type: 'delta', + added: [locationSpecToLocationEntity(location)], + removed: [], + }); return location; }); @@ -75,40 +84,33 @@ export class DefaultLocationStore implements LocationStore { } deleteLocation(id: string): Promise { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + return this.db.transaction(async tx => { const location = await this.db.location(id); if (!location) { throw new ConflictError(`No location found with id: ${id}`); } await this.db.removeLocation(tx, id); - this.notifyDeletion(location); - }); - } - - private notifyAddition(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ - added: [location], - removed: [], - }); - } - } - - private notifyDeletion(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ + await this.connection.applyMutation({ + type: 'delta', added: [], - removed: [location], + removed: [locationSpecToLocationEntity(location)], }); - } + }); } - location$(): Observable { - return new ObservableImpl(subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); + private get connection(): EntityProviderConnection { + if (!this._connection) { + throw new Error('location store is not initialized'); + } + + return this._connection; + } + + async connect(connection: EntityProviderConnection): Promise { + this._connection = connection; } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index c3e0a6bd9c..1796847164 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -272,11 +272,10 @@ export class NextCatalogBuilder { const entitiesCatalog = new NextEntitiesCatalog(dbClient); const locationStore = new DefaultLocationStore(db); - const dbLocationProvider = new DatabaseLocationProvider(locationStore); const stitcher = new Stitcher(dbClient, logger); const processingEngine = new DefaultCatalogProcessingEngine( logger, - [dbLocationProvider], // entityproviders + [locationStore], // entityproviders stateManager, orchestrator, stitcher, diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index 0aeb290224..c3854d631f 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, EntityName, @@ -21,7 +22,6 @@ import { EntityRelationSpec, } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; -import { Observable } from '@backstage/core'; // << nooo export interface LocationEntity { apiVersion: 'backstage.io/v1alpha1'; @@ -45,20 +45,11 @@ export interface LocationService { deleteLocation(id: string): Promise; } -export type EntityMessage = - | { all: Entity[] } - | { added: Entity[]; removed: EntityName[] }; - export interface LocationStore { - // extends EntityProvider createLocation(spec: LocationSpec): Promise; listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; - - location$(): Observable< - { all: Location[] } | { added: Location[]; removed: Location[] } - >; } export interface CatalogProcessingEngine { @@ -66,8 +57,16 @@ export interface CatalogProcessingEngine { stop(): Promise; } +export type EntityProviderMutation = + | { type: 'full'; entities: Iterable } + | { type: 'delta'; added: Iterable; removed: Iterable }; + +export interface EntityProviderConnection { + applyMutation(mutation: EntityProviderMutation): Promise; +} + export interface EntityProvider { - entityChange$(): Observable; + connect(connection: EntityProviderConnection): Promise; } export type EntityProcessingRequest = { From 92a4a4832beccc622f83473328555f03d094bba7 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 22 Apr 2021 19:41:40 +0200 Subject: [PATCH 12/88] chore: worked on some more of the new catalog migration with fixing deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Johan Haals Co-authored-by: Fredrik Adelöw Signed-off-by: blam --- .../20210302150147_refresh_state.js | 33 ++-- .../next/DefaultCatalogProcessingEngine.ts | 38 +++- .../src/next/DefaultLocationStore.ts | 4 - .../src/next/DefaultProcessingStateManager.ts | 13 +- .../src/next/database/DatabaseManager.ts | 114 +++++++++++ .../DefaultProcessingDatabase.test.ts | 31 +++ .../database/DefaultProcessingDatabase.ts | 180 +++++++++++++++++- .../src/next/database/types.ts | 17 ++ plugins/catalog-backend/src/next/types.ts | 21 +- 9 files changed, 405 insertions(+), 46 deletions(-) create mode 100644 plugins/catalog-backend/src/next/database/DatabaseManager.ts create mode 100644 plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 6abab6270b..14c75b2530 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -59,13 +59,14 @@ exports.up = async function up(knex) { .notNullable() .comment('JSON array containing all errors related to entity'); table - .dateTime('next_update_at') + .dateTime('next_update_at') // TOOD: timezone or change to epoch-millis or similar .notNullable() .comment('Timestamp of when entity should be updated'); table - .dateTime('last_discovery_at') + .dateTime('last_discovery_at') // TOOD: timezone or change to epoch-millis or similar .notNullable() .comment('The last timestamp of which this entity was discovered'); + table.index('entity_id', 'refresh_state_entity_id_idx'); table.index('entity_ref', 'refresh_state_entity_ref_idx'); table.index('next_update_at', 'refresh_state_next_update_at_idx'); }); @@ -94,38 +95,35 @@ exports.up = async function up(knex) { 'Holds edges between refresh state rows. Every time when an entity is processed and emits another entity, an edge will be stored to represent that fact. This is used to detect orphans and ultimately deletions.', ); table - .text('source_special_key') + .text('source_key') .nullable() .comment( 'When the reference source is not an entity, this is an opaque identifier for that source.', ); table - .text('source_entity_id') + .text('source_entity_ref') .nullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') .comment( - 'When the reference source is an entity, this is the ID of the source entity.', + 'When the reference source is an entity, this is the EntityRef of the source entity.', ); table - .text('target_entity_id') + .text('target_entity_ref') .notNullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') - .comment('The ID of the target entity.'); + .comment('The EntityRef of the target entity.'); + table.index('source_key', 'refresh_state_references_source_key_idx'); table.index( - 'source_special_key', - 'refresh_state_references_source_special_key_idx', + 'source_entity_ref', + 'refresh_state_references_source_entity_ref_idx', ); table.index( - 'source_entity_id', - 'refresh_state_references_source_entity_id_idx', - ); - table.index( - 'target_entity_id', - 'refresh_state_references_target_entity_id_idx', + 'target_entity_ref', + 'refresh_state_references_target_entity_ref_idx', ); }); @@ -165,6 +163,7 @@ exports.down = async function down(knex) { table.dropIndex([], 'refresh_state_references_target_entity_id_idx'); }); await knex.schema.alterTable('refresh_state', table => { + table.dropIndex([], 'refresh_state_entity_id_idx'); table.dropIndex([], 'refresh_state_entity_ref_idx'); table.dropIndex([], 'refresh_state_next_update_at_idx'); }); diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index b423b61041..7f49bf832d 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -30,9 +30,31 @@ import { stringifyEntityRef } from '@backstage/catalog-model'; import { Stitcher } from './Stitcher'; class Connection implements EntityProviderConnection { - constructor(private readonly stateManager: ProcessingStateManager) {} + constructor( + private readonly config: { + stateManager: ProcessingStateManager; + id: string; + }, + ) {} - async applyMutation(mutation: EntityProviderMutation): Promise {} + async applyMutation(mutation: EntityProviderMutation): Promise { + if (mutation.type === 'full') { + await this.config.stateManager.replaceProcessingItems({ + id: this.config.id, + type: 'full', + items: mutation.entities, + }); + + return; + } + + await this.config.stateManager.replaceProcessingItems({ + id: this.config.id, + type: 'delta', + added: mutation.added, + removed: mutation.removed, + }); + } } export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { @@ -48,7 +70,9 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - provider.connect(new Connection(this.stateManager)); + // TODO: this ID should be some form of identifier for the EntityProvider + const id = 'databaseProvider'; + provider.connect(new Connection({ stateManager: this.stateManager, id })); } this.running = true; @@ -57,12 +81,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { const { id, entity, - state: intialState, + state: initialState, } = await this.stateManager.getNextProcessingItem(); const result = await this.orchestrator.process({ entity, - state: intialState, + state: initialState, }); for (const error of result.errors) { @@ -95,10 +119,6 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async stop() { this.running = false; - - for (const subscription of this.subscriptions) { - subscription.unsubscribe(); - } } private async onNext(id: string, message: EntityMessage) { diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index a6b4adcca4..dff424b1a9 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -35,10 +35,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { constructor(private readonly db: Database) {} createLocation(spec: LocationSpec): Promise { - if (!this.connection) { - throw new Error('location store is not initialized'); - } - return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index 91090bc139..d2765c3101 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -17,14 +17,23 @@ import { ProcessingDatabase, RefreshStateItem } from './database/types'; import { AddProcessingItemRequest, - ProccessingItem, + ProcessingItem, ProcessingItemResult, ProcessingStateManager, + ReplaceProcessingItemsRequest, } from './types'; export class DefaultProcessingStateManager implements ProcessingStateManager { constructor(private readonly db: ProcessingDatabase) {} + replaceProcessingItems( + request: ReplaceProcessingItemsRequest, + ): Promise { + return this.db.transaction(async tx => { + await this.db.replaceUnprocessedEntities(tx, request); + }); + } + async setProcessingItemResult(result: ProcessingItemResult) { return this.db.transaction(async tx => { await this.db.updateProcessedEntity(tx, { @@ -44,7 +53,7 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { }); } - async getNextProcessingItem(): Promise { + async getNextProcessingItem(): Promise { const entities = await new Promise(resolve => this.popFromQueue(resolve), ); diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts new file mode 100644 index 0000000000..4c9e45950f --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; +import knexFactory, { Knex } from 'knex'; +import { v4 as uuidv4 } from 'uuid'; +import { Logger } from 'winston'; +import { CommonDatabase } from '../../database/CommonDatabase'; +import { Database } from '../../database/types'; +import fs from 'fs-extra'; + +export type CreateDatabaseOptions = { + logger: Logger; +}; + +const defaultOptions: CreateDatabaseOptions = { + logger: getVoidLogger(), +}; + +export class DatabaseManager { + public static async createDatabase( + knex: Knex, + options: Partial = {}, + ): Promise { + const allMigrations = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrations', + ); + + const migrationsDir = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrationsv2', + ); + await fs.copy(allMigrations, migrationsDir); + + await knex.migrate.latest({ + directory: migrationsDir, + }); + const { logger } = { ...defaultOptions, ...options }; + return new CommonDatabase(knex, logger); + } + + public static async createInMemoryDatabase(): Promise { + const knex = await this.createInMemoryDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createInMemoryDatabaseConnection(): Promise { + const knex = knexFactory({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } + + public static async createTestDatabase(): Promise { + const knex = await this.createTestDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createTestDatabaseConnection(): Promise { + const config: Knex.Config = { + /* + client: 'pg', + connection: { + host: 'localhost', + user: 'postgres', + password: 'postgres', + }, + */ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }; + + let knex = knexFactory(config); + if (typeof config.connection !== 'string') { + const tempDbName = `d${uuidv4().replace(/-/g, '')}`; + await knex.raw(`CREATE DATABASE ${tempDbName};`); + knex = knexFactory({ + ...config, + connection: { + ...config.connection, + database: tempDbName, + }, + }); + } + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } +} diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts new file mode 100644 index 0000000000..fd2e9e6750 --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; +import { DatabaseManager } from './DatabaseManager'; +import { Knex } from 'knex'; + +describe('Default Processing Database', () => { + let db: Knex | undefined; + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + }); + + it('should write some stuff', async () => { + await db('refresh_state_referencess').select(); + }); +}); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index cf2d4e121c..b799bd6284 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -15,6 +15,7 @@ */ import { ConflictError, NotFoundError } from '@backstage/errors'; +import { stringifyEntityRef, Entity } from '@backstage/catalog-model'; import { Knex } from 'knex'; import { Transaction } from '../../database'; import lodash from 'lodash'; @@ -24,20 +25,21 @@ import { AddUnprocessedEntitiesOptions, UpdateProcessedEntityOptions, GetProcessableEntitiesResult, + ReplaceUnprocessedEntitiesOptions, } from './types'; import type { Logger } from 'winston'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + import { v4 as uuid } from 'uuid'; export type DbRefreshStateRow = { entity_id: string; entity_ref: string; unprocessed_entity: string; - processed_entity: string; - cache: string; + processed_entity?: string; + cache?: string; next_update_at: string; last_discovery_at: string; // remove? - errors: string; + errors?: string; }; export type DbRelationsRow = { @@ -47,10 +49,10 @@ export type DbRelationsRow = { type: string; }; -export type DbRefreshStateReferences = { - source_special_key?: string; - source_entity_id?: string; - target_entity_id: string; +export type DbRefreshStateReferencesRow = { + source_key?: string; + source_entity_ref?: string; + target_entity_ref: string; }; // The number of items that are sent per batch to the database layer, when @@ -128,6 +130,164 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + async replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + const entityIds = new Array(); + + if (options.type === 'full') { + const oldRefs = await tx( + 'refresh_state_references', + ) + .where({ source_key: options.sourceKey }) + .select('target_entity_ref') + .then(rows => rows.map(r => r.target_entity_ref)); + + const items = options.items.map(entity => ({ + entity, + ref: stringifyEntityRef(entity), + id: uuid(), + })); + + const oldRefsSet = new Set(oldRefs); + const newRefsSet = new Set(items.map(item => item.ref)); + const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); + const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); + + if (toRemove.length) { + // TODO(freben): Batch split, to not hit variable limits? + + // get all refs where source = any of the toRemove + // delete all state rows matching any of the toRemove + // revisit the targets of all of those refs + // verify if they have references, otherwise delete from refresh_state + + const current = [...toRemove]; + while (true) { + + tx.withRecursive('r', function refs() { + return tx.select({ }) + .from({ r1: 'refresh_state_references' }) + .where({ source_key: options.sourceKey }) + .whereIn('target_entity_ref', toRemove) + .unionAll(function recurse() { + return tx.select({ }) + .from({ r2: 'refresh_state_references' }) + .whereNotExists() + }) + }) + .select().from('refs'); + + const nextLayerToRemove = await tx( + 'refresh_state_references', + ) + .whereIn('source_entity_ref', toRemove) + .leftOuterJoin('refresh_state_references AS b', function f() { + + }) + + .whereNotNull('b.source_target_ref') + .select('target_entity_ref'); + + await tx('refresh_state') + .whereIn('entity_ref', toRemove) + .delete(); + + const refsThatStillHaveASourcePointingAtThe = await tx( + 'refresh_state_references', + ) + .whereIn('target_entity_ref', nextLayerTargetRefs) + .groupBy('target_entity_ref') + .select('target_entity_ref'); + + } + + + + + if (toAdd.length) { + const state: Knex.DbRecord[] = toAdd.map(item => ({ + entity_id: item.id, + entity_ref: item.ref, + unprocessed_entity: JSON.stringify(item.entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + })); + const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( + item => ({ + source_key: options.sourceKey, + target_entity_ref: item.ref, + }), + ); + // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense + await tx.batchInsert('refresh_state', state, BATCH_SIZE); + await tx.batchInsert('refresh_state_references', stateReferences, BATCH_SIZE); + } + + + for (const entity of options.items) { + const entityRef = stringifyEntityRef(entity); + await tx('refresh_state') + .insert({ + entity_id: uuid(), + entity_ref: entityRef, + unprocessed_entity: JSON.stringify(entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }) + .onConflict('entity_ref') + .merge(['unprocessed_entity', 'last_discovery_at']); + + const [{ entity_id: entityId }] = await tx( + 'refresh_state', + ).where({ entity_ref: entityRef }); + entityIds.push(entityId); + } + const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( + entityId => ({ + source_key: options.sourceKey, + target_entityRef: entityRef, + }), + ); + await tx.batchInsert( + 'refresh_state_references', + referenceRows, + BATCH_SIZE, + ); + return; + } + + for (const entity of options.removed) { + const entityRef = stringifyEntityRef(entity); + const [result] = await tx('refresh_state') + .where({ entity_ref: entityRef }) + .select('entity_id'); + + if (!result) { + this.logger.info( + `Unable to delete entity '${entityRef}', entity does not exist in refresh state`, + ); + continue; + } + + const referenceResults = await tx( + 'refresh_state_references', + ) + // todo correct key? + .where({ source_entity_id: result.entity_id }) + .select(); + + await tx('refresh_state_references') + // todo correct key? + .where({ source_entity_id: result.entity_id }) + .delete(); + } + } + async addUnprocessedEntities( txOpaque: Transaction, options: AddUnprocessedEntitiesOptions, @@ -160,11 +320,11 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ? { source_special_key: options.id } : { source_entity_id: options.id }; // copied from update refs - await tx('refresh_state_references') + await tx('refresh_state_references') .where(key) .delete(); - const referenceRows: DbRefreshStateReferences[] = entityIds.map( + const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( entityId => ({ ...key, target_entity_id: entityId, diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index 22e9e90c8e..b926676887 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -50,6 +50,19 @@ export type GetProcessableEntitiesResult = { items: RefreshStateItem[]; }; +export type ReplaceUnprocessedEntitiesOptions = + | { + sourceKey: string; + items: Entity[]; + type: 'full'; + } + | { + sourceKey: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; + export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; @@ -58,6 +71,10 @@ export interface ProcessingDatabase { options: AddUnprocessedEntitiesOptions, ): Promise; + replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise; getProcessableEntities( txOpaque: Transaction, request: { processBatchSize: number }, diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index c3854d631f..f8b4de1c41 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -58,8 +58,8 @@ export interface CatalogProcessingEngine { } export type EntityProviderMutation = - | { type: 'full'; entities: Iterable } - | { type: 'delta'; added: Iterable; removed: Iterable }; + | { type: 'full'; entities: Entity[] } + | { type: 'delta'; added: Entity[]; removed: Entity[] }; export interface EntityProviderConnection { applyMutation(mutation: EntityProviderMutation): Promise; @@ -108,14 +108,27 @@ export type AddProcessingItemRequest = { entities: Entity[]; }; -export type ProccessingItem = { +export type ProcessingItem = { id: string; entity: Entity; state: Map; }; +export type ReplaceProcessingItemsRequest = + | { + id: string; + items: Entity[]; + type: 'full'; + } + | { + id: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; - getNextProcessingItem(): Promise; + getNextProcessingItem(): Promise; addProcessingItems(request: AddProcessingItemRequest): Promise; + replaceProcessingItems(request: ReplaceProcessingItemsRequest): Promise; } From f62db5992f7b9ba66dbcb291a9d88ab1264aca07 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 15:05:04 +0200 Subject: [PATCH 13/88] feat: added support for full sync replace with an awesome sql statement courtesy of @freben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../20210302150147_refresh_state.js | 3 + .../DefaultProcessingDatabase.test.ts | 271 +++++++++++++++++- .../database/DefaultProcessingDatabase.ts | 220 +++++++------- 3 files changed, 390 insertions(+), 104 deletions(-) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 14c75b2530..2afeda02c0 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -94,6 +94,9 @@ exports.up = async function up(knex) { table.comment( 'Holds edges between refresh state rows. Every time when an entity is processed and emits another entity, an edge will be stored to represent that fact. This is used to detect orphans and ultimately deletions.', ); + table + .increments('id') + .comment('Primary key to distinguish unique lines from each other'); table .text('source_key') .nullable() diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index fd2e9e6750..ee04a2baf8 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -16,16 +16,283 @@ // import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; import { DatabaseManager } from './DatabaseManager'; import { Knex } from 'knex'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DefaultProcessingDatabase, +} from './DefaultProcessingDatabase'; + +import { Entity } from '@backstage/catalog-model'; +import * as uuid from 'uuid'; +import { getVoidLogger } from '@backstage/backend-common'; describe('Default Processing Database', () => { let db: Knex | undefined; + let processingDatabase: DefaultProcessingDatabase | undefined; + + const logger = getVoidLogger(); beforeEach(async () => { db = await DatabaseManager.createTestDatabaseConnection(); await DatabaseManager.createDatabase(db); + + processingDatabase = new DefaultProcessingDatabase(db!, logger); }); - it('should write some stuff', async () => { - await db('refresh_state_referencess').select(); + describe('replaceUnprocessedEntities', () => { + const insertRefRow = async (ref: DbRefreshStateReferencesRow) => { + return db!( + 'refresh_state_references', + ).insert(ref); + }; + + const insertRefreshStateRow = async (ref: DbRefreshStateRow) => { + await db!('refresh_state').insert(ref); + }; + + const createLocations = async (entityRefs: string[]) => { + for (const ref of entityRefs) { + await insertRefreshStateRow({ + entity_id: uuid.v4(), + entity_ref: ref, + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + } + }; + + it('replaces all existing state correctly for simple dependency chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + database -> location:default/second -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/second', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_key: 'database', + target_entity_ref: 'location:default/second', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/second', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + for (const ref of ['location:default/root', 'location:default/root-1']) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/root-1' && + t.source_key === 'config', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/new-root' && + t.source_key === 'config', + ), + ).toBeTruthy(); + }); + + it('should work for more complex chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + config -> location:default/root -> location:default/root-1a -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/root-1a', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1a', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1a', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + const deletedRefs = [ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-1a', + 'location:default/root-2', + ]; + + for (const ref of deletedRefs) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1a', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1a' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + }); }); }); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index b799bd6284..f0c5443651 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -135,7 +135,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { options: ReplaceUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - const entityIds = new Array(); if (options.type === 'full') { const oldRefs = await tx( @@ -158,55 +157,95 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { if (toRemove.length) { // TODO(freben): Batch split, to not hit variable limits? - - // get all refs where source = any of the toRemove - // delete all state rows matching any of the toRemove - // revisit the targets of all of those refs - // verify if they have references, otherwise delete from refresh_state - - const current = [...toRemove]; - while (true) { - - tx.withRecursive('r', function refs() { - return tx.select({ }) - .from({ r1: 'refresh_state_references' }) - .where({ source_key: options.sourceKey }) - .whereIn('target_entity_ref', toRemove) - .unionAll(function recurse() { - return tx.select({ }) - .from({ r2: 'refresh_state_references' }) - .whereNotExists() - }) + /* + WITH RECURSIVE + -- All the refs that can be reached from each individual root + root_reach(id, entity_ref) AS ( + -- Start with all roots + SELECT id, target_entity_ref + FROM refresh_state_references + WHERE source_key IS NOT NULL + UNION + -- For each match, select all children + SELECT root_reach.id, target_entity_ref + FROM refresh_state_references, root_reach + WHERE source_entity_ref = root_reach.entity_ref + ) + -- Start out with our own matching row (see the WHERE that + -- matches on source_key and target_entity_ref below) + SELECT us.entity_ref + FROM refresh_state_references + -- Expand the entire tree that emanates from that row + JOIN root_reach AS us + ON us.id = refresh_state_references.id + -- Expand with all roots that target the same node but + -- aren't ourselves + LEFT OUTER JOIN root_reach AS them + ON them.entity_ref = us.entity_ref + AND them.id != us.id + -- Keep only the matches that had no other rooots + WHERE refresh_state_references.source_key = "R1" + AND refresh_state_references.target_entity_ref = "A" + AND them.id IS NULL; + */ + const removedCount = await tx('refresh_state') + .whereIn('entity_ref', function orphanedEntityRefs(orphans) { + return ( + orphans + // All the refs that can be reached from each individual root + .withRecursive('root_reach', function rootReach(outer) { + // Start with all roots + return outer + .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .whereNotNull('source_key') + .union(function recurse(inner) { + return ( + inner + // For each match, select all children + .select({ + id: 'root_reach.id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('refresh_state_references') + .crossJoin('root_reach', { + 'root_reach.entity_ref': + 'refresh_state_references.source_entity_ref', + }) + ); + }); + }) + .select('us.entity_ref') + // Start out with our own matching row + .from('refresh_state_references') + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + // Expand the entire tree that emanates from that row + .leftJoin({ us: 'root_reach' }, function us() { + this.on('us.id', '=', 'refresh_state_references.id'); + }) + // Expand with all roots that target the same node but aren't ourselves + .leftOuterJoin({ them: 'root_reach' }, function them() { + this.on('them.entity_ref', '=', 'us.entity_ref'); + this.andOn('them.id', '!=', 'us.id'); + }) + // Keep only the matches that had no other rooots + .whereNull('them.id') + ); }) - .select().from('refs'); + .delete(); - const nextLayerToRemove = await tx( - 'refresh_state_references', - ) - .whereIn('source_entity_ref', toRemove) - .leftOuterJoin('refresh_state_references AS b', function f() { - - }) - - .whereNotNull('b.source_target_ref') - .select('target_entity_ref'); - - await tx('refresh_state') - .whereIn('entity_ref', toRemove) - .delete(); - - const refsThatStillHaveASourcePointingAtThe = await tx( - 'refresh_state_references', - ) - .whereIn('target_entity_ref', nextLayerTargetRefs) - .groupBy('target_entity_ref') - .select('target_entity_ref'); + await tx('refresh_state_references') + .where('source_key', '=', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .delete(); + console.log( + `REMOVED, ${removedCount} entities: ${JSON.stringify(toRemove)}`, + ); } - - - if (toAdd.length) { const state: Knex.DbRecord[] = toAdd.map(item => ({ entity_id: item.id, @@ -224,67 +263,44 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense await tx.batchInsert('refresh_state', state, BATCH_SIZE); - await tx.batchInsert('refresh_state_references', stateReferences, BATCH_SIZE); - } - - - for (const entity of options.items) { - const entityRef = stringifyEntityRef(entity); - await tx('refresh_state') - .insert({ - entity_id: uuid(), - entity_ref: entityRef, - unprocessed_entity: JSON.stringify(entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - }) - .onConflict('entity_ref') - .merge(['unprocessed_entity', 'last_discovery_at']); - - const [{ entity_id: entityId }] = await tx( - 'refresh_state', - ).where({ entity_ref: entityRef }); - entityIds.push(entityId); - } - const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - entityId => ({ - source_key: options.sourceKey, - target_entityRef: entityRef, - }), - ); - await tx.batchInsert( - 'refresh_state_references', - referenceRows, - BATCH_SIZE, - ); - return; - } - - for (const entity of options.removed) { - const entityRef = stringifyEntityRef(entity); - const [result] = await tx('refresh_state') - .where({ entity_ref: entityRef }) - .select('entity_id'); - - if (!result) { - this.logger.info( - `Unable to delete entity '${entityRef}', entity does not exist in refresh state`, + await tx.batchInsert( + 'refresh_state_references', + stateReferences, + BATCH_SIZE, ); - continue; } - const referenceResults = await tx( - 'refresh_state_references', - ) - // todo correct key? - .where({ source_entity_id: result.entity_id }) - .select(); + // for (const entity of options.items) { + // const entityRef = stringifyEntityRef(entity); + // await tx('refresh_state') + // .insert({ + // entity_id: uuid(), + // entity_ref: entityRef, + // unprocessed_entity: JSON.stringify(entity), + // errors: '', + // next_update_at: tx.fn.now(), + // last_discovery_at: tx.fn.now(), + // }) + // .onConflict('entity_ref') + // .merge(['unprocessed_entity', 'last_discovery_at']); - await tx('refresh_state_references') - // todo correct key? - .where({ source_entity_id: result.entity_id }) - .delete(); + // const [{ entity_id: entityId }] = await tx( + // 'refresh_state', + // ).where({ entity_ref: entityRef }); + // entityIds.push(entityId); + // } + // const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( + // entityId => ({ + // source_key: options.sourceKey, + // target_entityRef: entityRef, + // }), + // ); + // await tx.batchInsert( + // 'refresh_state_references', + // referenceRows, + // BATCH_SIZE, + // ); + // return; } } From 6e545108e7418ee0bd9f57678f59f1a14e27f9be Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 16:01:36 +0200 Subject: [PATCH 14/88] feat: support delta and full sync with the same code and simplify things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../DefaultProcessingDatabase.test.ts | 89 +++++++ .../database/DefaultProcessingDatabase.ts | 242 ++++++++---------- 2 files changed, 201 insertions(+), 130 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index ee04a2baf8..c4784c081d 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -294,5 +294,94 @@ describe('Default Processing Database', () => { ), ).toBeFalsy(); }); + + it('should add new locations using the delta options', async () => { + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + removed: [], + added: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + }); + + it('should remove old locations using the delta options', async () => { + await createLocations(['location:default/new-root']); + + await insertRefRow({ + source_key: 'lols', + target_entity_ref: 'location:default/new-root', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + added: [], + removed: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + }); }); }); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index f0c5443651..a80325d469 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -130,34 +130,48 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + private async createDelta( + tx: Knex.Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise<{ toAdd: Entity[]; toRemove: string[] }> { + if (options.type === 'delta') { + return { + toAdd: options.added, + toRemove: options.removed.map(e => stringifyEntityRef(e)), + }; + } + + const oldRefs = await tx( + 'refresh_state_references', + ) + .where({ source_key: options.sourceKey }) + .select('target_entity_ref') + .then(rows => rows.map(r => r.target_entity_ref)); + + const items = options.items.map(entity => ({ + entity, + ref: stringifyEntityRef(entity), + })); + + const oldRefsSet = new Set(oldRefs); + const newRefsSet = new Set(items.map(item => item.ref)); + const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); + const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); + + return { toAdd: toAdd.map(({ entity }) => entity), toRemove }; + } + async replaceUnprocessedEntities( txOpaque: Transaction, options: ReplaceUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - if (options.type === 'full') { - const oldRefs = await tx( - 'refresh_state_references', - ) - .where({ source_key: options.sourceKey }) - .select('target_entity_ref') - .then(rows => rows.map(r => r.target_entity_ref)); + const { toAdd, toRemove } = await this.createDelta(tx, options); - const items = options.items.map(entity => ({ - entity, - ref: stringifyEntityRef(entity), - id: uuid(), - })); - - const oldRefsSet = new Set(oldRefs); - const newRefsSet = new Set(items.map(item => item.ref)); - const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); - const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); - - if (toRemove.length) { - // TODO(freben): Batch split, to not hit variable limits? - /* + if (toRemove.length) { + // TODO(freben): Batch split, to not hit variable limits? + /* WITH RECURSIVE -- All the refs that can be reached from each individual root root_reach(id, entity_ref) AS ( @@ -188,119 +202,87 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { AND refresh_state_references.target_entity_ref = "A" AND them.id IS NULL; */ - const removedCount = await tx('refresh_state') - .whereIn('entity_ref', function orphanedEntityRefs(orphans) { - return ( - orphans - // All the refs that can be reached from each individual root - .withRecursive('root_reach', function rootReach(outer) { - // Start with all roots - return outer - .select({ id: 'id', entity_ref: 'target_entity_ref' }) - .from('refresh_state_references') - .whereNotNull('source_key') - .union(function recurse(inner) { - return ( - inner - // For each match, select all children - .select({ - id: 'root_reach.id', - entity_ref: - 'refresh_state_references.target_entity_ref', - }) - .from('refresh_state_references') - .crossJoin('root_reach', { - 'root_reach.entity_ref': - 'refresh_state_references.source_entity_ref', - }) - ); - }); - }) - .select('us.entity_ref') - // Start out with our own matching row - .from('refresh_state_references') - .where('source_key', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - // Expand the entire tree that emanates from that row - .leftJoin({ us: 'root_reach' }, function us() { - this.on('us.id', '=', 'refresh_state_references.id'); - }) - // Expand with all roots that target the same node but aren't ourselves - .leftOuterJoin({ them: 'root_reach' }, function them() { - this.on('them.entity_ref', '=', 'us.entity_ref'); - this.andOn('them.id', '!=', 'us.id'); - }) - // Keep only the matches that had no other rooots - .whereNull('them.id') - ); - }) - .delete(); + const removedCount = await tx('refresh_state') + .whereIn('entity_ref', function orphanedEntityRefs(orphans) { + return ( + orphans + // All the refs that can be reached from each individual root + .withRecursive('root_reach', function rootReach(outer) { + // Start with all roots + return outer + .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .whereNotNull('source_key') + .union(function recurse(inner) { + return ( + inner + // For each match, select all children + .select({ + id: 'root_reach.id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('refresh_state_references') + .crossJoin('root_reach', { + 'root_reach.entity_ref': + 'refresh_state_references.source_entity_ref', + }) + ); + }); + }) + .select('us.entity_ref') + // Start out with our own matching row + .from('refresh_state_references') + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + // Expand the entire tree that emanates from that row + .leftJoin({ us: 'root_reach' }, function us() { + this.on('us.id', '=', 'refresh_state_references.id'); + }) + // Expand with all roots that target the same node but aren't ourselves + .leftOuterJoin({ them: 'root_reach' }, function them() { + this.on('them.entity_ref', '=', 'us.entity_ref'); + this.andOn('them.id', '!=', 'us.id'); + }) + // Keep only the matches that had no other rooots + .whereNull('them.id') + ); + }) + .delete(); - await tx('refresh_state_references') - .where('source_key', '=', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - .delete(); + await tx('refresh_state_references') + .where('source_key', '=', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .delete(); - console.log( - `REMOVED, ${removedCount} entities: ${JSON.stringify(toRemove)}`, - ); - } + this.logger.debug( + `removed, ${removedCount} entities: ${JSON.stringify(toRemove)}`, + ); + } - if (toAdd.length) { - const state: Knex.DbRecord[] = toAdd.map(item => ({ - entity_id: item.id, - entity_ref: item.ref, - unprocessed_entity: JSON.stringify(item.entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - })); - const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( - item => ({ - source_key: options.sourceKey, - target_entity_ref: item.ref, - }), - ); - // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense - await tx.batchInsert('refresh_state', state, BATCH_SIZE); - await tx.batchInsert( - 'refresh_state_references', - stateReferences, - BATCH_SIZE, - ); - } + if (toAdd.length) { + const state: Knex.DbRecord[] = toAdd.map(entity => ({ + entity_id: uuid(), + entity_ref: stringifyEntityRef(entity), + unprocessed_entity: JSON.stringify(entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + })); - // for (const entity of options.items) { - // const entityRef = stringifyEntityRef(entity); - // await tx('refresh_state') - // .insert({ - // entity_id: uuid(), - // entity_ref: entityRef, - // unprocessed_entity: JSON.stringify(entity), - // errors: '', - // next_update_at: tx.fn.now(), - // last_discovery_at: tx.fn.now(), - // }) - // .onConflict('entity_ref') - // .merge(['unprocessed_entity', 'last_discovery_at']); - - // const [{ entity_id: entityId }] = await tx( - // 'refresh_state', - // ).where({ entity_ref: entityRef }); - // entityIds.push(entityId); - // } - // const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - // entityId => ({ - // source_key: options.sourceKey, - // target_entityRef: entityRef, - // }), - // ); - // await tx.batchInsert( - // 'refresh_state_references', - // referenceRows, - // BATCH_SIZE, - // ); - // return; + const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( + entity => ({ + source_key: options.sourceKey, + target_entity_ref: stringifyEntityRef(entity), + }), + ); + // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense + await tx.batchInsert('refresh_state', state, BATCH_SIZE); + await tx.batchInsert( + 'refresh_state_references', + stateReferences, + BATCH_SIZE, + ); } } From b8e80eab23b8e4aadd79b75de16a42839c6e4c45 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 10:14:45 +0200 Subject: [PATCH 15/88] catalog-backend: Remove obervables Signed-off-by: Johan Haals --- plugins/catalog-backend/package.json | 4 +- .../src/next/DatabaseLocationProvider.ts | 71 ------------------- .../next/DefaultCatalogProcessingEngine.ts | 27 +------ .../src/next/DefaultLocationStore.ts | 4 -- plugins/catalog-backend/src/next/types.ts | 5 +- 5 files changed, 5 insertions(+), 106 deletions(-) delete mode 100644 plugins/catalog-backend/src/next/DatabaseLocationProvider.ts diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 9afb537637..5f70ec60e1 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -33,7 +33,6 @@ "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/plugin-search-backend-node": "^0.1.3", @@ -61,8 +60,7 @@ "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.3", - "zen-observable": "^0.8.15" + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.6.9", diff --git a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts b/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts deleted file mode 100644 index eaf5a78556..0000000000 --- a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Observable } from '@backstage/core'; -import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { EntityProvider, LocationStore, EntityMessage } from './types'; -import ObservableImpl from 'zen-observable'; -import { - locationSpecToLocationEntity, - locationSpecToMetadataName, -} from './util'; - -export class DatabaseLocationProvider implements EntityProvider { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - constructor(private readonly store: LocationStore) { - store.location$().subscribe({ - next: locations => { - if ('all' in locations) { - this.notify({ - all: locations.all.map(l => locationSpecToLocationEntity(l)), - }); - } else { - this.notify({ - added: locations.added.map(l => locationSpecToLocationEntity(l)), - removed: locations.removed.map(l => ({ - kind: 'Location', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: locationSpecToMetadataName(l), - })), - }); - } - }, - }); - } - - private notify(message: EntityMessage) { - for (const subscriber of this.subscribers) { - subscriber.next(message); - } - } - - entityChange$(): Observable { - return new ObservableImpl(subscriber => { - this.store.listLocations().then(locations => { - subscriber.next({ - all: locations.map(l => locationSpecToLocationEntity(l)), - }); - this.subscribers.add(subscriber); - }); - return () => { - this.subscribers.delete(subscriber); - }; - }); - } -} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 7f49bf832d..b71b2c1045 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { Subscription } from '@backstage/core'; import { CatalogProcessingEngine, EntityProvider, - EntityMessage, EntityProviderConnection, EntityProviderMutation, ProcessingStateManager, @@ -40,7 +38,7 @@ class Connection implements EntityProviderConnection { async applyMutation(mutation: EntityProviderMutation): Promise { if (mutation.type === 'full') { await this.config.stateManager.replaceProcessingItems({ - id: this.config.id, + sourceKey: this.config.id, type: 'full', items: mutation.entities, }); @@ -49,7 +47,7 @@ class Connection implements EntityProviderConnection { } await this.config.stateManager.replaceProcessingItems({ - id: this.config.id, + sourceKey: this.config.id, type: 'delta', added: mutation.added, removed: mutation.removed, @@ -120,25 +118,4 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async stop() { this.running = false; } - - private async onNext(id: string, message: EntityMessage) { - if ('all' in message) { - // TODO unhandled rejection - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.all, - }); - } - - if ('added' in message) { - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.added, - }); - - // TODO deletions of message.removed - } - } } diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index dff424b1a9..21206f0a7b 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -25,10 +25,6 @@ import { v4 as uuidv4 } from 'uuid'; import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; -export type LocationMessage = - | { all: Location[] } - | { added: Location[]; removed: Location[] }; - export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index f8b4de1c41..a39ab9a8ff 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -16,7 +16,6 @@ import { Entity, - EntityName, LocationSpec, Location, EntityRelationSpec, @@ -116,12 +115,12 @@ export type ProcessingItem = { export type ReplaceProcessingItemsRequest = | { - id: string; + sourceKey: string; items: Entity[]; type: 'full'; } | { - id: string; + sourceKey: string; added: Entity[]; removed: Entity[]; type: 'delta'; From 464863419888171a95d805f3d51cc44bd8950de8 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 13:56:32 +0200 Subject: [PATCH 16/88] Refactor deferredEntities processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Signed-off-by: Johan Haals --- .../src/next/DefaultLocationStore.ts | 8 + .../src/next/DefaultProcessingStateManager.ts | 39 ++- .../src/next/NextCatalogBuilder.ts | 1 - plugins/catalog-backend/src/next/Stitcher.ts | 4 +- .../DefaultProcessingDatabase.test.ts | 83 ++++-- .../database/DefaultProcessingDatabase.ts | 236 ++++++++++-------- .../src/next/database/types.ts | 7 +- 7 files changed, 222 insertions(+), 156 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 21206f0a7b..40870cff7b 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -104,5 +104,13 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async connect(connection: EntityProviderConnection): Promise { this._connection = connection; + const locations = await this.db.locations(); + const entities = locations.map(location => { + return locationSpecToLocationEntity(location); + }); + await this.connection.applyMutation({ + type: 'full', + entities, + }); } } diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index d2765c3101..0003b7a7cf 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -49,35 +49,28 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { async addProcessingItems(request: AddProcessingItemRequest) { return this.db.transaction(async tx => { - await this.db.addUnprocessedEntities(tx, request); + // await this.db.addUnprocessedEntities(tx, request); }); } async getNextProcessingItem(): Promise { - const entities = await new Promise(resolve => - this.popFromQueue(resolve), - ); - const { id, state, unprocessedEntity } = entities[0]; - return { - id, - entity: unprocessedEntity, - state, - }; - } - - async popFromQueue(resolve: (rows: RefreshStateItem[]) => void) { - const entities = await this.db.transaction(async tx => { - return this.db.getProcessableEntities(tx, { - processBatchSize: 1, + for (;;) { + const { items } = await this.db.transaction(async tx => { + return this.db.getProcessableEntities(tx, { + processBatchSize: 1, + }); }); - }); - // No entities require refresh, wait and try again. - if (!entities.items.length) { - setTimeout(() => this.popFromQueue(resolve), 1000); - return; + if (items.length) { + const { id, state, unprocessedEntity } = items[0]; + return { + id, + entity: unprocessedEntity, + state, + }; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); } - - resolve(entities.items); } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 1796847164..d720bd331a 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -67,7 +67,6 @@ import { LocationAnalyzer } from '../ingestion/types'; import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; -import { DatabaseLocationProvider } from '../next/DatabaseLocationProvider'; import { DefaultLocationStore } from './DefaultLocationStore'; import { DefaultProcessingStateManager } from './DefaultProcessingStateManager'; import { CatalogProcessingEngine } from '../next/types'; diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 3e5e3ac24b..f53b7c6fad 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -76,8 +76,8 @@ export class Stitcher { const [reference_count_result] = await tx( 'refresh_state_references', ) - .where({ target_entity_id: entity.metadata.uid }) - .count({ reference_count: 'target_entity_id' }); + .where({ target_entity_ref: entityRef }) + .count({ reference_count: 'target_entity_ref' }); if (Number(reference_count_result.reference_count) === 0) { this.logger.debug(`${entityRef} is orphan`); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index c4784c081d..14990a2a79 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -27,27 +27,26 @@ import * as uuid from 'uuid'; import { getVoidLogger } from '@backstage/backend-common'; describe('Default Processing Database', () => { - let db: Knex | undefined; - let processingDatabase: DefaultProcessingDatabase | undefined; - + let db: Knex; + let processingDatabase: DefaultProcessingDatabase; const logger = getVoidLogger(); beforeEach(async () => { db = await DatabaseManager.createTestDatabaseConnection(); await DatabaseManager.createDatabase(db); - processingDatabase = new DefaultProcessingDatabase(db!, logger); + processingDatabase = new DefaultProcessingDatabase(db, logger); }); describe('replaceUnprocessedEntities', () => { const insertRefRow = async (ref: DbRefreshStateReferencesRow) => { - return db!( - 'refresh_state_references', - ).insert(ref); + return db('refresh_state_references').insert( + ref, + ); }; const insertRefreshStateRow = async (ref: DbRefreshStateRow) => { - await db!('refresh_state').insert(ref); + await db('refresh_state').insert(ref); }; const createLocations = async (entityRefs: string[]) => { @@ -101,8 +100,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/root-2', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'full', sourceKey: 'config', items: [ @@ -117,11 +116,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -205,8 +204,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/root-2', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'full', sourceKey: 'config', items: [ @@ -221,11 +220,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -296,8 +295,8 @@ describe('Default Processing Database', () => { }); it('should add new locations using the delta options', async () => { - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'delta', sourceKey: 'lols', removed: [], @@ -313,11 +312,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -336,6 +335,42 @@ describe('Default Processing Database', () => { ).toBeTruthy(); }); + it('should not remove locations that are referenced elsewhere', async () => { + /* + config-1 -> location:default/root + config-2 -> location:default/root + */ + await createLocations(['location:default/root']); + + await insertRefRow({ + source_key: 'config-1', + target_entity_ref: 'location:default/root', + }); + await insertRefRow({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config-1', + items: [], + }); + }); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + expect(currentRefRowState).toEqual({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }); + + // TODO: assert that root wasn't removed + }); + it('should remove old locations using the delta options', async () => { await createLocations(['location:default/new-root']); @@ -344,8 +379,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/new-root', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'delta', sourceKey: 'lols', added: [], @@ -361,11 +396,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index a80325d469..dc5e6036f5 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -26,10 +26,12 @@ import { UpdateProcessedEntityOptions, GetProcessableEntitiesResult, ReplaceUnprocessedEntitiesOptions, + RefreshStateItem, } from './types'; import type { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/config'; export type DbRefreshStateRow = { entity_id: string; @@ -88,7 +90,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { errors, }) .where('entity_id', id); - if (refreshResult === 0) { throw new NotFoundError(`Processing state not found for ${id}`); } @@ -96,8 +97,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { // Schedule all deferred entities for future processing. await this.addUnprocessedEntities(tx, { entities: deferredEntities, - id, - type: 'entity', + entityRef: stringifyEntityRef(processedEntity), }); // Update fragments @@ -172,80 +172,104 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { if (toRemove.length) { // TODO(freben): Batch split, to not hit variable limits? /* - WITH RECURSIVE - -- All the refs that can be reached from each individual root - root_reach(id, entity_ref) AS ( - -- Start with all roots - SELECT id, target_entity_ref - FROM refresh_state_references - WHERE source_key IS NOT NULL - UNION - -- For each match, select all children - SELECT root_reach.id, target_entity_ref - FROM refresh_state_references, root_reach - WHERE source_entity_ref = root_reach.entity_ref - ) - -- Start out with our own matching row (see the WHERE that - -- matches on source_key and target_entity_ref below) - SELECT us.entity_ref - FROM refresh_state_references - -- Expand the entire tree that emanates from that row - JOIN root_reach AS us - ON us.id = refresh_state_references.id - -- Expand with all roots that target the same node but - -- aren't ourselves - LEFT OUTER JOIN root_reach AS them - ON them.entity_ref = us.entity_ref - AND them.id != us.id - -- Keep only the matches that had no other rooots - WHERE refresh_state_references.source_key = "R1" - AND refresh_state_references.target_entity_ref = "A" - AND them.id IS NULL; - */ + WITH RECURSIVE + -- All the nodes that can be reached downwards from our root + descendants(root_id, entity_ref) AS ( + SELECT id, target_entity_ref + FROM refresh_state_references + WHERE source_key = "R1" AND target_entity_ref = "A" + UNION + SELECT descendants.root_id, target_entity_ref + FROM descendants + JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref + ), + -- All the nodes that can be reached upwards from the descendants + ancestors(root_id, via_entity_ref, to_entity_ref) AS ( + SELECT NULL, entity_ref, entity_ref + FROM descendants + UNION + SELECT + CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END, + source_entity_ref, + ancestors.to_entity_ref + FROM ancestors + JOIN refresh_state_references ON target_entity_ref = ancestors.via_entity_ref + ) + -- Start out with all of the descendants + SELECT descendants.entity_ref + FROM descendants + -- Expand with all ancestors that point to those, but aren't the current root + LEFT OUTER JOIN ancestors + ON ancestors.to_entity_ref = descendants.entity_ref + AND ancestors.root_id IS NOT NULL + AND ancestors.root_id != descendants.root_id + -- Exclude all lines that had such a foreign ancestor + WHERE ancestors.root_id IS NULL; + */ const removedCount = await tx('refresh_state') .whereIn('entity_ref', function orphanedEntityRefs(orphans) { return ( orphans - // All the refs that can be reached from each individual root - .withRecursive('root_reach', function rootReach(outer) { - // Start with all roots + // All the nodes that can be reached downwards from our root + .withRecursive('descendants', function descendants(outer) { return outer - .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .select({ root_id: 'id', entity_ref: 'target_entity_ref' }) .from('refresh_state_references') - .whereNotNull('source_key') - .union(function recurse(inner) { - return ( - inner - // For each match, select all children - .select({ - id: 'root_reach.id', - entity_ref: - 'refresh_state_references.target_entity_ref', - }) - .from('refresh_state_references') - .crossJoin('root_reach', { - 'root_reach.entity_ref': - 'refresh_state_references.source_entity_ref', - }) - ); + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .union(function recursive(inner) { + return inner + .select({ + root_id: 'descendants.root_id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('descendants') + .join('refresh_state_references', { + 'descendants.entity_ref': + 'refresh_state_references.source_entity_ref', + }); }); }) - .select('us.entity_ref') - // Start out with our own matching row - .from('refresh_state_references') - .where('source_key', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - // Expand the entire tree that emanates from that row - .leftJoin({ us: 'root_reach' }, function us() { - this.on('us.id', '=', 'refresh_state_references.id'); + // All the nodes that can be reached upwards from the descendants + .withRecursive('ancestors', function ancestors(outer) { + return outer + .select({ + root_id: tx.raw('NULL', []), + via_entity_ref: 'entity_ref', + to_entity_ref: 'entity_ref', + }) + .from('descendants') + .union(function recursive(inner) { + return inner + .select({ + root_id: tx.raw( + 'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END', + [], + ), + via_entity_ref: 'source_entity_ref', + to_entity_ref: 'ancestors.to_entity_ref', + }) + .from('ancestors') + .join('refresh_state_references', { + target_entity_ref: 'ancestors.via_entity_ref', + }); + }); }) - // Expand with all roots that target the same node but aren't ourselves - .leftOuterJoin({ them: 'root_reach' }, function them() { - this.on('them.entity_ref', '=', 'us.entity_ref'); - this.andOn('them.id', '!=', 'us.id'); + // Start out with all of the descendants + .select('descendants.entity_ref') + .from('descendants') + // Expand with all ancestors that point to those, but aren't the current root + .leftOuterJoin('ancestors', function keepaliveRoots() { + this.on( + 'ancestors.to_entity_ref', + '=', + 'descendants.entity_ref', + ); + this.andOnNotNull('ancestors.root_id'); + this.andOn('ancestors.root_id', '!=', 'descendants.root_id'); }) - // Keep only the matches that had no other rooots - .whereNull('them.id') + .whereNull('ancestors.root_id') ); }) .delete(); @@ -291,44 +315,45 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { options: AddUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - const entityIds = new Array(); - for (const entity of options.entities) { - const entityRef = stringifyEntityRef(entity); - await tx('refresh_state') - .insert({ + const stateRows = options.entities.map( + entity => + ({ entity_id: uuid(), - entity_ref: entityRef, + entity_ref: stringifyEntityRef(entity), unprocessed_entity: JSON.stringify(entity), errors: '', next_update_at: tx.fn.now(), last_discovery_at: tx.fn.now(), - }) + } as Knex.DbRecord), + ); + const stateReferenceRows = stateRows.map( + stateRow => + ({ + source_entity_ref: options.entityRef, + target_entity_ref: stateRow.entity_ref, + } as Knex.DbRecord), + ); + + // Upsert all of the unprocessed entities into the refresh_state table, by + // their entity ref. + // TODO(freben): Can this be batched somehow? + for (const row of stateRows) { + await tx('refresh_state') + .insert(row) .onConflict('entity_ref') .merge(['unprocessed_entity', 'last_discovery_at']); - - const [{ entity_id: entityId }] = await tx( - 'refresh_state', - ).where({ entity_ref: entityRef }); - entityIds.push(entityId); } - const key = - options.type === 'provider' - ? { source_special_key: options.id } - : { source_entity_id: options.id }; - // copied from update refs + // Replace all references for the originating entity before creating new ones await tx('refresh_state_references') - .where(key) + .where({ source_entity_ref: options.entityRef }) .delete(); - - const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - entityId => ({ - ...key, - target_entity_id: entityId, - }), + await tx.batchInsert( + 'refresh_state_references', + stateReferenceRows, + BATCH_SIZE, ); - await tx.batchInsert('refresh_state_references', referenceRows, BATCH_SIZE); } async getProcessableEntities( @@ -356,16 +381,23 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { }); return { - items: items.map(i => ({ - id: i.entity_id, - entityRef: i.entity_ref, - unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, - processedEntity: JSON.parse(i.processed_entity) as Entity, - nextUpdateAt: i.next_update_at, - lastDiscoveryAt: i.last_discovery_at, - state: JSON.parse(i.cache), - errors: i.errors, - })), + items: items.map( + i => + ({ + id: i.entity_id, + entityRef: i.entity_ref, + unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, + processedEntity: i.processed_entity + ? (JSON.parse(i.processed_entity) as Entity) + : undefined, + nextUpdateAt: i.next_update_at, + lastDiscoveryAt: i.last_discovery_at, + state: i.cache + ? JSON.parse(i.cache) + : new Map(), + errors: i.errors, + } as RefreshStateItem), + ), }; } diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index b926676887..d7a1dcd20b 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -19,8 +19,7 @@ import { JsonObject } from '@backstage/config'; import { Transaction } from '../../database/types'; export type AddUnprocessedEntitiesOptions = { - type: 'entity' | 'provider'; - id: string; + entityRef: string; entities: Entity[]; }; @@ -39,11 +38,11 @@ export type RefreshStateItem = { id: string; entityRef: string; unprocessedEntity: Entity; - processedEntity: Entity; + processedEntity?: Entity; nextUpdateAt: string; lastDiscoveryAt: string; // remove? state: Map; - errors: string; + errors?: string; }; export type GetProcessableEntitiesResult = { From f22ac8403507761e188e28ae25bb49433c694d2d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:02:41 +0200 Subject: [PATCH 17/88] Remove addProcessingItems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/Stitcher.ts | 4 ++-- plugins/catalog-backend/src/next/types.ts | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index f53b7c6fad..57f5ae9167 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -19,7 +19,7 @@ import { Logger } from 'winston'; import { Transaction } from '../database'; import { ConflictError } from '@backstage/errors'; import { - DbRefreshStateReferences, + DbRefreshStateReferencesRow, DbRefreshStateRow, DbRelationsRow, } from './database/DefaultProcessingDatabase'; @@ -73,7 +73,7 @@ export class Stitcher { return; } - const [reference_count_result] = await tx( + const [reference_count_result] = await tx( 'refresh_state_references', ) .where({ target_entity_ref: entityRef }) diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index a39ab9a8ff..ef3bab797b 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -101,12 +101,6 @@ export type ProcessingItemResult = { deferredEntities: Entity[]; }; -export type AddProcessingItemRequest = { - type: 'entity' | 'provider'; - id: string; - entities: Entity[]; -}; - export type ProcessingItem = { id: string; entity: Entity; @@ -128,6 +122,5 @@ export type ReplaceProcessingItemsRequest = export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; getNextProcessingItem(): Promise; - addProcessingItems(request: AddProcessingItemRequest): Promise; replaceProcessingItems(request: ReplaceProcessingItemsRequest): Promise; } From d77f5fea4406de005f25d052a90423f494d8bfcb Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:12:57 +0200 Subject: [PATCH 18/88] Add moar tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index 14990a2a79..f67b8603db 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -359,16 +359,26 @@ describe('Default Processing Database', () => { }); }); + const currentRefreshState = await db( + 'refresh_state', + ).select(); + const currentRefRowState = await db( 'refresh_state_references', ).select(); - expect(currentRefRowState).toEqual({ - source_key: 'config-2', - target_entity_ref: 'location:default/root', - }); + expect(currentRefRowState).toEqual([ + expect.objectContaining({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }), + ]); - // TODO: assert that root wasn't removed + expect(currentRefreshState).toEqual([ + expect.objectContaining({ + entity_ref: 'location:default/root', + }), + ]); }); it('should remove old locations using the delta options', async () => { From c1156a4b4748dfe9661ca9727e11184065d35d87 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:15:29 +0200 Subject: [PATCH 19/88] Remove dead code Signed-off-by: Johan Haals --- .../src/next/DefaultProcessingStateManager.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index 0003b7a7cf..9c1d20ae9c 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { ProcessingDatabase, RefreshStateItem } from './database/types'; +import { ProcessingDatabase } from './database/types'; import { - AddProcessingItemRequest, ProcessingItem, ProcessingItemResult, ProcessingStateManager, @@ -47,12 +46,6 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { }); } - async addProcessingItems(request: AddProcessingItemRequest) { - return this.db.transaction(async tx => { - // await this.db.addUnprocessedEntities(tx, request); - }); - } - async getNextProcessingItem(): Promise { for (;;) { const { items } = await this.db.transaction(async tx => { From 656e1d82b281f4f3a9518e27d1bfe014393c2c91 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 15:38:31 +0200 Subject: [PATCH 20/88] Add all migration files to migrationsv2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../migrationsv2/20200511113813_init.js | 133 ++++++++++ ...0200520140700_location_update_log_table.js | 43 ++++ ...7114117_location_update_log_latest_view.js | 45 ++++ .../migrationsv2/20200702153613_entities.js | 236 ++++++++++++++++++ ..._location_update_log_latest_deduplicate.js | 44 ++++ ...904_location_update_log_duplication_fix.js | 87 +++++++ .../20200807120600_entitySearch.js | 41 +++ .../20200809202832_add_bootstrap_location.js | 42 ++++ .../20200923104503_case_insensitivity.js | 32 +++ .../20201005122705_add_entity_full_name.js | 60 +++++ .../20201006130744_entity_data_column.js | 66 +++++ ...6203131_entity_remove_redundant_columns.js | 50 ++++ .../20201007201501_index_entity_search.js | 37 +++ .../20201019130742_add_relations_table.js | 54 ++++ .../20201123205611_relations_table_uniq.js | 93 +++++++ .../migrationsv2/20201210185851_fk_index.js | 45 ++++ .../20201230103504_update_log_varchar.js | 73 ++++++ .../20210209121210_locations_fk_index.js | 45 ++++ .../src/next/database/DatabaseManager.ts | 15 -- 19 files changed, 1226 insertions(+), 15 deletions(-) create mode 100644 plugins/catalog-backend/migrationsv2/20200511113813_init.js create mode 100644 plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js create mode 100644 plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js create mode 100644 plugins/catalog-backend/migrationsv2/20200702153613_entities.js create mode 100644 plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js create mode 100644 plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js create mode 100644 plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js create mode 100644 plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js create mode 100644 plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js create mode 100644 plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js create mode 100644 plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js create mode 100644 plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js create mode 100644 plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js create mode 100644 plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js create mode 100644 plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js create mode 100644 plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js create mode 100644 plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js create mode 100644 plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js diff --git a/plugins/catalog-backend/migrationsv2/20200511113813_init.js b/plugins/catalog-backend/migrationsv2/20200511113813_init.js new file mode 100644 index 0000000000..7f3d75e35c --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200511113813_init.js @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return ( + knex.schema + // + // locations + // + .createTable('locations', table => { + table.comment( + 'Registered locations that shall be contiuously scanned for catalog item updates', + ); + table + .uuid('id') + .primary() + .notNullable() + .comment('Auto-generated ID of the location'); + table.string('type').notNullable().comment('The type of location'); + table + .string('target') + .notNullable() + .comment('The actual target of the location'); + }) + // + // entities + // + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }) + // + // entities_search + // + .createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }) + ); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema + .dropTable('entities_search') + .alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }) + .dropTable('entities') + .dropTable('locations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js new file mode 100644 index 0000000000..d8093fc9b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return knex.schema.createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.dropTableIfExists('location_update_log'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js new file mode 100644 index 0000000000..a0f0f33a65 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Get list sorted by created_at timestamp in descending order + // Grouped by location_id + return knex.schema.raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200702153613_entities.js b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js new file mode 100644 index 0000000000..0f1c204f9b --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js @@ -0,0 +1,236 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js new file mode 100644 index 0000000000..87b41a80fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; +`); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js new file mode 100644 index 0000000000..de2b194cff --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.bigIncrements('id').primary(); // instead of uuid, so we can MAX it + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js new file mode 100644 index 0000000000..45226e53b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.text('value').nullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.string('value').nullable().alter(); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js new file mode 100644 index 0000000000..a90813fe85 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Adds a single 'bootstrap' location that can be used to trigger work in processors. + // This is primarily here to fulfill foreign key constraints. + await knex('locations').insert({ + id: require('uuid').v4(), + type: 'bootstrap', + target: 'bootstrap', + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex('locations') + .where({ + type: 'bootstrap', + target: 'bootstrap', + }) + .del(); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js new file mode 100644 index 0000000000..ea5ba9e58d --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex('entities') + .where({ namespace: null }) + .update({ namespace: 'default' }); + await knex('entities_search').update({ + key: knex.raw('LOWER(key)'), + value: knex.raw('LOWER(value)'), + }); +}; + +exports.down = async function down() {}; diff --git a/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js new file mode 100644 index 0000000000..aae1861658 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.text('full_name').nullable(); + }); + + await knex('entities').update({ + full_name: knex.raw( + "LOWER(kind) || ':' || LOWER(COALESCE(namespace, 'default')) || '/' || LOWER(name)", + ), + }); + + // SQLite does not support alter column + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('full_name').notNullable().alter(); + }); + } + + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['full_name'], 'entities_unique_full_name'); + table.dropUnique([], 'entities_unique_name'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.dropUnique([], 'entities_unique_full_name'); + table.unique(['kind', 'namespace', 'name'], 'entities_unique_name'); + }); + + await knex.schema.alterTable('entities_search', table => { + table.dropColumn('full_name'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js new file mode 100644 index 0000000000..a8964efbf6 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('data') + .nullable() + .comment('The entire JSON data blob of the entity'); + }); + + await knex('entities').update({ + // apiVersion and kind should not contain any JSON unsafe chars, and both + // metadata and spec are already valid serialized JSON + data: knex.raw( + `'{"apiVersion":"' || api_version || '","kind":"' || kind || '","metadata":' || metadata || COALESCE(',"spec":' || spec, '') || '}'`, + ), + }); + + await knex.schema.alterTable('entities', table => { + table.dropColumn('metadata'); + table.dropColumn('spec'); + }); + + // SQLite does not support ALTER COLUMN. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('data').notNullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + table.dropColumn('data'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js new file mode 100644 index 0000000000..f40df5f73e --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.dropColumn('api_version'); + table.dropColumn('kind'); + table.dropColumn('name'); + table.dropColumn('namespace'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table.string('kind').notNullable().comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js new file mode 100644 index 0000000000..77bf0529eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities_search', table => { + table.index(['key'], 'entities_search_key'); + table.index(['value'], 'entities_search_value'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities_search', table => { + table.dropIndex('', 'entities_search_key'); + table.dropIndex('', 'entities_search_value'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js new file mode 100644 index 0000000000..85e729f814 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('entities_relations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js new file mode 100644 index 0000000000..9e8198b5eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + // sqlite doesn't support dropPrimary so we recreate it properly instead + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + table.index('source_full_name', 'source_full_name_idx'); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropPrimary(); + table.index('source_full_name', 'source_full_name_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'source_full_name_idx'); + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js new file mode 100644 index 0000000000..abb26cd5fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js new file mode 100644 index 0000000000..d924b0414a --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + // We actually just want to widen columns, but can't do that while a + // view is dependent on them - so we just reconstruct it exactly as it was + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.text('message').alter(); + table.text('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.string('message').alter(); + table.string('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js new file mode 100644 index 0000000000..ccfb1faffb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.index('location_id', 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.index('location_id', 'update_log_location_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.dropIndex([], 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.dropIndex([], 'update_log_location_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts index 4c9e45950f..f660f73ecb 100644 --- a/plugins/catalog-backend/src/next/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -20,7 +20,6 @@ import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; import { CommonDatabase } from '../../database/CommonDatabase'; import { Database } from '../../database/types'; -import fs from 'fs-extra'; export type CreateDatabaseOptions = { logger: Logger; @@ -35,16 +34,10 @@ export class DatabaseManager { knex: Knex, options: Partial = {}, ): Promise { - const allMigrations = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrations', - ); - const migrationsDir = resolvePackagePath( '@backstage/plugin-catalog-backend', 'migrationsv2', ); - await fs.copy(allMigrations, migrationsDir); await knex.migrate.latest({ directory: migrationsDir, @@ -79,14 +72,6 @@ export class DatabaseManager { public static async createTestDatabaseConnection(): Promise { const config: Knex.Config = { - /* - client: 'pg', - connection: { - host: 'localhost', - user: 'postgres', - password: 'postgres', - }, - */ client: 'sqlite3', connection: ':memory:', useNullAsDefault: true, From fae7eb02965c17534168b0c85580ea5367e05477 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 15:43:22 +0200 Subject: [PATCH 21/88] Add configLocationProvider and provider identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../src/next/ConfigLocationProvider.ts | 49 +++++++++++++++++++ .../next/DefaultCatalogProcessingEngine.ts | 9 ++-- .../src/next/DefaultLocationStore.ts | 4 ++ .../src/next/NextCatalogBuilder.ts | 7 +-- plugins/catalog-backend/src/next/types.ts | 1 + 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 plugins/catalog-backend/src/next/ConfigLocationProvider.ts diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts new file mode 100644 index 0000000000..5b93fcfd98 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityProviderConnection, EntityProvider } from './types'; +import path from 'path'; +import { Config } from '@backstage/config'; +import { locationSpecToLocationEntity } from './util'; + +export class ConfigLocationProvider implements EntityProvider { + private connection: EntityProviderConnection | undefined; + + constructor(private readonly config: Config) {} + + getProviderName(): string { + return 'ConfigLocationProvider'; + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + + const locationConfigs = + this.config.getOptionalConfigArray('catalog.locations') ?? []; + + const entities = locationConfigs.map(location => + locationSpecToLocationEntity({ + type: location.getString('type'), + target: path.resolve(location.getString('target')), + }), + ); + + await this.connection.applyMutation({ + type: 'full', + entities, + }); + } +} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index b71b2c1045..1934b0aea7 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -68,9 +68,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - // TODO: this ID should be some form of identifier for the EntityProvider - const id = 'databaseProvider'; - provider.connect(new Connection({ stateManager: this.stateManager, id })); + provider.connect( + new Connection({ + stateManager: this.stateManager, + id: provider.getProviderName(), + }), + ); } this.running = true; diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 40870cff7b..a56e5d1585 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -30,6 +30,10 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { constructor(private readonly db: Database) {} + getProviderName(): string { + return 'DefaultLocationStore'; + } + createLocation(spec: LocationSpec): Promise { return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index d720bd331a..0699a13f58 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -50,6 +50,7 @@ import { GithubDiscoveryProcessor, GithubOrgReaderProcessor, LdapOrgReaderProcessor, + LocationEntityProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -73,6 +74,7 @@ import { CatalogProcessingEngine } from '../next/types'; import { NextEntitiesCatalog } from './NextEntitiesCatalog'; import { Stitcher } from './Stitcher'; import { CommonDatabase } from '../database/CommonDatabase'; +import { ConfigLocationProvider } from './ConfigLocationProvider'; export type CatalogEnvironment = { logger: Logger; @@ -272,9 +274,10 @@ export class NextCatalogBuilder { const locationStore = new DefaultLocationStore(db); const stitcher = new Stitcher(dbClient, logger); + const configLocationProvider = new ConfigLocationProvider(config); const processingEngine = new DefaultCatalogProcessingEngine( logger, - [locationStore], // entityproviders + [locationStore, configLocationProvider], stateManager, orchestrator, stitcher, @@ -322,7 +325,6 @@ export class NextCatalogBuilder { // These are always there no matter what const processors: CatalogProcessor[] = [ - StaticLocationProcessor.fromConfig(config), new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }), new BuiltinKindsEntityProcessor(), ]; @@ -338,7 +340,6 @@ export class NextCatalogBuilder { MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), CodeOwnersProcessor.fromConfig(config, { logger, reader }), - // new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor({ integrations }), ); } diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index ef3bab797b..e0616fb336 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -65,6 +65,7 @@ export interface EntityProviderConnection { } export interface EntityProvider { + getProviderName(): string; connect(connection: EntityProviderConnection): Promise; } From e00ba124546a742639973fd26edb39635dff5351 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 16:53:44 +0200 Subject: [PATCH 22/88] chore: remove unused imports Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/NextCatalogBuilder.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 0699a13f58..24b763a098 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -50,11 +50,9 @@ import { GithubDiscoveryProcessor, GithubOrgReaderProcessor, LdapOrgReaderProcessor, - LocationEntityProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, - StaticLocationProcessor, UrlReaderProcessor, } from '../ingestion'; import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; From b74e3e1efc3739304e37435a58ea3f0f16478bfe Mon Sep 17 00:00:00 2001 From: Victor Perera Date: Mon, 26 Apr 2021 18:53:11 -0500 Subject: [PATCH 23/88] Enable starred templates on scaffolder Signed-off-by: Victor Perera --- .../FavouriteTemplate/FavouriteTemplate.tsx | 67 +++++++++++++++++++ .../ScaffolderPage/ScaffolderPage.tsx | 1 + .../components/TemplateCard/TemplateCard.tsx | 10 ++- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx diff --git a/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx new file mode 100644 index 0000000000..99f6c21a1d --- /dev/null +++ b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { useStarredEntities } from '@backstage/plugin-catalog-react'; +import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core'; +import StarBorder from '@material-ui/icons/StarBorder'; +import Star from '@material-ui/icons/Star'; +import { Entity } from '@backstage/catalog-model'; + +type Props = ComponentProps & { entity: Entity }; + +const YellowStar = withStyles({ + root: { + color: '#f3ba37', + }, +})(Star); + +const useStyles = makeStyles(theme => ({ + starButton: { + position: 'absolute', + top: theme.spacing(0.5), + right: theme.spacing(0.5), + padding: '0.25rem', + }, +})); + +export const favouriteTemplateTooltip = (isStarred: boolean) => + isStarred ? 'Remove from favorites' : 'Add to favorites'; + +export const favouriteTemplateIcon = (isStarred: boolean) => + isStarred ? : ; + +/** + * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities + * @param props MaterialUI IconButton props extended by required `entity` prop + */ +export const FavouriteTemplate = (props: Props) => { + const classes = useStyles(); + const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); + const isStarred = isStarredEntity(props.entity); + return ( + toggleStarredEntity(props.entity)} + > + + {favouriteTemplateIcon(isStarred)} + + + ); +}; diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 68bf588d01..00b13853ec 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -61,6 +61,7 @@ const getTemplateCardProps = ( type: template.spec.type ?? '', description: template.metadata.description ?? '-', tags: (template.metadata?.tags as string[]) ?? [], + entityTemplate: template, }; }; diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 2ba83fdaae..2e9cfe6470 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -28,8 +28,13 @@ import { import React from 'react'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; +import { Entity } from '@backstage/catalog-model'; +import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; const useStyles = makeStyles({ + cardHeader: { + position: 'relative', + }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, @@ -48,6 +53,7 @@ export type TemplateCardProps = { title: string; type: string; name: string; + entityTemplate: Entity; }; export const TemplateCard = ({ @@ -56,6 +62,7 @@ export const TemplateCard = ({ title, type, name, + entityTemplate, }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); @@ -69,7 +76,8 @@ export const TemplateCard = ({ return ( - + + Date: Mon, 26 Apr 2021 19:00:57 -0500 Subject: [PATCH 24/88] Adding Changeset Signed-off-by: Victor Perera --- .changeset/long-ladybugs-promise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/long-ladybugs-promise.md diff --git a/.changeset/long-ladybugs-promise.md b/.changeset/long-ladybugs-promise.md new file mode 100644 index 0000000000..7d683650c3 --- /dev/null +++ b/.changeset/long-ladybugs-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Enable starred templates on Scaffolder frontend From 4c42ecca2d106c80f358655b7e0863be463049c6 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Mon, 19 Apr 2021 16:31:30 +0100 Subject: [PATCH 25/88] Wrap EmptyState in a Card This prevents it overflowing onto subsequent content Signed-off-by: Andrew Shirley --- .changeset/empty-lizards-doubt.md | 5 +++ .../Cards/RecentWorkflowRunsCard.tsx | 43 ++++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 .changeset/empty-lizards-doubt.md diff --git a/.changeset/empty-lizards-doubt.md b/.changeset/empty-lizards-doubt.md new file mode 100644 index 0000000000..cfe1e128fc --- /dev/null +++ b/.changeset/empty-lizards-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Wrap EmptyState in Card diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 1126c2a2a0..a3605abb7e 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -25,7 +25,14 @@ import { } from '@backstage/core'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { Button, Link } from '@material-ui/core'; +import { + Button, + Card, + CardContent, + CardHeader, + Divider, + Link, +} from '@material-ui/core'; import React, { useEffect } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; @@ -75,20 +82,26 @@ export const RecentWorkflowRunsCard = ({ const githubHost = hostname || 'github.com'; return !runs.length ? ( - - Create new Workflow - - } - /> + + + + + + Create new Workflow + + } + /> + + ) : ( Date: Mon, 26 Apr 2021 17:31:43 +0100 Subject: [PATCH 26/88] Use InfoCard not Card Signed-off-by: Andrew Shirley --- .../Cards/RecentWorkflowRunsCard.tsx | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index a3605abb7e..97428088fc 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -25,14 +25,7 @@ import { } from '@backstage/core'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { - Button, - Card, - CardContent, - CardHeader, - Divider, - Link, -} from '@material-ui/core'; +import { Button, Link } from '@material-ui/core'; import React, { useEffect } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; @@ -82,26 +75,22 @@ export const RecentWorkflowRunsCard = ({ const githubHost = hostname || 'github.com'; return !runs.length ? ( - - - - - - Create new Workflow - - } - /> - - + + + Create new Workflow + + } + /> + ) : ( Date: Mon, 26 Apr 2021 18:04:06 +0100 Subject: [PATCH 27/88] Eliminate EmptyState usage Signed-off-by: Andrew Shirley --- .../Cards/RecentWorkflowRunsCard.tsx | 89 +++++++++---------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 97428088fc..f0366e5590 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -16,21 +16,21 @@ import { Entity } from '@backstage/catalog-model'; import { configApiRef, - EmptyState, errorApiRef, InfoCard, InfoCardVariants, + Link, Table, useApi, } from '@backstage/core'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { Button, Link } from '@material-ui/core'; import React, { useEffect } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { Typography } from '@material-ui/core'; const firstLine = (message: string): string => message.split('\n')[0]; @@ -74,56 +74,53 @@ export const RecentWorkflowRunsCard = ({ const githubHost = hostname || 'github.com'; - return !runs.length ? ( - - - Create new Workflow - - } - /> - - ) : ( + return ( - ( - - {firstLine(data.message)} - - ), - }, - { title: 'Branch', field: 'source.branchName' }, - { title: 'Status', field: 'status', render: WorkflowRunStatus }, - ]} - data={runs} - /> + {!runs.length ? ( +
+ + This component has GitHub Actions enabled, but no workflows were + found. + + + + Create a new workflow + + +
+ ) : ( +
( + + {firstLine(data.message)} + + ), + }, + { title: 'Branch', field: 'source.branchName' }, + { title: 'Status', field: 'status', render: WorkflowRunStatus }, + ]} + data={runs} + /> + )} ); }; From 6c38f5e92031b5574e20d8da28ffeedd89608177 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Tue, 27 Apr 2021 10:55:57 +0200 Subject: [PATCH 28/88] tests(proxy-backend): align tests with default backend configuration The pathPrefix is not suffixed with `/` by default as noted at https://github.com/backstage/backstage/blob/df897a6126d1641d111b015c527cc8d40c47bd06/packages/backend-common/src/discovery/types.ts#L47 and seen in the implementation at https://github.com/backstage/backstage/blob/df897a6126d1641d111b015c527cc8d40c47bd06/packages/backend-common/src/discovery/SingleHostDiscovery.ts#L81. Currently the tests work in the opposite way - the pathPrefix is suffixed by / whereas the routes are **not** prefixed by /. This commit brings the tests in line with the default configuration prior to any further work. Signed-off-by: Brian Fox --- .../proxy-backend/src/service/router.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 6c47043602..3a0a25f164 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -60,7 +60,7 @@ describe('buildMiddleware', () => { }); it('accepts strings', async () => { - buildMiddleware('/api/', logger, 'test', 'http://mocked'); + buildMiddleware('/proxy', logger, '/test', 'http://mocked'); expect(createProxyMiddleware).toHaveBeenCalledTimes(1); @@ -74,13 +74,13 @@ describe('buildMiddleware', () => { expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); - expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); expect(fullConfig.changeOrigin).toBe(true); expect(fullConfig.logProvider!(logger)).toBe(logger); }); it('limits allowedMethods', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedMethods: ['GET', 'DELETE'], }); @@ -97,13 +97,13 @@ describe('buildMiddleware', () => { expect(filter('', { method: 'PATCH', headers: {} })).toBe(false); expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); - expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); expect(fullConfig.changeOrigin).toBe(true); expect(fullConfig.logProvider!(logger)).toBe(logger); }); it('permits default headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', }); @@ -143,7 +143,7 @@ describe('buildMiddleware', () => { }); it('permits default and configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', headers: { Authorization: 'my-token', @@ -176,7 +176,7 @@ describe('buildMiddleware', () => { }); it('permits configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedHeaders: ['authorization', 'cookie'], }); @@ -208,7 +208,7 @@ describe('buildMiddleware', () => { }); it('responds default headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', }); @@ -251,7 +251,7 @@ describe('buildMiddleware', () => { }); it('responds configured headers', async () => { - buildMiddleware('/api/', logger, 'test', { + buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', allowedHeaders: ['set-cookie'], }); @@ -282,10 +282,10 @@ describe('buildMiddleware', () => { it('rejects malformed target URLs', async () => { expect(() => - buildMiddleware('/api/', logger, 'test', 'backstage.io'), + buildMiddleware('/proxy', logger, '/test', 'backstage.io'), ).toThrowError(/Proxy target is not a valid URL/); expect(() => - buildMiddleware('/api/', logger, 'test', { target: 'backstage.io' }), + buildMiddleware('/proxy', logger, '/test', { target: 'backstage.io' }), ).toThrowError(/Proxy target is not a valid URL/); }); }); From cdb3426e56f49dd5dffba3c35eea383a416b1bf1 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Tue, 27 Apr 2021 12:38:31 +0200 Subject: [PATCH 29/88] fix(proxy-backend): prefix routes with `/` if not present in config This commit ensures that the default `pathRewrite` configuration for a proxy element is more resilient to the combination of whether: - `route` is prefixed with `/` - `pathPrefix` on the plugin is suffixed with `/` (which at present it will never be - see comments). Signed-off-by: Brian Fox --- .changeset/thick-cobras-switch.md | 5 +++ docs/plugins/proxying.md | 11 ++--- .../proxy-backend/src/service/router.test.ts | 42 ++++++++++++++++++- plugins/proxy-backend/src/service/router.ts | 16 ++++++- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .changeset/thick-cobras-switch.md diff --git a/.changeset/thick-cobras-switch.md b/.changeset/thick-cobras-switch.md new file mode 100644 index 0000000000..002c724421 --- /dev/null +++ b/.changeset/thick-cobras-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-proxy-backend': patch +--- + +Prefix proxy routes with `/` if not present in configuration diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 50c56af7a6..3e20e37cad 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -36,7 +36,7 @@ Example: ```yaml # in app-config.yaml proxy: - '/simple-example': http://simple.example.com:8080 + simple-example: http://simple.example.com:8080 '/larger-example/v1': target: http://larger.example.com:8080/svc.v1 headers: @@ -46,10 +46,11 @@ proxy: ``` Each key under the proxy configuration entry is a route to match, below the -prefix that the proxy plugin is mounted on. It must start with a slash. For -example, if the backend mounts the proxy plugin as `/proxy`, the above -configuration will lead to the proxy acting on backend requests to -`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`. +prefix that the proxy plugin is mounted on. If it does not start with a slash, +one will be prefixed automatically. For example, if the backend mounts the proxy +plugin as `/proxy`, the above configuration will lead to the proxy acting on +backend requests to `/api/proxy/simple-example/...` and +`/api/proxy/larger-example/v1/...`. The value inside each route is either a simple URL string, or an object on the format accepted by diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 3a0a25f164..571de96ac4 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -59,7 +59,7 @@ describe('buildMiddleware', () => { mockCreateProxyMiddleware.mockClear(); }); - it('accepts strings', async () => { + it('accepts strings prefixed by /', async () => { buildMiddleware('/proxy', logger, '/test', 'http://mocked'); expect(createProxyMiddleware).toHaveBeenCalledTimes(1); @@ -79,6 +79,46 @@ describe('buildMiddleware', () => { expect(fullConfig.logProvider!(logger)).toBe(logger); }); + it('accepts routes not prefixed with / when path is not suffixed with /', async () => { + buildMiddleware('/proxy', logger, 'test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('accepts routes prefixed with / when path is suffixed with /', async () => { + buildMiddleware('/proxy/', logger, '/test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + it('limits allowedMethods', async () => { buildMiddleware('/proxy', logger, '/test', { target: 'http://mocked', diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 5c306de966..71ed82c23b 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -77,10 +77,24 @@ export function buildMiddleware( `Proxy target is not a valid URL: ${fullConfig.target ?? ''}`, ); } + // Default is to do a path rewrite that strips out the proxy's path prefix // and the rest of the route. if (fullConfig.pathRewrite === undefined) { - const routeWithSlash = route.endsWith('/') ? route : `${route}/`; + let routeWithSlash = route.endsWith('/') ? route : `${route}/`; + + if (!pathPrefix.endsWith('/') && !routeWithSlash.startsWith('/')) { + // Need to insert a / between pathPrefix and routeWithSlash + routeWithSlash = `/${routeWithSlash}`; + } else if (pathPrefix.endsWith('/') && routeWithSlash.startsWith('/')) { + // Never expect this to happen at this point in time as + // pathPrefix is set using `getExternalBaseUrl` which "Returns the + // external HTTP base backend URL for a given plugin, + // **without a trailing slash.**". But in case this changes in future, we + // need to drop a / on either pathPrefix or routeWithSlash + routeWithSlash = routeWithSlash.substring(1); + } + fullConfig.pathRewrite = { [`^${pathPrefix}${routeWithSlash}`]: '/', }; From 8ce7d6e8f81d621c66e069c64cfe67f4e42fed3c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 14:16:49 +0200 Subject: [PATCH 30/88] catalog-backend: Add ConfigLocationProvider tests Co-authored-by: Ben Lambert Signed-off-by: Johan Haals --- .../src/next/ConfigLocationProvider.test.ts | 67 +++++++++++++++++++ .../src/next/ConfigLocationProvider.ts | 14 ++-- 2 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts new file mode 100644 index 0000000000..9aaba13c48 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigLocationProvider } from './ConfigLocationProvider'; +import { EntityProviderConnection } from './types'; +import { ConfigReader } from '@backstage/config'; +import { resolvePackagePath } from '@backstage/backend-common'; +import path from 'path'; + +describe('Config Location Provider', () => { + it('should apply mutation with the correct paths in the config', async () => { + const mockConfig = new ConfigReader({ + catalog: { + locations: [ + { type: 'file', target: './lols.yaml' }, + { type: 'url', target: 'https://github.com/backstage/backstage' }, + ], + }, + }); + + const mockConnection = ({ + applyMutation: jest.fn(), + } as unknown) as EntityProviderConnection; + const locationProvider = new ConfigLocationProvider(mockConfig); + + await locationProvider.connect(mockConnection); + + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: path.join( + resolvePackagePath('@backstage/plugin-catalog-backend'), + './lols.yaml', + ), + type: 'file', + }, + }), + ]), + }); + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }), + ]), + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts index 5b93fcfd98..8a65487df1 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts @@ -34,12 +34,14 @@ export class ConfigLocationProvider implements EntityProvider { const locationConfigs = this.config.getOptionalConfigArray('catalog.locations') ?? []; - const entities = locationConfigs.map(location => - locationSpecToLocationEntity({ - type: location.getString('type'), - target: path.resolve(location.getString('target')), - }), - ); + const entities = locationConfigs.map(location => { + const type = location.getString('type'); + const target = location.getString('target'); + return locationSpecToLocationEntity({ + type, + target: type === 'file' ? path.resolve(target) : target, + }); + }); await this.connection.applyMutation({ type: 'full', From 6b580d979b4d5aed9f9b167436c8b3a448af58b0 Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Tue, 27 Apr 2021 09:16:41 -0600 Subject: [PATCH 31/88] addres feedback Signed-off-by: jrusso1020 --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 2506e2e9c1..0dba11fd62 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -44,7 +44,7 @@ metadata: name: artist-web description: The place to be, for great artists labels: - backstage.io/custom: ValueStuff + example.com/custom: custom_label_value annotations: example.com/service-discovery: artistweb circleci.com/project-slug: github/example-org/artist-website From 826bcc46b6afebaa915d5d0da0df2803d031e2a3 Mon Sep 17 00:00:00 2001 From: Victor Perera Date: Tue, 27 Apr 2021 10:36:14 -0500 Subject: [PATCH 32/88] Issues/sugestions fixed Signed-off-by: Victor Perera victorcito001@hotmail.com Signed-off-by: Victor Perera --- .../FavouriteTemplate/FavouriteTemplate.tsx | 15 ++++-- .../ScaffolderPage/ScaffolderPage.tsx | 18 +------ .../components/TemplateCard/TemplateCard.tsx | 50 ++++++++++++------- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx index 99f6c21a1d..ae6057beff 100644 --- a/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx +++ b/plugins/scaffolder/src/components/FavouriteTemplate/FavouriteTemplate.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ComponentProps } from 'react'; +import React, { ComponentProps, useMemo } from 'react'; import { useStarredEntities } from '@backstage/plugin-catalog-react'; import { IconButton, makeStyles, Tooltip, withStyles } from '@material-ui/core'; import StarBorder from '@material-ui/icons/StarBorder'; @@ -29,6 +29,12 @@ const YellowStar = withStyles({ }, })(Star); +const WhiteBorderStar = withStyles({ + root: { + color: '#ffffff', + }, +})(StarBorder); + const useStyles = makeStyles(theme => ({ starButton: { position: 'absolute', @@ -42,7 +48,7 @@ export const favouriteTemplateTooltip = (isStarred: boolean) => isStarred ? 'Remove from favorites' : 'Add to favorites'; export const favouriteTemplateIcon = (isStarred: boolean) => - isStarred ? : ; + isStarred ? : ; /** * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities @@ -51,7 +57,10 @@ export const favouriteTemplateIcon = (isStarred: boolean) => export const FavouriteTemplate = (props: Props) => { const classes = useStyles(); const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const isStarred = isStarredEntity(props.entity); + const isStarred = useMemo(() => isStarredEntity(props.entity), [ + isStarredEntity, + props.entity, + ]); return ( ({ @@ -51,20 +51,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const getTemplateCardProps = ( - template: TemplateEntityV1alpha1, -): TemplateCardProps & { key: string } => { - return { - key: template.metadata.uid!, - name: template.metadata.name, - title: `${(template.metadata.title || template.metadata.name) ?? ''}`, - type: template.spec.type ?? '', - description: template.metadata.description ?? '-', - tags: (template.metadata?.tags as string[]) ?? [], - entityTemplate: template, - }; -}; - export const ScaffolderPageContents = () => { const styles = useStyles(); const { @@ -189,7 +175,7 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map(template => ( - + ))} diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 2e9cfe6470..8c64d981ad 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -28,7 +28,7 @@ import { import React from 'react'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; -import { Entity } from '@backstage/catalog-model'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; const useStyles = makeStyles({ @@ -48,52 +48,66 @@ const useStyles = makeStyles({ }); export type TemplateCardProps = { + template: TemplateEntityV1alpha1; +}; + +type TemplateProps = { description: string; tags: string[]; title: string; type: string; name: string; - entityTemplate: Entity; }; -export const TemplateCard = ({ - description, - tags, - title, - type, - name, - entityTemplate, -}: TemplateCardProps) => { +const getTemplateCardProps = ( + template: TemplateEntityV1alpha1, +): TemplateProps & { key: string } => { + return { + key: template.metadata.uid!, + name: template.metadata.name, + title: `${(template.metadata.title || template.metadata.name) ?? ''}`, + type: template.spec.type ?? '', + description: template.metadata.description ?? '-', + tags: (template.metadata?.tags as string[]) ?? [], + }; +}; + +export const TemplateCard = ({ template }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); + const templateProps = getTemplateCardProps(template); - const themeId = pageTheme[type] ? type : 'other'; + const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); const href = generatePath(`${rootLink()}/templates/:templateName`, { - templateName: name, + templateName: templateProps.name, }); return ( - + - {tags?.map(tag => ( + {templateProps.tags?.map(tag => ( ))} - {description} + {templateProps.description} - From 512889f3ca11f266786b0344900ef71ee432e4b9 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:28:55 +0200 Subject: [PATCH 33/88] WIP tests for DefaultProcessingDatabase Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index f67b8603db..a5c1e56cff 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -429,4 +429,66 @@ describe('Default Processing Database', () => { ).toBeFalsy(); }); }); + + describe('updateProcessedEntity', () => { + it('should throw if the entity does not exist', async () => { + await processingDatabase.transaction(async tx => { + await expect( + processingDatabase.updateProcessedEntity(tx, { + id: '9', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [], + relations: [], + }), + ).rejects.toThrow('Processing state not found for 9'); + }); + }); + + it('should update a processed entity', async () => { + await db('refresh_state').insert({ + entity_id: '123', + entity_ref: 'Component:default/wacka', + unprocessed_entity: '', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + + const deferredEntity = { + apiVersion: '1.0.0', + metadata: { + name: 'deferred', + }, + kind: 'Location', + } as Entity; + + await processingDatabase.transaction(async tx => { + await processingDatabase.updateProcessedEntity(tx, { + id: '123', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [deferredEntity], + relations: [], + }); + }); + + console.log( + await db('refresh_state') + .where({ entity_ref: 'deferred' }) + .select(), + ); + expect(1).toBeDefined(); + }); + }); }); From 412f96edc10bccc1ccf0716f63dca1bc0dcd244b Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:30:00 +0200 Subject: [PATCH 34/88] Combine stitiching queries Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/Stitcher.ts | 118 ++++++++++++++----- 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 57f5ae9167..04a8ab8cfc 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -26,6 +26,14 @@ import { import { Entity, parseEntityRef } from '@backstage/catalog-model'; import { createHash } from 'crypto'; import stableStringify from 'fast-json-stable-stringify'; +import { buildEntitySearch } from '../database/search'; +import { DbEntitiesSearchRow } from '../database/types'; + +// The number of items that are sent per batch to the database layer, when +// doing .batchInsert calls to knex. This needs to be low enough to not cause +// errors in the underlying engine due to exceeding query limits, but large +// enough to get the speed benefits. +const BATCH_SIZE = 50; export type DbFinalEntitiesRow = { entity_id: string; @@ -49,64 +57,114 @@ export class Stitcher { for (const entityRef of entityRefs) { await this.transaction(async txOpaque => { const tx = txOpaque as Knex.Transaction; - const [result] = await tx('refresh_state') - .select('entity_id', 'processed_entity') - .where({ entity_ref: entityRef }); - if (!result) { + const result: Array<{ + entityId: string; + processedEntity?: string; + errors: string; + incomingReferenceCount: string | number; + previousEtag?: string; + relationType?: string; + relationTarget?: string; + }> = await tx + .with('incoming_references', function incomingReferences(builder) { + return builder + .from('refresh_state_references') + .where({ target_entity_ref: entityRef }) + .count({ count: '*' }); + }) + .select({ + entityId: 'refresh_state.entity_id', + processedEntity: 'refresh_state.processed_entity', + errors: 'refresh_state.errors', + incomingReferenceCount: 'incoming_references.count', + previousEtag: 'final_entities.etag', + relationType: 'relations.type', + relationTarget: 'relations.target_entity_ref', + }) + .from('refresh_state') + .leftJoin('incoming_references', {}) + .leftOuterJoin('final_entities', { + 'final_entities.entity_id': 'refresh_state.entity_id', + }) + .leftOuterJoin('relations', { + 'relations.source_entity_ref': 'refresh_state.entity_ref', + }) + .where({ 'refresh_state.entity_ref': entityRef }); + + if (!result.length) { this.logger.debug( `Unable to stitch ${entityRef}, item does not exist in refresh state table`, ); return; - } else if (!result.processed_entity) { + } + + const { + entityId, + processedEntity, + errors, + incomingReferenceCount, + previousEtag, + } = result[0]; + + if (!processedEntity) { this.logger.debug( `Unable to stitch ${entityRef}, the entity has not yet been processed`, ); return; } - const entity: Entity = JSON.parse(result.processed_entity); + const entity = JSON.parse(processedEntity) as Entity; + const isOrphan = Number(incomingReferenceCount) === 0; - const entityId = entity?.metadata?.uid; - if (!entityId) { - this.logger.error(`missing ID in entity ${JSON.stringify(entity)}`); - return; - } - - const [reference_count_result] = await tx( - 'refresh_state_references', - ) - .where({ target_entity_ref: entityRef }) - .count({ reference_count: 'target_entity_ref' }); - - if (Number(reference_count_result.reference_count) === 0) { - this.logger.debug(`${entityRef} is orphan`); + if (isOrphan) { + this.logger.debug(`${entityRef} is an orphan`); entity.metadata.annotations = { ...entity.metadata.annotations, ['backstage.io/orphan']: 'true', }; } - const relationResults = await tx('relations') - .where({ source_entity_ref: entityRef }) - .select(); - - // TODO: entityRef is lower case and should be uppercase in the final result. - entity.relations = relationResults.map(relation => ({ - type: relation.type, - target: parseEntityRef(relation.target_entity_ref), - })); + // TODO: entityRef is lower case and should be uppercase in the final result + entity.relations = result + .filter(row => row.relationType) + .map(row => ({ + type: row.relationType!, + target: parseEntityRef(row.relationTarget!), + })); entity.metadata.generation = 1; + + // If the output entity was actually not changed, just abort const etag = generateEntityEtag(entity); + if (etag === previousEtag) { + console.log(`Skipped stitching of ${entityRef}, no changes`); + return; + } + entity.metadata.etag = etag; + await tx('final_entities') .insert({ - finalized_entity: JSON.stringify(entity), entity_id: entityId, + finalized_entity: JSON.stringify(entity), etag, }) .onConflict('entity_id') .merge(['finalized_entity', 'etag']); + + try { + const entries = buildEntitySearch(entityId, entity); + await tx('search') + .where({ entity_id: entityId }) + .delete(); + await tx.batchInsert('search', entries, BATCH_SIZE); + } catch (e) { + this.logger.debug( + `Failed to write search entries for ${entityId}`, + e, + ); + // intentionally ignored + } }); } } From 20346793a20e32299e88858c636985cd4836236d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:30:22 +0200 Subject: [PATCH 35/88] Create search table Signed-off-by: Johan Haals --- .../20210302150147_refresh_state.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 2afeda02c0..755a645894 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -154,6 +154,28 @@ exports.up = async function up(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + + await knex.schema.createTable('search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('entity_id') + .inTable('refresh_state') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + table.index(['key'], 'search_key_idx'); + table.index(['value'], 'search_value_idx'); + }); }; /** @@ -177,6 +199,12 @@ exports.down = async function down(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + await knex.schema.alterTable('search', table => { + table.dropIndex([], 'search_key_idx'); + table.dropIndex([], 'search_value_idx'); + }); + + await knex.schema.dropTable('search'); await knex.schema.dropTable('final_entities'); await knex.schema.dropTable('relations'); await knex.schema.dropTable('references'); From 1a3ce09aee63fa19714f149d73b3985a960fc43c Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 27 Apr 2021 21:23:35 +0200 Subject: [PATCH 36/88] chore(tests): Added some more tests for DefaultLocationStore and fixing migration so it will run properly on PG Signed-off-by: blam --- .../20210302150147_refresh_state.js | 2 +- .../src/next/DefaultLocationStore.test.ts | 146 ++++++++++++++++++ .../src/next/DefaultLocationStore.ts | 26 ++-- 3 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 plugins/catalog-backend/src/next/DefaultLocationStore.test.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 755a645894..bbaf4820cd 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -160,7 +160,7 @@ exports.up = async function up(knex) { 'Flattened key-values from the entities, used for quick filtering', ); table - .uuid('entity_id') + .text('entity_id') .references('entity_id') .inTable('refresh_state') .onDelete('CASCADE') diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts new file mode 100644 index 0000000000..be20179e9a --- /dev/null +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DatabaseManager } from './database/DatabaseManager'; +import { DefaultLocationStore } from './DefaultLocationStore'; +import { v4 } from 'uuid'; + +describe('Default Location Store', () => { + const createLocationStore = async () => { + const db = await DatabaseManager.createTestDatabase(); + const connection = { applyMutation: jest.fn() }; + const store = new DefaultLocationStore(db); + await store.connect(connection); + return { store, connection }; + }; + + it('should do a full sync with the locations on connect', async () => { + const { connection } = await createLocationStore(); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: [], + }); + }); + + describe('listLocations', () => { + it('lists empty locations when there is no locations', async () => { + const { store } = await createLocationStore(); + + expect(await store.listLocations()).toEqual([]); + }); + + it('lists locations that are added to the db', async () => { + const { store } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + const listLocations = await store.listLocations(); + + expect(listLocations).toHaveLength(1); + expect(listLocations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }), + ]), + ); + }); + }); + + describe('createLocation', () => { + it('throws when the location already exists', async () => { + const { store } = await createLocationStore(); + const spec = { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }; + await store.createLocation(spec); + + await expect(() => store.createLocation(spec)).rejects.toThrow( + new RegExp(`Location ${spec.type}:${spec.target} already exists`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [], + added: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); + + describe('deleteLocation', () => { + it('throws if the location does not exist', async () => { + const { store } = await createLocationStore(); + + const id = v4(); + + await expect(() => store.deleteLocation(id)).rejects.toThrow( + new RegExp(`Found no location with ID ${id}`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + const location = await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + await store.deleteLocation(location.id); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: [], + removed: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index a56e5d1585..28db3790b6 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -34,10 +34,8 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return 'DefaultLocationStore'; } - createLocation(spec: LocationSpec): Promise { + async createLocation(spec: LocationSpec): Promise { return this.db.transaction(async tx => { - // TODO: id should really be type and target combined and not a uuid. - // Attempt to find a previous location matching the spec const previousLocations = await this.listLocations(); const previousLocation = previousLocations.some( @@ -50,6 +48,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { ); } + // TODO: id should really be type and target combined and not a uuid. const location = await this.db.addLocation(tx, { id: uuidv4(), type: spec.type, @@ -68,11 +67,17 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async listLocations(): Promise { const dbLocations = await this.db.locations(); - return dbLocations.map(item => ({ - id: item.id, - target: item.target, - type: item.type, - })); + return ( + dbLocations + // TODO(blam): We should create a mutation to remove this location for everyone + // eventually when it's all done and dusted + .filter(({ type }) => type !== 'bootstrap') + .map(item => ({ + id: item.id, + target: item.target, + type: item.type, + })) + ); } getLocation(id: string): Promise { @@ -86,9 +91,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return this.db.transaction(async tx => { const location = await this.db.location(id); - if (!location) { - throw new ConflictError(`No location found with id: ${id}`); - } await this.db.removeLocation(tx, id); await this.connection.applyMutation({ type: 'delta', @@ -108,7 +110,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async connect(connection: EntityProviderConnection): Promise { this._connection = connection; - const locations = await this.db.locations(); + const locations = await this.listLocations(); const entities = locations.map(location => { return locationSpecToLocationEntity(location); }); From 0aff8985d1fd437cd26ded862912e3cdfce61f56 Mon Sep 17 00:00:00 2001 From: azhurbilo Date: Thu, 22 Apr 2021 21:31:42 +0300 Subject: [PATCH 37/88] ability to disable postgres CA mount Signed-off-by: azhurbilo --- contrib/chart/backstage/README.md | 10 ++++++++++ contrib/chart/backstage/templates/_helpers.tpl | 2 +- .../backstage/templates/backend-deployment.yaml | 4 ++-- .../templates/lighthouse-deployment.yaml | 4 ++++ .../postgresql-password-backend-secret.yaml | 15 --------------- contrib/chart/backstage/values.yaml | 2 ++ 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 7d04a2cf1e..40bb2666bc 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -129,6 +129,16 @@ For the CA, create a `configMap` named `--postgres-ca` kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" ``` +or disable CA mount + +```yaml +backend: + postgresCertMountEnabled: false + +lighthouse: + postgresCertMountEnabled: false +``` + > Where the release name contains the chart name "backstage" then only the release name will be used. Now install the helm chart: diff --git a/contrib/chart/backstage/templates/_helpers.tpl b/contrib/chart/backstage/templates/_helpers.tpl index 5d49ba6975..123d4dba5b 100644 --- a/contrib/chart/backstage/templates/_helpers.tpl +++ b/contrib/chart/backstage/templates/_helpers.tpl @@ -214,7 +214,7 @@ Postgres port for the backend {{- .Values.postgresql.service.port }} {{- else if .Values.appConfig.backend.database.connection.port -}} {{- .Values.appConfig.backend.database.connection.port }} -{{ else }} +{{- else -}} 5432 {{- end -}} {{- end -}} diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 7009a22de0..4059ee3cc9 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -49,7 +49,7 @@ spec: name: {{ include "backend.postgresql.passwordSecret" .}} key: postgresql-password volumeMounts: - {{- if .Values.postgresql.enabled }} + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca mountPath: {{ include "backstage.backend.postgresCaDir" . }} {{- end }} @@ -58,7 +58,7 @@ spec: subPath: {{ include "backstage.appConfigFilename" . }} volumes: - {{- if .Values.postgresql.enabled }} + {{- if .Values.backend.postgresCertMountEnabled }} - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml index 6104d4e73c..849c48d7a6 100644 --- a/contrib/chart/backstage/templates/lighthouse-deployment.yaml +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -51,14 +51,18 @@ spec: name: {{ include "lighthouse.postgresql.passwordSecret" . }} key: postgresql-password + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumeMounts: - name: postgres-ca mountPath: {{ include "backstage.lighthouse.postgresCaDir" . }} + {{- end }} + {{- if .Values.lighthouse.postgresCertMountEnabled }} volumes: - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + {{- end }} {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} diff --git a/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml index e53369adb9..a71cef7b21 100644 --- a/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml +++ b/contrib/chart/backstage/templates/postgresql-password-backend-secret.yaml @@ -13,18 +13,3 @@ metadata: data: postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} {{- end }} -{{- if not .Values.postgresql.enabled }} ---- -apiVersion: v1 -kind: Secret -type: Opaque -metadata: - name: {{ include "lighthouse.postgresql.passwordSecret" . }} - labels: - release: {{ .Release.Name }} - annotations: - "helm.sh/hook": "pre-install,pre-upgrade" - "helm.sh/hook-delete-policy": "before-hook-creation" -data: - postgresql-password: {{ .Values.lighthouse.database.connection.password | b64enc }} -{{- end }} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index e21148b04b..645de91313 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -28,6 +28,7 @@ backend: pullPolicy: IfNotPresent containerPort: 7000 serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 512Mi @@ -43,6 +44,7 @@ lighthouse: pullPolicy: IfNotPresent containerPort: 3003 serviceType: ClusterIP + postgresCertMountEnabled: true resources: requests: memory: 128Mi From 0265764749ed726e6a040593489ecb4726e0911b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 04:19:31 +0000 Subject: [PATCH 38/88] chore(deps): bump eslint-plugin-jest from 24.1.5 to 24.3.6 Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 24.1.5 to 24.3.6. - [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases) - [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v24.1.5...v24.3.6) Signed-off-by: dependabot[bot] --- yarn.lock | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index b21e2a9bf5..230ff83c19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12518,9 +12518,9 @@ eslint-plugin-import@^2.20.2: tsconfig-paths "^3.9.0" eslint-plugin-jest@^24.1.0: - version "24.1.5" - resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.5.tgz#1e866a9f0deac587d0a3d5d7cefe99815a580de2" - integrity sha512-FIP3lwC8EzEG+rOs1y96cOJmMVpdFNreoDJv29B5vIupVssRi8zrSY3QadogT0K3h1Y8TMxJ6ZSAzYUmFCp2hg== + version "24.3.6" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.6.tgz#5f0ca019183c3188c5ad3af8e80b41de6c8e9173" + integrity sha512-WOVH4TIaBLIeCX576rLcOgjNXqP+jNlCiEmRgFTfQtJ52DpwnIQKAVGlGPAN7CZ33bW6eNfHD6s8ZbEUTQubJg== dependencies: "@typescript-eslint/experimental-utils" "^4.0.1" @@ -23538,7 +23538,7 @@ semver@7.0.0: resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@~7.3.0: version "7.3.5" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -23555,13 +23555,6 @@ semver@~5.3.0: resolved "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= -semver@~7.3.0: - version "7.3.5" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== - dependencies: - lru-cache "^6.0.0" - send@0.17.1: version "0.17.1" resolved "https://registry.npmjs.org/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" From d49c94f2f7c5485da0aedff54ac2b4c5616a5795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 04:22:54 +0000 Subject: [PATCH 39/88] chore(deps): bump core-js from 3.8.3 to 3.11.0 Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.8.3 to 3.11.0. - [Release notes](https://github.com/zloirock/core-js/releases) - [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md) - [Commits](https://github.com/zloirock/core-js/commits/v3.11.0/packages/core-js) Signed-off-by: dependabot[bot] --- yarn.lock | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index b21e2a9bf5..19aff83248 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10451,9 +10451,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.8.3" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" - integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== + version "3.11.0" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.11.0.tgz#05dac6aa70c0a4ad842261f8957b961d36eb8926" + integrity sha512-bd79DPpx+1Ilh9+30aT5O1sgpQd4Ttg8oqkqi51ZzhedMM1omD2e6IOF48Z/DzDCZ2svp49tN/3vneTK6ZBkXw== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" @@ -23538,7 +23538,7 @@ semver@7.0.0: resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@~7.3.0: version "7.3.5" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -23555,13 +23555,6 @@ semver@~5.3.0: resolved "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= -semver@~7.3.0: - version "7.3.5" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== - dependencies: - lru-cache "^6.0.0" - send@0.17.1: version "0.17.1" resolved "https://registry.npmjs.org/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" From c04403f6195cfebd1e0b99e2525965d149a2a68c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 04:26:55 +0000 Subject: [PATCH 40/88] chore(deps): bump json-schema from 0.2.5 to 0.3.0 Bumps [json-schema](https://github.com/kriszyp/json-schema) from 0.2.5 to 0.3.0. - [Release notes](https://github.com/kriszyp/json-schema/releases) - [Commits](https://github.com/kriszyp/json-schema/compare/v0.2.5...v0.3.0) Signed-off-by: dependabot[bot] --- packages/catalog-model/package.json | 2 +- packages/cli/package.json | 2 +- packages/config-loader/package.json | 2 +- plugins/scaffolder/package.json | 2 +- yarn.lock | 17 +++++------------ 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 64550b35d1..941877aec5 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -33,7 +33,7 @@ "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", "ajv": "^7.0.3", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "lodash": "^4.17.15", "uuid": "^8.0.0", "yup": "^0.29.3" diff --git a/packages/cli/package.json b/packages/cli/package.json index cd61193efe..1110efefe6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -82,7 +82,7 @@ "inquirer": "^7.0.4", "jest": "^26.0.1", "jest-css-modules": "^2.1.0", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "lodash": "^4.17.19", "mini-css-extract-plugin": "^0.9.0", "ora": "^5.3.0", diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0a0a76c2d6..55d86314af 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -35,7 +35,7 @@ "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", "fs-extra": "^9.0.0", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "json-schema-merge-allof": "^0.7.0", "typescript-json-schema": "^0.49.0", "yaml": "^1.9.2", diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 2b4feecb7b..8af5339a0f 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -44,7 +44,7 @@ "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", - "json-schema": "^0.2.5", + "json-schema": "^0.3.0", "git-url-parse": "^11.4.4", "humanize-duration": "^3.25.1", "immer": "^9.0.1", diff --git a/yarn.lock b/yarn.lock index b21e2a9bf5..6076e21713 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16965,10 +16965,10 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.2.5: - version "0.2.5" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz#97997f50972dd0500214e208c407efa4b5d7063b" - integrity sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ== +json-schema@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.3.0.tgz#90a9c5054bd065422c00241851ce8d59475b701b" + integrity sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" @@ -23538,7 +23538,7 @@ semver@7.0.0: resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@~7.3.0: version "7.3.5" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -23555,13 +23555,6 @@ semver@~5.3.0: resolved "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= -semver@~7.3.0: - version "7.3.5" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== - dependencies: - lru-cache "^6.0.0" - send@0.17.1: version "0.17.1" resolved "https://registry.npmjs.org/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" From da546ce005ab90240a0fec4c2969826e7cfb1d38 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 28 Apr 2021 09:12:15 +0200 Subject: [PATCH 41/88] Support `gridItem` variant for `EntityLinksCard` This makes it similar to other cards. Signed-off-by: Oliver Sand --- .changeset/small-snakes-shake.md | 5 +++++ .../src/components/EntityLinksCard/EntityLinksCard.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/small-snakes-shake.md diff --git a/.changeset/small-snakes-shake.md b/.changeset/small-snakes-shake.md new file mode 100644 index 0000000000..6db9db3754 --- /dev/null +++ b/.changeset/small-snakes-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Support `gridItem` variant for `EntityLinksCard`. diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx index f7e558e285..59de0a4587 100644 --- a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx @@ -27,9 +27,10 @@ type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; cols?: ColumnBreakpoints | number; + variant?: 'gridItem'; }; -export const EntityLinksCard = ({ cols = undefined }: Props) => { +export const EntityLinksCard = ({ cols = undefined, variant }: Props) => { const { entity } = useEntity(); const app = useApp(); @@ -39,7 +40,7 @@ export const EntityLinksCard = ({ cols = undefined }: Props) => { const links = entity?.metadata?.links; return ( - + {!links || links.length === 0 ? ( ) : ( From f7167e9aacd2ec721c1998896f12694c1f7b8917 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 28 Apr 2021 09:18:35 +0200 Subject: [PATCH 42/88] Update processed entity tests Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index a5c1e56cff..d76bd74260 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -19,10 +19,11 @@ import { Knex } from 'knex'; import { DbRefreshStateReferencesRow, DbRefreshStateRow, + DbRelationsRow, DefaultProcessingDatabase, } from './DefaultProcessingDatabase'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, EntityRelationSpec } from '@backstage/catalog-model'; import * as uuid from 'uuid'; import { getVoidLogger } from '@backstage/backend-common'; @@ -452,8 +453,8 @@ describe('Default Processing Database', () => { it('should update a processed entity', async () => { await db('refresh_state').insert({ - entity_id: '123', - entity_ref: 'Component:default/wacka', + entity_id: '321', + entity_ref: 'location:default/new-root', unprocessed_entity: '', errors: '', next_update_at: 'now()', @@ -468,9 +469,23 @@ describe('Default Processing Database', () => { kind: 'Location', } as Entity; + const relation: EntityRelationSpec = { + source: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + target: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + type: 'url', + }; + await processingDatabase.transaction(async tx => { await processingDatabase.updateProcessedEntity(tx, { - id: '123', + id: '321', processedEntity: { apiVersion: '1.0.0', metadata: { @@ -479,16 +494,24 @@ describe('Default Processing Database', () => { kind: 'Location', } as Entity, deferredEntities: [deferredEntity], - relations: [], + relations: [relation], }); }); - console.log( - await db('refresh_state') - .where({ entity_ref: 'deferred' }) - .select(), - ); - expect(1).toBeDefined(); + const deferredResult = await db('refresh_state') + .where({ entity_ref: 'location:default/deferred' }) + .select(); + expect(deferredResult.length).toBe(1); + + const [relations] = await db('relations') + .where({ originating_entity_id: '321' }) + .select(); + expect(relations).toEqual({ + originating_entity_id: '321', + source_entity_ref: 'component:default/foo', + type: 'url', + target_entity_ref: 'component:default/foo', + }); }); }); }); From 17b8da50a959a0c452a457f4207328d8ae1ca98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 09:18:36 +0200 Subject: [PATCH 43/88] update the stitcher including search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../20210302150147_refresh_state.js | 7 +- .../catalog-backend/src/next/Stitcher.test.ts | 205 ++++++++++++++++++ plugins/catalog-backend/src/next/Stitcher.ts | 59 ++--- .../catalog-backend/src/next/search.test.ts | 160 ++++++++++++++ plugins/catalog-backend/src/next/search.ts | 191 ++++++++++++++++ plugins/catalog-backend/src/next/types.ts | 1 + 6 files changed, 592 insertions(+), 31 deletions(-) create mode 100644 plugins/catalog-backend/src/next/Stitcher.test.ts create mode 100644 plugins/catalog-backend/src/next/search.test.ts create mode 100644 plugins/catalog-backend/src/next/search.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index bbaf4820cd..4d2c30f194 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -33,7 +33,6 @@ exports.up = async function up(knex) { ); table .text('entity_ref') - .unique() .notNullable() .comment('A reference to the entity that the refresh state is tied to'); table @@ -59,13 +58,14 @@ exports.up = async function up(knex) { .notNullable() .comment('JSON array containing all errors related to entity'); table - .dateTime('next_update_at') // TOOD: timezone or change to epoch-millis or similar + .dateTime('next_update_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('Timestamp of when entity should be updated'); table - .dateTime('last_discovery_at') // TOOD: timezone or change to epoch-millis or similar + .dateTime('last_discovery_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('The last timestamp of which this entity was discovered'); + table.unique(['entity_ref'], 'refresh_state_entity_ref_uniq'); table.index('entity_id', 'refresh_state_entity_id_idx'); table.index('entity_ref', 'refresh_state_entity_ref_idx'); table.index('next_update_at', 'refresh_state_next_update_at_idx'); @@ -188,6 +188,7 @@ exports.down = async function down(knex) { table.dropIndex([], 'refresh_state_references_target_entity_id_idx'); }); await knex.schema.alterTable('refresh_state', table => { + table.dropUnique([], 'refresh_state_entity_ref_uniq'); table.dropIndex([], 'refresh_state_entity_id_idx'); table.dropIndex([], 'refresh_state_entity_ref_idx'); table.dropIndex([], 'refresh_state_next_update_at_idx'); diff --git a/plugins/catalog-backend/src/next/Stitcher.test.ts b/plugins/catalog-backend/src/next/Stitcher.test.ts new file mode 100644 index 0000000000..327d922c07 --- /dev/null +++ b/plugins/catalog-backend/src/next/Stitcher.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DatabaseManager } from './database/DatabaseManager'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DbRelationsRow, +} from './database/DefaultProcessingDatabase'; +import { DbSearchRow } from './search'; +import { DbFinalEntitiesRow, Stitcher } from './Stitcher'; + +describe('Stitcher', () => { + let db: Knex; + const logger = getVoidLogger(); + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + }); + + it('runs the happy path', async () => { + const stitcher = new Stitcher(db, logger); + + await db.transaction(async tx => { + await tx('refresh_state').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + unprocessed_entity: JSON.stringify({}), + processed_entity: JSON.stringify({ + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + spec: { + k: 'v', + }, + }), + errors: '[]', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }, + ]); + await tx('refresh_state_references').insert([ + { source_key: 'a', target_entity_ref: 'k:ns/n' }, + ]); + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/other', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + let firstEtag: string; + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entity).toEqual({ + relations: [ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + ], + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + firstEtag = entity.metadata.etag; + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + + // Re-stitch without any changes + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entities[0].etag).toEqual(firstEtag); + expect(entity.metadata.etag).toEqual(firstEtag); + }); + + // Now add one more relation and re-stitch + await db.transaction(async tx => { + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/third', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entity).toEqual({ + relations: expect.arrayContaining([ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'third', + }, + }, + ]), + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + expect(entities[0].etag).not.toEqual(firstEtag); + expect(entities[0].etag).toEqual(entity.metadata.etag); + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/third' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 04a8ab8cfc..cd9220e934 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -14,20 +14,14 @@ * limitations under the License. */ +import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { ConflictError } from '@backstage/errors'; +import { createHash } from 'crypto'; +import stableStringify from 'fast-json-stable-stringify'; import { Knex } from 'knex'; import { Logger } from 'winston'; import { Transaction } from '../database'; -import { ConflictError } from '@backstage/errors'; -import { - DbRefreshStateReferencesRow, - DbRefreshStateRow, - DbRelationsRow, -} from './database/DefaultProcessingDatabase'; -import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { createHash } from 'crypto'; -import stableStringify from 'fast-json-stable-stringify'; -import { buildEntitySearch } from '../database/search'; -import { DbEntitiesSearchRow } from '../database/types'; +import { buildEntitySearch, DbSearchRow } from './search'; // The number of items that are sent per batch to the database layer, when // doing .batchInsert calls to knex. This needs to be low enough to not cause @@ -58,6 +52,13 @@ export class Stitcher { await this.transaction(async txOpaque => { const tx = txOpaque as Knex.Transaction; + // Selecting from refresh_state and final_entities should yield exactly + // one row (except in abnormal cases where the stitch was invoked for + // something that didn't exist at all, in which case it's zero rows). + // The join with the temporary incoming_references still gives one row. + // The only result set "expanding" join is the one with relations, so + // the output should be at least one row (if zero or one relations were + // found), or at most the same number of rows as relations. const result: Array<{ entityId: string; processedEntity?: string; @@ -90,8 +91,14 @@ export class Stitcher { .leftOuterJoin('relations', { 'relations.source_entity_ref': 'refresh_state.entity_ref', }) - .where({ 'refresh_state.entity_ref': entityRef }); + .where({ 'refresh_state.entity_ref': entityRef }) + .orderBy('relationType', 'asc') + .orderBy('relationTarget', 'asc'); + // If there were no rows returned, it would mean that there was no + // matching row even in the refresh_state. This can happen for example + // if we emit a relation to something that hasn't been ingested yet. + // It's safe to ignore this stitch attempt in that case. if (!result.length) { this.logger.debug( `Unable to stitch ${entityRef}, item does not exist in refresh state table`, @@ -102,11 +109,15 @@ export class Stitcher { const { entityId, processedEntity, - errors, + // errors, incomingReferenceCount, previousEtag, } = result[0]; + // If there was no processed entity in place, the target hasn't been + // through the processing steps yet. It's safe to ignore this stitch + // attempt in that case, since another stitch will be triggered when + // that processing has finished. if (!processedEntity) { this.logger.debug( `Unable to stitch ${entityRef}, the entity has not yet been processed`, @@ -114,6 +125,7 @@ export class Stitcher { return; } + // Grab the processed entity and stitch all of the relevant data into it const entity = JSON.parse(processedEntity) as Entity; const isOrphan = Number(incomingReferenceCount) === 0; @@ -132,15 +144,16 @@ export class Stitcher { type: row.relationType!, target: parseEntityRef(row.relationTarget!), })); - entity.metadata.generation = 1; // If the output entity was actually not changed, just abort const etag = generateEntityEtag(entity); if (etag === previousEtag) { - console.log(`Skipped stitching of ${entityRef}, no changes`); + this.logger.debug(`Skipped stitching of ${entityRef}, no changes`); return; } + entity.metadata.uid = entityId; + entity.metadata.generation = 1; entity.metadata.etag = etag; await tx('final_entities') @@ -152,19 +165,9 @@ export class Stitcher { .onConflict('entity_id') .merge(['finalized_entity', 'etag']); - try { - const entries = buildEntitySearch(entityId, entity); - await tx('search') - .where({ entity_id: entityId }) - .delete(); - await tx.batchInsert('search', entries, BATCH_SIZE); - } catch (e) { - this.logger.debug( - `Failed to write search entries for ${entityId}`, - e, - ); - // intentionally ignored - } + const searchEntries = buildEntitySearch(entityId, entity); + await tx('search').where({ entity_id: entityId }).delete(); + await tx.batchInsert('search', searchEntries, BATCH_SIZE); }); } } diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts new file mode 100644 index 0000000000..be9a98fe69 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { buildEntitySearch, mapToRows, traverse } from './search'; + +describe('search', () => { + describe('traverse', () => { + it('expands lists of strings to several rows', () => { + const input = { a: ['b', 'c', 'd'] }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'b' }, + { key: 'a.b', value: true }, + { key: 'a', value: 'c' }, + { key: 'a.c', value: true }, + { key: 'a', value: 'd' }, + { key: 'a.d', value: true }, + ]); + }); + + it('expands objects', () => { + const input = { a: { b: { c: 'd' }, e: 'f' } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a.b.c', value: 'd' }, + { key: 'a.e', value: 'f' }, + ]); + }); + + it('expands list of objects', () => { + const input = { root: { list: [{ a: 1 }, { a: 2 }] } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'root.list.a', value: 1 }, + { key: 'root.list.a', value: 2 }, + ]); + }); + + it('skips over special keys', () => { + const input = { + state: { x: 1 }, + relations: [{ y: 2 }], + a: 'a', + metadata: { + b: 'b', + name: 'name', + namespace: 'namespace', + uid: 'uid', + etag: 'etag', + generation: 'generation', + c: 'c', + }, + d: 'd', + }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'a' }, + { key: 'metadata.b', value: 'b' }, + { key: 'metadata.c', value: 'c' }, + { key: 'd', value: 'd' }, + ]); + }); + }); + + describe('mapToRows', () => { + it('converts base types to strings or null', () => { + const input = [ + { key: 'a', value: true }, + { key: 'b', value: false }, + { key: 'c', value: 7 }, + { key: 'd', value: 'string' }, + { key: 'e', value: null }, + { key: 'f', value: undefined }, + ]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([ + { entity_id: 'eid', key: 'a', value: 'true' }, + { entity_id: 'eid', key: 'b', value: 'false' }, + { entity_id: 'eid', key: 'c', value: '7' }, + { entity_id: 'eid', key: 'd', value: 'string' }, + { entity_id: 'eid', key: 'e', value: null }, + { entity_id: 'eid', key: 'f', value: null }, + ]); + }); + + it('emits lowercase version of keys and values', () => { + const input = [{ key: 'fOo', value: 'BaR' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); + }); + + it('skips very large values', () => { + const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + }); + + describe('buildEntitySearch', () => { + it('adds special keys even if missing', () => { + const input: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + ]); + }); + + it('adds relations', () => { + const input: Entity = { + relations: [ + { type: 't1', target: { kind: 'k', namespace: 'ns', name: 'a' } }, + { type: 't2', target: { kind: 'k', namespace: 'ns', name: 'b' } }, + ], + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + { entity_id: 'eid', key: 'relations', value: 't1:k:ns/a' }, + { entity_id: 'eid', key: 'relations', value: 't2:k:ns/b' }, + ]); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts new file mode 100644 index 0000000000..4aa8bae1a8 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; + +export type DbSearchRow = { + entity_id: string; + key: string; + value: string | null; +}; + +// These are excluded in the generic loop, either because they do not make sense +// to index, or because they are special-case always inserted whether they are +// null or not +const SPECIAL_KEYS = [ + 'state', + 'relations', + 'metadata.name', + 'metadata.namespace', + 'metadata.uid', + 'metadata.etag', + 'metadata.generation', +]; + +// The maximum length allowed for search values. These columns are indexed, and +// database engines do not like to index on massive values. For example, +// postgres will balk after 8191 byte line sizes. +const MAX_VALUE_LENGTH = 200; + +type Kv = { + key: string; + value: unknown; +}; + +// Helper for traversing through a nested structure and outputting a list of +// path->value entries of the leaves. +// +// For example, this yaml structure +// +// a: 1 +// b: +// c: null +// e: [f, g] +// h: +// - i: 1 +// j: k +// - i: 2 +// j: l +// +// will result in +// +// "a", 1 +// "b.c", null +// "b.e": "f" +// "b.e.f": true +// "b.e": "g" +// "b.e.g": true +// "h.i": 1 +// "h.j": "k" +// "h.i": 2 +// "h.j": "l" +export function traverse(root: unknown): Kv[] { + const output: Kv[] = []; + + function visit(path: string, current: unknown) { + if (SPECIAL_KEYS.includes(path)) { + return; + } + + // empty or scalar + if ( + current === undefined || + current === null || + ['string', 'number', 'boolean'].includes(typeof current) + ) { + output.push({ key: path, value: current }); + return; + } + + // unknown + if (typeof current !== 'object') { + return; + } + + // array + if (Array.isArray(current)) { + for (const item of current) { + // NOTE(freben): The reason that these are output in two different ways, + // is to support use cases where you want to express that MORE than one + // tag is present in a list. Since the EntityFilters structure is a + // record, you can't have several entries of the same key. Therefore + // you will have to match on + // + // { "a.b": ["true"], "a.c": ["true"] } + // + // rather than + // + // { "a": ["b", "c"] } + // + // because the latter means EITHER b or c has to be present. + visit(path, item); + if (typeof item === 'string') { + output.push({ key: `${path}.${item}`, value: true }); + } + } + return; + } + + // object + for (const [key, value] of Object.entries(current!)) { + visit(path ? `${path}.${key}` : key, value); + } + } + + visit('', root); + + return output; +} + +// Translates a number of raw data rows to search table rows +export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] { + const result: DbSearchRow[] = []; + + for (const { key: rawKey, value: rawValue } of input) { + const key = rawKey.toLocaleLowerCase('en-US'); + if (rawValue === undefined || rawValue === null) { + result.push({ entity_id: entityId, key, value: null }); + } else { + const value = String(rawValue).toLocaleLowerCase('en-US'); + if (value.length <= MAX_VALUE_LENGTH) { + result.push({ entity_id: entityId, key, value }); + } + } + } + + return result; +} + +/** + * Generates all of the search rows that are relevant for this entity. + * + * @param entityId The uid of the entity + * @param entity The entity + * @returns A list of entity search rows + */ +export function buildEntitySearch( + entityId: string, + entity: Entity, +): DbSearchRow[] { + // Visit the base structure recursively + const raw = traverse(entity); + + // Start with some special keys that are always present because you want to + // be able to easily search for null specifically + raw.push({ key: 'metadata.name', value: entity.metadata.name }); + raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace }); + raw.push({ key: 'metadata.uid', value: entity.metadata.uid }); + + // Namespace not specified has the default value "default", so we want to + // match on that as well + if (!entity.metadata.namespace) { + raw.push({ key: 'metadata.namespace', value: ENTITY_DEFAULT_NAMESPACE }); + } + + // Visit relations + for (const relation of entity.relations ?? []) { + raw.push({ + key: 'relations', + value: `${relation.type}:${stringifyEntityRef(relation.target)}`, + }); + } + + return mapToRows(raw, entityId); +} diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index e0616fb336..cc9cc48ad6 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -120,6 +120,7 @@ export type ReplaceProcessingItemsRequest = removed: Entity[]; type: 'delta'; }; + export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; getNextProcessingItem(): Promise; From bd231a3d63310049c2467fff15670f351cec3e41 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 4 Apr 2021 19:45:23 +0200 Subject: [PATCH 44/88] catalog-backend/next: add Context Signed-off-by: Patrik Oldsberg --- .../src/next/Context/BaseContext.ts | 26 ++++++++++++ .../src/next/Context/ContextWithValue.ts | 40 +++++++++++++++++++ .../src/next/Context/TransactionContext.ts | 40 +++++++++++++++++++ .../catalog-backend/src/next/Context/index.ts | 21 ++++++++++ .../catalog-backend/src/next/Context/types.ts | 23 +++++++++++ 5 files changed, 150 insertions(+) create mode 100644 plugins/catalog-backend/src/next/Context/BaseContext.ts create mode 100644 plugins/catalog-backend/src/next/Context/ContextWithValue.ts create mode 100644 plugins/catalog-backend/src/next/Context/TransactionContext.ts create mode 100644 plugins/catalog-backend/src/next/Context/index.ts create mode 100644 plugins/catalog-backend/src/next/Context/types.ts diff --git a/plugins/catalog-backend/src/next/Context/BaseContext.ts b/plugins/catalog-backend/src/next/Context/BaseContext.ts new file mode 100644 index 0000000000..c9553cffd5 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/BaseContext.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextKey } from './types'; + +/** + * A base Context implementation that does not hold any value. + */ +export class BaseContext implements Context { + getContextValue(key: ContextKey): T { + return key.defaultValue; + } +} diff --git a/plugins/catalog-backend/src/next/Context/ContextWithValue.ts b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts new file mode 100644 index 0000000000..8e61e2c6f2 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BaseContext } from './BaseContext'; +import { Context, ContextKey } from './types'; + +/** + * A Context implementation that holds a single value, optionally extending an existing context. + */ +export class ContextWithValue implements Context { + static create(key: ContextKey, value: unknown, parent?: Context) { + return new ContextWithValue(parent ?? new BaseContext(), key, value); + } + + private constructor( + private readonly parent: Context, + private readonly key: ContextKey, + private readonly value: unknown, + ) {} + + getContextValue(key: ContextKey): T { + if (this.key === key) { + return this.value as T; + } + return this.parent.getContextValue(key); + } +} diff --git a/plugins/catalog-backend/src/next/Context/TransactionContext.ts b/plugins/catalog-backend/src/next/Context/TransactionContext.ts new file mode 100644 index 0000000000..6ac50fbc07 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/TransactionContext.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextKey } from './types'; +import { Knex } from 'knex'; +import { ContextWithValue } from './ContextWithValue'; + +const transactionContextKey = new ContextKey( + undefined, +); + +/** + * TransactionContext handles the wrapping of a knex transaction in a Context. + */ +export class TransactionContext { + static create(tx: Knex.Transaction, parent?: Context) { + return ContextWithValue.create(transactionContextKey, tx, parent); + } + + static getTransaction(context: Context): Knex.Transaction { + const transaction = context.getContextValue(transactionContextKey); + if (!transaction) { + throw new Error(`No transaction available in context`); + } + return transaction; + } +} diff --git a/plugins/catalog-backend/src/next/Context/index.ts b/plugins/catalog-backend/src/next/Context/index.ts new file mode 100644 index 0000000000..992f5e89a6 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BaseContext } from './BaseContext'; +export { ContextWithValue } from './ContextWithValue'; +export { TransactionContext } from './TransactionContext'; +export { ContextKey } from './types'; +export type { Context } from './types'; diff --git a/plugins/catalog-backend/src/next/Context/types.ts b/plugins/catalog-backend/src/next/Context/types.ts new file mode 100644 index 0000000000..0973b83515 --- /dev/null +++ b/plugins/catalog-backend/src/next/Context/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ContextKey { + constructor(readonly defaultValue: T) {} +} + +export interface Context { + getContextValue(key: ContextKey): T; +} From 065e532aade1047d3d06a0d6a308091e63e6eb06 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 28 Apr 2021 10:30:03 +0200 Subject: [PATCH 45/88] catalog-backend: tweak Context API Signed-off-by: Patrik Oldsberg --- .../Context/{BaseContext.ts => BackgroundContext.ts} | 2 +- .../src/next/Context/ContextWithValue.ts | 5 ++--- .../{TransactionContext.ts => TransactionValue.ts} | 10 +++++----- plugins/catalog-backend/src/next/Context/index.ts | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) rename plugins/catalog-backend/src/next/Context/{BaseContext.ts => BackgroundContext.ts} (93%) rename plugins/catalog-backend/src/next/Context/{TransactionContext.ts => TransactionValue.ts} (77%) diff --git a/plugins/catalog-backend/src/next/Context/BaseContext.ts b/plugins/catalog-backend/src/next/Context/BackgroundContext.ts similarity index 93% rename from plugins/catalog-backend/src/next/Context/BaseContext.ts rename to plugins/catalog-backend/src/next/Context/BackgroundContext.ts index c9553cffd5..72b9a3b1ed 100644 --- a/plugins/catalog-backend/src/next/Context/BaseContext.ts +++ b/plugins/catalog-backend/src/next/Context/BackgroundContext.ts @@ -19,7 +19,7 @@ import { Context, ContextKey } from './types'; /** * A base Context implementation that does not hold any value. */ -export class BaseContext implements Context { +export class BackgroundContext implements Context { getContextValue(key: ContextKey): T { return key.defaultValue; } diff --git a/plugins/catalog-backend/src/next/Context/ContextWithValue.ts b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts index 8e61e2c6f2..e8f94fd922 100644 --- a/plugins/catalog-backend/src/next/Context/ContextWithValue.ts +++ b/plugins/catalog-backend/src/next/Context/ContextWithValue.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import { BaseContext } from './BaseContext'; import { Context, ContextKey } from './types'; /** * A Context implementation that holds a single value, optionally extending an existing context. */ export class ContextWithValue implements Context { - static create(key: ContextKey, value: unknown, parent?: Context) { - return new ContextWithValue(parent ?? new BaseContext(), key, value); + static create(parent: Context, key: ContextKey, value: unknown) { + return new ContextWithValue(parent, key, value); } private constructor( diff --git a/plugins/catalog-backend/src/next/Context/TransactionContext.ts b/plugins/catalog-backend/src/next/Context/TransactionValue.ts similarity index 77% rename from plugins/catalog-backend/src/next/Context/TransactionContext.ts rename to plugins/catalog-backend/src/next/Context/TransactionValue.ts index 6ac50fbc07..6959d13a18 100644 --- a/plugins/catalog-backend/src/next/Context/TransactionContext.ts +++ b/plugins/catalog-backend/src/next/Context/TransactionValue.ts @@ -23,14 +23,14 @@ const transactionContextKey = new ContextKey( ); /** - * TransactionContext handles the wrapping of a knex transaction in a Context. + * TransactionValue handles the wrapping of a knex transaction in a Context. */ -export class TransactionContext { - static create(tx: Knex.Transaction, parent?: Context) { - return ContextWithValue.create(transactionContextKey, tx, parent); +export class TransactionValue { + static in(parent: Context, tx: Knex.Transaction) { + return ContextWithValue.create(parent, transactionContextKey, tx); } - static getTransaction(context: Context): Knex.Transaction { + static from(context: Context): Knex.Transaction { const transaction = context.getContextValue(transactionContextKey); if (!transaction) { throw new Error(`No transaction available in context`); diff --git a/plugins/catalog-backend/src/next/Context/index.ts b/plugins/catalog-backend/src/next/Context/index.ts index 992f5e89a6..61dd4a2958 100644 --- a/plugins/catalog-backend/src/next/Context/index.ts +++ b/plugins/catalog-backend/src/next/Context/index.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -export { BaseContext } from './BaseContext'; +export { BackgroundContext } from './BackgroundContext'; export { ContextWithValue } from './ContextWithValue'; -export { TransactionContext } from './TransactionContext'; +export { TransactionValue } from './TransactionValue'; export { ContextKey } from './types'; export type { Context } from './types'; From 213cd261b6ccc691bc457907106ffba32b95a695 Mon Sep 17 00:00:00 2001 From: Martina Iglesias Fernandez Date: Fri, 23 Apr 2021 18:51:52 +0200 Subject: [PATCH 46/88] [chart] Update docker image Signed-off-by: Martina Iglesias Fernandez --- contrib/chart/backstage/templates/backend-deployment.yaml | 7 +++++++ contrib/chart/backstage/templates/frontend-deployment.yaml | 2 +- contrib/chart/backstage/values.yaml | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 144fc93e62..3096fc37ab 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -26,6 +26,13 @@ spec: {{- end}} containers: - name: {{ .Chart.Name }}-backend + command: ["node"] + args: + - "packages/backend" + - "--config" + - "app-config.yaml" + - "--config" + - {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) | quote }} image: {{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }} imagePullPolicy: {{ .Values.backend.image.pullPolicy }} ports: diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml index 9c674cc160..56f720e834 100644 --- a/contrib/chart/backstage/templates/frontend-deployment.yaml +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -1,3 +1,4 @@ +{{- if .Values.frontend.enabled }} apiVersion: apps/v1 kind: Deployment metadata: @@ -46,7 +47,6 @@ spec: {{- if .Values.global.nodeSelector }} nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} {{- end }} -{{- if .Values.frontend.enabled }} --- apiVersion: v1 kind: Service diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index bd80bc22b6..78eb7cb92a 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -3,7 +3,7 @@ # Declare variables to be passed into your templates. frontend: - enabled: true + enabled: false replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-frontend @@ -23,7 +23,7 @@ backend: replicaCount: 1 image: repository: martinaif/backstage-k8s-demo-backend - tag: test1 + tag: 20210423T1550 pullPolicy: IfNotPresent containerPort: 7000 resources: From 7e1617ce0e79dbff7f905389a337d5f6f6d2e6d2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Apr 2021 19:58:02 +0200 Subject: [PATCH 47/88] catalog-backend: WIP EntityProvider mutation interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../next/DefaultCatalogProcessingEngine.ts | 15 ++-- .../src/next/DefaultLocationStore.ts | 68 ++++++++++--------- .../src/next/NextCatalogBuilder.ts | 3 +- plugins/catalog-backend/src/next/types.ts | 21 +++--- 4 files changed, 55 insertions(+), 52 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 934ab3a1cc..b423b61041 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -19,6 +19,8 @@ import { CatalogProcessingEngine, EntityProvider, EntityMessage, + EntityProviderConnection, + EntityProviderMutation, ProcessingStateManager, CatalogProcessingOrchestrator, } from './types'; @@ -27,8 +29,13 @@ import { Logger } from 'winston'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { Stitcher } from './Stitcher'; +class Connection implements EntityProviderConnection { + constructor(private readonly stateManager: ProcessingStateManager) {} + + async applyMutation(mutation: EntityProviderMutation): Promise {} +} + export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { - private subscriptions: Subscription[] = []; private running: boolean = false; constructor( @@ -41,11 +48,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - const id = 'databaseProvider'; - const subscription = provider - .entityChange$() - .subscribe({ next: m => this.onNext(id, m) }); - this.subscriptions.push(subscription); + provider.connect(new Connection(this.stateManager)); } this.running = true; diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index b21fe9da02..a6b4adcca4 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -16,24 +16,29 @@ import { LocationSpec, Location } from '@backstage/catalog-model'; import { Database } from '../database'; -import { LocationStore } from './types'; +import { + LocationStore, + EntityProvider, + EntityProviderConnection, +} from './types'; import { v4 as uuidv4 } from 'uuid'; +import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; -import { Observable } from '@backstage/core'; -import ObservableImpl from 'zen-observable'; export type LocationMessage = | { all: Location[] } | { added: Location[]; removed: Location[] }; -export class DefaultLocationStore implements LocationStore { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); +export class DefaultLocationStore implements LocationStore, EntityProvider { + private _connection: EntityProviderConnection | undefined; constructor(private readonly db: Database) {} createLocation(spec: LocationSpec): Promise { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. @@ -55,7 +60,11 @@ export class DefaultLocationStore implements LocationStore { target: spec.target, }); - this.notifyAddition(location); + await this.connection.applyMutation({ + type: 'delta', + added: [locationSpecToLocationEntity(location)], + removed: [], + }); return location; }); @@ -75,40 +84,33 @@ export class DefaultLocationStore implements LocationStore { } deleteLocation(id: string): Promise { + if (!this.connection) { + throw new Error('location store is not initialized'); + } + return this.db.transaction(async tx => { const location = await this.db.location(id); if (!location) { throw new ConflictError(`No location found with id: ${id}`); } await this.db.removeLocation(tx, id); - this.notifyDeletion(location); - }); - } - - private notifyAddition(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ - added: [location], - removed: [], - }); - } - } - - private notifyDeletion(location: Location) { - for (const subscriber of this.subscribers) { - subscriber.next({ + await this.connection.applyMutation({ + type: 'delta', added: [], - removed: [location], + removed: [locationSpecToLocationEntity(location)], }); - } + }); } - location$(): Observable { - return new ObservableImpl(subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); + private get connection(): EntityProviderConnection { + if (!this._connection) { + throw new Error('location store is not initialized'); + } + + return this._connection; + } + + async connect(connection: EntityProviderConnection): Promise { + this._connection = connection; } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index c3e0a6bd9c..1796847164 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -272,11 +272,10 @@ export class NextCatalogBuilder { const entitiesCatalog = new NextEntitiesCatalog(dbClient); const locationStore = new DefaultLocationStore(db); - const dbLocationProvider = new DatabaseLocationProvider(locationStore); const stitcher = new Stitcher(dbClient, logger); const processingEngine = new DefaultCatalogProcessingEngine( logger, - [dbLocationProvider], // entityproviders + [locationStore], // entityproviders stateManager, orchestrator, stitcher, diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index 0aeb290224..c3854d631f 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, EntityName, @@ -21,7 +22,6 @@ import { EntityRelationSpec, } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; -import { Observable } from '@backstage/core'; // << nooo export interface LocationEntity { apiVersion: 'backstage.io/v1alpha1'; @@ -45,20 +45,11 @@ export interface LocationService { deleteLocation(id: string): Promise; } -export type EntityMessage = - | { all: Entity[] } - | { added: Entity[]; removed: EntityName[] }; - export interface LocationStore { - // extends EntityProvider createLocation(spec: LocationSpec): Promise; listLocations(): Promise; getLocation(id: string): Promise; deleteLocation(id: string): Promise; - - location$(): Observable< - { all: Location[] } | { added: Location[]; removed: Location[] } - >; } export interface CatalogProcessingEngine { @@ -66,8 +57,16 @@ export interface CatalogProcessingEngine { stop(): Promise; } +export type EntityProviderMutation = + | { type: 'full'; entities: Iterable } + | { type: 'delta'; added: Iterable; removed: Iterable }; + +export interface EntityProviderConnection { + applyMutation(mutation: EntityProviderMutation): Promise; +} + export interface EntityProvider { - entityChange$(): Observable; + connect(connection: EntityProviderConnection): Promise; } export type EntityProcessingRequest = { From d9e5eeb575d316f60924dbd19a0c034cc433d162 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 22 Apr 2021 19:41:40 +0200 Subject: [PATCH 48/88] chore: worked on some more of the new catalog migration with fixing deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Johan Haals Co-authored-by: Fredrik Adelöw Signed-off-by: blam --- .../20210302150147_refresh_state.js | 33 ++-- .../next/DefaultCatalogProcessingEngine.ts | 38 +++- .../src/next/DefaultLocationStore.ts | 4 - .../src/next/DefaultProcessingStateManager.ts | 13 +- .../src/next/database/DatabaseManager.ts | 114 +++++++++++ .../DefaultProcessingDatabase.test.ts | 31 +++ .../database/DefaultProcessingDatabase.ts | 180 +++++++++++++++++- .../src/next/database/types.ts | 17 ++ plugins/catalog-backend/src/next/types.ts | 21 +- 9 files changed, 405 insertions(+), 46 deletions(-) create mode 100644 plugins/catalog-backend/src/next/database/DatabaseManager.ts create mode 100644 plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 6abab6270b..14c75b2530 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -59,13 +59,14 @@ exports.up = async function up(knex) { .notNullable() .comment('JSON array containing all errors related to entity'); table - .dateTime('next_update_at') + .dateTime('next_update_at') // TOOD: timezone or change to epoch-millis or similar .notNullable() .comment('Timestamp of when entity should be updated'); table - .dateTime('last_discovery_at') + .dateTime('last_discovery_at') // TOOD: timezone or change to epoch-millis or similar .notNullable() .comment('The last timestamp of which this entity was discovered'); + table.index('entity_id', 'refresh_state_entity_id_idx'); table.index('entity_ref', 'refresh_state_entity_ref_idx'); table.index('next_update_at', 'refresh_state_next_update_at_idx'); }); @@ -94,38 +95,35 @@ exports.up = async function up(knex) { 'Holds edges between refresh state rows. Every time when an entity is processed and emits another entity, an edge will be stored to represent that fact. This is used to detect orphans and ultimately deletions.', ); table - .text('source_special_key') + .text('source_key') .nullable() .comment( 'When the reference source is not an entity, this is an opaque identifier for that source.', ); table - .text('source_entity_id') + .text('source_entity_ref') .nullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') .comment( - 'When the reference source is an entity, this is the ID of the source entity.', + 'When the reference source is an entity, this is the EntityRef of the source entity.', ); table - .text('target_entity_id') + .text('target_entity_ref') .notNullable() - .references('entity_id') + .references('entity_ref') .inTable('refresh_state') .onDelete('CASCADE') - .comment('The ID of the target entity.'); + .comment('The EntityRef of the target entity.'); + table.index('source_key', 'refresh_state_references_source_key_idx'); table.index( - 'source_special_key', - 'refresh_state_references_source_special_key_idx', + 'source_entity_ref', + 'refresh_state_references_source_entity_ref_idx', ); table.index( - 'source_entity_id', - 'refresh_state_references_source_entity_id_idx', - ); - table.index( - 'target_entity_id', - 'refresh_state_references_target_entity_id_idx', + 'target_entity_ref', + 'refresh_state_references_target_entity_ref_idx', ); }); @@ -165,6 +163,7 @@ exports.down = async function down(knex) { table.dropIndex([], 'refresh_state_references_target_entity_id_idx'); }); await knex.schema.alterTable('refresh_state', table => { + table.dropIndex([], 'refresh_state_entity_id_idx'); table.dropIndex([], 'refresh_state_entity_ref_idx'); table.dropIndex([], 'refresh_state_next_update_at_idx'); }); diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index b423b61041..7f49bf832d 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -30,9 +30,31 @@ import { stringifyEntityRef } from '@backstage/catalog-model'; import { Stitcher } from './Stitcher'; class Connection implements EntityProviderConnection { - constructor(private readonly stateManager: ProcessingStateManager) {} + constructor( + private readonly config: { + stateManager: ProcessingStateManager; + id: string; + }, + ) {} - async applyMutation(mutation: EntityProviderMutation): Promise {} + async applyMutation(mutation: EntityProviderMutation): Promise { + if (mutation.type === 'full') { + await this.config.stateManager.replaceProcessingItems({ + id: this.config.id, + type: 'full', + items: mutation.entities, + }); + + return; + } + + await this.config.stateManager.replaceProcessingItems({ + id: this.config.id, + type: 'delta', + added: mutation.added, + removed: mutation.removed, + }); + } } export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { @@ -48,7 +70,9 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - provider.connect(new Connection(this.stateManager)); + // TODO: this ID should be some form of identifier for the EntityProvider + const id = 'databaseProvider'; + provider.connect(new Connection({ stateManager: this.stateManager, id })); } this.running = true; @@ -57,12 +81,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { const { id, entity, - state: intialState, + state: initialState, } = await this.stateManager.getNextProcessingItem(); const result = await this.orchestrator.process({ entity, - state: intialState, + state: initialState, }); for (const error of result.errors) { @@ -95,10 +119,6 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async stop() { this.running = false; - - for (const subscription of this.subscriptions) { - subscription.unsubscribe(); - } } private async onNext(id: string, message: EntityMessage) { diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index a6b4adcca4..dff424b1a9 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -35,10 +35,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { constructor(private readonly db: Database) {} createLocation(spec: LocationSpec): Promise { - if (!this.connection) { - throw new Error('location store is not initialized'); - } - return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index 91090bc139..d2765c3101 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -17,14 +17,23 @@ import { ProcessingDatabase, RefreshStateItem } from './database/types'; import { AddProcessingItemRequest, - ProccessingItem, + ProcessingItem, ProcessingItemResult, ProcessingStateManager, + ReplaceProcessingItemsRequest, } from './types'; export class DefaultProcessingStateManager implements ProcessingStateManager { constructor(private readonly db: ProcessingDatabase) {} + replaceProcessingItems( + request: ReplaceProcessingItemsRequest, + ): Promise { + return this.db.transaction(async tx => { + await this.db.replaceUnprocessedEntities(tx, request); + }); + } + async setProcessingItemResult(result: ProcessingItemResult) { return this.db.transaction(async tx => { await this.db.updateProcessedEntity(tx, { @@ -44,7 +53,7 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { }); } - async getNextProcessingItem(): Promise { + async getNextProcessingItem(): Promise { const entities = await new Promise(resolve => this.popFromQueue(resolve), ); diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts new file mode 100644 index 0000000000..4c9e45950f --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; +import knexFactory, { Knex } from 'knex'; +import { v4 as uuidv4 } from 'uuid'; +import { Logger } from 'winston'; +import { CommonDatabase } from '../../database/CommonDatabase'; +import { Database } from '../../database/types'; +import fs from 'fs-extra'; + +export type CreateDatabaseOptions = { + logger: Logger; +}; + +const defaultOptions: CreateDatabaseOptions = { + logger: getVoidLogger(), +}; + +export class DatabaseManager { + public static async createDatabase( + knex: Knex, + options: Partial = {}, + ): Promise { + const allMigrations = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrations', + ); + + const migrationsDir = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrationsv2', + ); + await fs.copy(allMigrations, migrationsDir); + + await knex.migrate.latest({ + directory: migrationsDir, + }); + const { logger } = { ...defaultOptions, ...options }; + return new CommonDatabase(knex, logger); + } + + public static async createInMemoryDatabase(): Promise { + const knex = await this.createInMemoryDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createInMemoryDatabaseConnection(): Promise { + const knex = knexFactory({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } + + public static async createTestDatabase(): Promise { + const knex = await this.createTestDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createTestDatabaseConnection(): Promise { + const config: Knex.Config = { + /* + client: 'pg', + connection: { + host: 'localhost', + user: 'postgres', + password: 'postgres', + }, + */ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }; + + let knex = knexFactory(config); + if (typeof config.connection !== 'string') { + const tempDbName = `d${uuidv4().replace(/-/g, '')}`; + await knex.raw(`CREATE DATABASE ${tempDbName};`); + knex = knexFactory({ + ...config, + connection: { + ...config.connection, + database: tempDbName, + }, + }); + } + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + } +} diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts new file mode 100644 index 0000000000..fd2e9e6750 --- /dev/null +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; +import { DatabaseManager } from './DatabaseManager'; +import { Knex } from 'knex'; + +describe('Default Processing Database', () => { + let db: Knex | undefined; + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + }); + + it('should write some stuff', async () => { + await db('refresh_state_referencess').select(); + }); +}); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index cf2d4e121c..b799bd6284 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -15,6 +15,7 @@ */ import { ConflictError, NotFoundError } from '@backstage/errors'; +import { stringifyEntityRef, Entity } from '@backstage/catalog-model'; import { Knex } from 'knex'; import { Transaction } from '../../database'; import lodash from 'lodash'; @@ -24,20 +25,21 @@ import { AddUnprocessedEntitiesOptions, UpdateProcessedEntityOptions, GetProcessableEntitiesResult, + ReplaceUnprocessedEntitiesOptions, } from './types'; import type { Logger } from 'winston'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + import { v4 as uuid } from 'uuid'; export type DbRefreshStateRow = { entity_id: string; entity_ref: string; unprocessed_entity: string; - processed_entity: string; - cache: string; + processed_entity?: string; + cache?: string; next_update_at: string; last_discovery_at: string; // remove? - errors: string; + errors?: string; }; export type DbRelationsRow = { @@ -47,10 +49,10 @@ export type DbRelationsRow = { type: string; }; -export type DbRefreshStateReferences = { - source_special_key?: string; - source_entity_id?: string; - target_entity_id: string; +export type DbRefreshStateReferencesRow = { + source_key?: string; + source_entity_ref?: string; + target_entity_ref: string; }; // The number of items that are sent per batch to the database layer, when @@ -128,6 +130,164 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + async replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + const entityIds = new Array(); + + if (options.type === 'full') { + const oldRefs = await tx( + 'refresh_state_references', + ) + .where({ source_key: options.sourceKey }) + .select('target_entity_ref') + .then(rows => rows.map(r => r.target_entity_ref)); + + const items = options.items.map(entity => ({ + entity, + ref: stringifyEntityRef(entity), + id: uuid(), + })); + + const oldRefsSet = new Set(oldRefs); + const newRefsSet = new Set(items.map(item => item.ref)); + const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); + const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); + + if (toRemove.length) { + // TODO(freben): Batch split, to not hit variable limits? + + // get all refs where source = any of the toRemove + // delete all state rows matching any of the toRemove + // revisit the targets of all of those refs + // verify if they have references, otherwise delete from refresh_state + + const current = [...toRemove]; + while (true) { + + tx.withRecursive('r', function refs() { + return tx.select({ }) + .from({ r1: 'refresh_state_references' }) + .where({ source_key: options.sourceKey }) + .whereIn('target_entity_ref', toRemove) + .unionAll(function recurse() { + return tx.select({ }) + .from({ r2: 'refresh_state_references' }) + .whereNotExists() + }) + }) + .select().from('refs'); + + const nextLayerToRemove = await tx( + 'refresh_state_references', + ) + .whereIn('source_entity_ref', toRemove) + .leftOuterJoin('refresh_state_references AS b', function f() { + + }) + + .whereNotNull('b.source_target_ref') + .select('target_entity_ref'); + + await tx('refresh_state') + .whereIn('entity_ref', toRemove) + .delete(); + + const refsThatStillHaveASourcePointingAtThe = await tx( + 'refresh_state_references', + ) + .whereIn('target_entity_ref', nextLayerTargetRefs) + .groupBy('target_entity_ref') + .select('target_entity_ref'); + + } + + + + + if (toAdd.length) { + const state: Knex.DbRecord[] = toAdd.map(item => ({ + entity_id: item.id, + entity_ref: item.ref, + unprocessed_entity: JSON.stringify(item.entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + })); + const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( + item => ({ + source_key: options.sourceKey, + target_entity_ref: item.ref, + }), + ); + // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense + await tx.batchInsert('refresh_state', state, BATCH_SIZE); + await tx.batchInsert('refresh_state_references', stateReferences, BATCH_SIZE); + } + + + for (const entity of options.items) { + const entityRef = stringifyEntityRef(entity); + await tx('refresh_state') + .insert({ + entity_id: uuid(), + entity_ref: entityRef, + unprocessed_entity: JSON.stringify(entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }) + .onConflict('entity_ref') + .merge(['unprocessed_entity', 'last_discovery_at']); + + const [{ entity_id: entityId }] = await tx( + 'refresh_state', + ).where({ entity_ref: entityRef }); + entityIds.push(entityId); + } + const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( + entityId => ({ + source_key: options.sourceKey, + target_entityRef: entityRef, + }), + ); + await tx.batchInsert( + 'refresh_state_references', + referenceRows, + BATCH_SIZE, + ); + return; + } + + for (const entity of options.removed) { + const entityRef = stringifyEntityRef(entity); + const [result] = await tx('refresh_state') + .where({ entity_ref: entityRef }) + .select('entity_id'); + + if (!result) { + this.logger.info( + `Unable to delete entity '${entityRef}', entity does not exist in refresh state`, + ); + continue; + } + + const referenceResults = await tx( + 'refresh_state_references', + ) + // todo correct key? + .where({ source_entity_id: result.entity_id }) + .select(); + + await tx('refresh_state_references') + // todo correct key? + .where({ source_entity_id: result.entity_id }) + .delete(); + } + } + async addUnprocessedEntities( txOpaque: Transaction, options: AddUnprocessedEntitiesOptions, @@ -160,11 +320,11 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ? { source_special_key: options.id } : { source_entity_id: options.id }; // copied from update refs - await tx('refresh_state_references') + await tx('refresh_state_references') .where(key) .delete(); - const referenceRows: DbRefreshStateReferences[] = entityIds.map( + const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( entityId => ({ ...key, target_entity_id: entityId, diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index 22e9e90c8e..b926676887 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -50,6 +50,19 @@ export type GetProcessableEntitiesResult = { items: RefreshStateItem[]; }; +export type ReplaceUnprocessedEntitiesOptions = + | { + sourceKey: string; + items: Entity[]; + type: 'full'; + } + | { + sourceKey: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; + export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; @@ -58,6 +71,10 @@ export interface ProcessingDatabase { options: AddUnprocessedEntitiesOptions, ): Promise; + replaceUnprocessedEntities( + txOpaque: Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise; getProcessableEntities( txOpaque: Transaction, request: { processBatchSize: number }, diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index c3854d631f..f8b4de1c41 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -58,8 +58,8 @@ export interface CatalogProcessingEngine { } export type EntityProviderMutation = - | { type: 'full'; entities: Iterable } - | { type: 'delta'; added: Iterable; removed: Iterable }; + | { type: 'full'; entities: Entity[] } + | { type: 'delta'; added: Entity[]; removed: Entity[] }; export interface EntityProviderConnection { applyMutation(mutation: EntityProviderMutation): Promise; @@ -108,14 +108,27 @@ export type AddProcessingItemRequest = { entities: Entity[]; }; -export type ProccessingItem = { +export type ProcessingItem = { id: string; entity: Entity; state: Map; }; +export type ReplaceProcessingItemsRequest = + | { + id: string; + items: Entity[]; + type: 'full'; + } + | { + id: string; + added: Entity[]; + removed: Entity[]; + type: 'delta'; + }; export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; - getNextProcessingItem(): Promise; + getNextProcessingItem(): Promise; addProcessingItems(request: AddProcessingItemRequest): Promise; + replaceProcessingItems(request: ReplaceProcessingItemsRequest): Promise; } From 87ff2b455a9390ff9067fea7e01c20d4f984dcfe Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 15:05:04 +0200 Subject: [PATCH 49/88] feat: added support for full sync replace with an awesome sql statement courtesy of @freben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../20210302150147_refresh_state.js | 3 + .../DefaultProcessingDatabase.test.ts | 271 +++++++++++++++++- .../database/DefaultProcessingDatabase.ts | 220 +++++++------- 3 files changed, 390 insertions(+), 104 deletions(-) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 14c75b2530..2afeda02c0 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -94,6 +94,9 @@ exports.up = async function up(knex) { table.comment( 'Holds edges between refresh state rows. Every time when an entity is processed and emits another entity, an edge will be stored to represent that fact. This is used to detect orphans and ultimately deletions.', ); + table + .increments('id') + .comment('Primary key to distinguish unique lines from each other'); table .text('source_key') .nullable() diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index fd2e9e6750..ee04a2baf8 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -16,16 +16,283 @@ // import { DefaultProcessingDatabase } from './DefaultProcessingDatabase'; import { DatabaseManager } from './DatabaseManager'; import { Knex } from 'knex'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DefaultProcessingDatabase, +} from './DefaultProcessingDatabase'; + +import { Entity } from '@backstage/catalog-model'; +import * as uuid from 'uuid'; +import { getVoidLogger } from '@backstage/backend-common'; describe('Default Processing Database', () => { let db: Knex | undefined; + let processingDatabase: DefaultProcessingDatabase | undefined; + + const logger = getVoidLogger(); beforeEach(async () => { db = await DatabaseManager.createTestDatabaseConnection(); await DatabaseManager.createDatabase(db); + + processingDatabase = new DefaultProcessingDatabase(db!, logger); }); - it('should write some stuff', async () => { - await db('refresh_state_referencess').select(); + describe('replaceUnprocessedEntities', () => { + const insertRefRow = async (ref: DbRefreshStateReferencesRow) => { + return db!( + 'refresh_state_references', + ).insert(ref); + }; + + const insertRefreshStateRow = async (ref: DbRefreshStateRow) => { + await db!('refresh_state').insert(ref); + }; + + const createLocations = async (entityRefs: string[]) => { + for (const ref of entityRefs) { + await insertRefreshStateRow({ + entity_id: uuid.v4(), + entity_ref: ref, + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + } + }; + + it('replaces all existing state correctly for simple dependency chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + database -> location:default/second -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/second', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_key: 'database', + target_entity_ref: 'location:default/second', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/second', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + for (const ref of ['location:default/root', 'location:default/root-1']) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/root-1' && + t.source_key === 'config', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.target_entity_ref === 'location:default/new-root' && + t.source_key === 'config', + ), + ).toBeTruthy(); + }); + + it('should work for more complex chains', async () => { + /* + config -> location:default/root -> location:default/root-1 -> location:default/root-2 + config -> location:default/root -> location:default/root-1a -> location:default/root-2 + */ + await createLocations([ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-2', + 'location:default/root-1a', + ]); + + await insertRefRow({ + source_key: 'config', + target_entity_ref: 'location:default/root', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root', + target_entity_ref: 'location:default/root-1a', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'location:default/root-2', + }); + + await insertRefRow({ + source_entity_ref: 'location:default/root-1a', + target_entity_ref: 'location:default/root-2', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config', + items: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + const deletedRefs = [ + 'location:default/root', + 'location:default/root-1', + 'location:default/root-1a', + 'location:default/root-2', + ]; + + for (const ref of deletedRefs) { + expect(currentRefreshState.some(t => t.entity_ref === ref)).toBeFalsy(); + } + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'config' && + t.target_entity_ref === 'location:default/root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root' && + t.target_entity_ref === 'location:default/root-1a', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_entity_ref === 'location:default/root-1a' && + t.target_entity_ref === 'location:default/root-2', + ), + ).toBeFalsy(); + }); }); }); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index b799bd6284..f0c5443651 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -135,7 +135,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { options: ReplaceUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - const entityIds = new Array(); if (options.type === 'full') { const oldRefs = await tx( @@ -158,55 +157,95 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { if (toRemove.length) { // TODO(freben): Batch split, to not hit variable limits? - - // get all refs where source = any of the toRemove - // delete all state rows matching any of the toRemove - // revisit the targets of all of those refs - // verify if they have references, otherwise delete from refresh_state - - const current = [...toRemove]; - while (true) { - - tx.withRecursive('r', function refs() { - return tx.select({ }) - .from({ r1: 'refresh_state_references' }) - .where({ source_key: options.sourceKey }) - .whereIn('target_entity_ref', toRemove) - .unionAll(function recurse() { - return tx.select({ }) - .from({ r2: 'refresh_state_references' }) - .whereNotExists() - }) + /* + WITH RECURSIVE + -- All the refs that can be reached from each individual root + root_reach(id, entity_ref) AS ( + -- Start with all roots + SELECT id, target_entity_ref + FROM refresh_state_references + WHERE source_key IS NOT NULL + UNION + -- For each match, select all children + SELECT root_reach.id, target_entity_ref + FROM refresh_state_references, root_reach + WHERE source_entity_ref = root_reach.entity_ref + ) + -- Start out with our own matching row (see the WHERE that + -- matches on source_key and target_entity_ref below) + SELECT us.entity_ref + FROM refresh_state_references + -- Expand the entire tree that emanates from that row + JOIN root_reach AS us + ON us.id = refresh_state_references.id + -- Expand with all roots that target the same node but + -- aren't ourselves + LEFT OUTER JOIN root_reach AS them + ON them.entity_ref = us.entity_ref + AND them.id != us.id + -- Keep only the matches that had no other rooots + WHERE refresh_state_references.source_key = "R1" + AND refresh_state_references.target_entity_ref = "A" + AND them.id IS NULL; + */ + const removedCount = await tx('refresh_state') + .whereIn('entity_ref', function orphanedEntityRefs(orphans) { + return ( + orphans + // All the refs that can be reached from each individual root + .withRecursive('root_reach', function rootReach(outer) { + // Start with all roots + return outer + .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .whereNotNull('source_key') + .union(function recurse(inner) { + return ( + inner + // For each match, select all children + .select({ + id: 'root_reach.id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('refresh_state_references') + .crossJoin('root_reach', { + 'root_reach.entity_ref': + 'refresh_state_references.source_entity_ref', + }) + ); + }); + }) + .select('us.entity_ref') + // Start out with our own matching row + .from('refresh_state_references') + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + // Expand the entire tree that emanates from that row + .leftJoin({ us: 'root_reach' }, function us() { + this.on('us.id', '=', 'refresh_state_references.id'); + }) + // Expand with all roots that target the same node but aren't ourselves + .leftOuterJoin({ them: 'root_reach' }, function them() { + this.on('them.entity_ref', '=', 'us.entity_ref'); + this.andOn('them.id', '!=', 'us.id'); + }) + // Keep only the matches that had no other rooots + .whereNull('them.id') + ); }) - .select().from('refs'); + .delete(); - const nextLayerToRemove = await tx( - 'refresh_state_references', - ) - .whereIn('source_entity_ref', toRemove) - .leftOuterJoin('refresh_state_references AS b', function f() { - - }) - - .whereNotNull('b.source_target_ref') - .select('target_entity_ref'); - - await tx('refresh_state') - .whereIn('entity_ref', toRemove) - .delete(); - - const refsThatStillHaveASourcePointingAtThe = await tx( - 'refresh_state_references', - ) - .whereIn('target_entity_ref', nextLayerTargetRefs) - .groupBy('target_entity_ref') - .select('target_entity_ref'); + await tx('refresh_state_references') + .where('source_key', '=', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .delete(); + console.log( + `REMOVED, ${removedCount} entities: ${JSON.stringify(toRemove)}`, + ); } - - - if (toAdd.length) { const state: Knex.DbRecord[] = toAdd.map(item => ({ entity_id: item.id, @@ -224,67 +263,44 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense await tx.batchInsert('refresh_state', state, BATCH_SIZE); - await tx.batchInsert('refresh_state_references', stateReferences, BATCH_SIZE); - } - - - for (const entity of options.items) { - const entityRef = stringifyEntityRef(entity); - await tx('refresh_state') - .insert({ - entity_id: uuid(), - entity_ref: entityRef, - unprocessed_entity: JSON.stringify(entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - }) - .onConflict('entity_ref') - .merge(['unprocessed_entity', 'last_discovery_at']); - - const [{ entity_id: entityId }] = await tx( - 'refresh_state', - ).where({ entity_ref: entityRef }); - entityIds.push(entityId); - } - const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - entityId => ({ - source_key: options.sourceKey, - target_entityRef: entityRef, - }), - ); - await tx.batchInsert( - 'refresh_state_references', - referenceRows, - BATCH_SIZE, - ); - return; - } - - for (const entity of options.removed) { - const entityRef = stringifyEntityRef(entity); - const [result] = await tx('refresh_state') - .where({ entity_ref: entityRef }) - .select('entity_id'); - - if (!result) { - this.logger.info( - `Unable to delete entity '${entityRef}', entity does not exist in refresh state`, + await tx.batchInsert( + 'refresh_state_references', + stateReferences, + BATCH_SIZE, ); - continue; } - const referenceResults = await tx( - 'refresh_state_references', - ) - // todo correct key? - .where({ source_entity_id: result.entity_id }) - .select(); + // for (const entity of options.items) { + // const entityRef = stringifyEntityRef(entity); + // await tx('refresh_state') + // .insert({ + // entity_id: uuid(), + // entity_ref: entityRef, + // unprocessed_entity: JSON.stringify(entity), + // errors: '', + // next_update_at: tx.fn.now(), + // last_discovery_at: tx.fn.now(), + // }) + // .onConflict('entity_ref') + // .merge(['unprocessed_entity', 'last_discovery_at']); - await tx('refresh_state_references') - // todo correct key? - .where({ source_entity_id: result.entity_id }) - .delete(); + // const [{ entity_id: entityId }] = await tx( + // 'refresh_state', + // ).where({ entity_ref: entityRef }); + // entityIds.push(entityId); + // } + // const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( + // entityId => ({ + // source_key: options.sourceKey, + // target_entityRef: entityRef, + // }), + // ); + // await tx.batchInsert( + // 'refresh_state_references', + // referenceRows, + // BATCH_SIZE, + // ); + // return; } } From 3f6c50a5291775994b0f90890f286b0018f74a7e Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 16:01:36 +0200 Subject: [PATCH 50/88] feat: support delta and full sync with the same code and simplify things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../DefaultProcessingDatabase.test.ts | 89 +++++++ .../database/DefaultProcessingDatabase.ts | 242 ++++++++---------- 2 files changed, 201 insertions(+), 130 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index ee04a2baf8..c4784c081d 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -294,5 +294,94 @@ describe('Default Processing Database', () => { ), ).toBeFalsy(); }); + + it('should add new locations using the delta options', async () => { + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + removed: [], + added: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeTruthy(); + }); + + it('should remove old locations using the delta options', async () => { + await createLocations(['location:default/new-root']); + + await insertRefRow({ + source_key: 'lols', + target_entity_ref: 'location:default/new-root', + }); + + await processingDatabase!.transaction(async tx => { + await processingDatabase!.replaceUnprocessedEntities(tx, { + type: 'delta', + sourceKey: 'lols', + added: [], + removed: [ + { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + ], + }); + }); + + const currentRefreshState = await db!( + 'refresh_state', + ).select(); + + const currentRefRowState = await db!( + 'refresh_state_references', + ).select(); + + expect( + currentRefreshState.some( + t => t.entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + + expect( + currentRefRowState.some( + t => + t.source_key === 'lols' && + t.target_entity_ref === 'location:default/new-root', + ), + ).toBeFalsy(); + }); }); }); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index f0c5443651..a80325d469 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -130,34 +130,48 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + private async createDelta( + tx: Knex.Transaction, + options: ReplaceUnprocessedEntitiesOptions, + ): Promise<{ toAdd: Entity[]; toRemove: string[] }> { + if (options.type === 'delta') { + return { + toAdd: options.added, + toRemove: options.removed.map(e => stringifyEntityRef(e)), + }; + } + + const oldRefs = await tx( + 'refresh_state_references', + ) + .where({ source_key: options.sourceKey }) + .select('target_entity_ref') + .then(rows => rows.map(r => r.target_entity_ref)); + + const items = options.items.map(entity => ({ + entity, + ref: stringifyEntityRef(entity), + })); + + const oldRefsSet = new Set(oldRefs); + const newRefsSet = new Set(items.map(item => item.ref)); + const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); + const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); + + return { toAdd: toAdd.map(({ entity }) => entity), toRemove }; + } + async replaceUnprocessedEntities( txOpaque: Transaction, options: ReplaceUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - if (options.type === 'full') { - const oldRefs = await tx( - 'refresh_state_references', - ) - .where({ source_key: options.sourceKey }) - .select('target_entity_ref') - .then(rows => rows.map(r => r.target_entity_ref)); + const { toAdd, toRemove } = await this.createDelta(tx, options); - const items = options.items.map(entity => ({ - entity, - ref: stringifyEntityRef(entity), - id: uuid(), - })); - - const oldRefsSet = new Set(oldRefs); - const newRefsSet = new Set(items.map(item => item.ref)); - const toAdd = items.filter(item => !oldRefsSet.has(item.ref)); - const toRemove = oldRefs.filter(ref => !newRefsSet.has(ref)); - - if (toRemove.length) { - // TODO(freben): Batch split, to not hit variable limits? - /* + if (toRemove.length) { + // TODO(freben): Batch split, to not hit variable limits? + /* WITH RECURSIVE -- All the refs that can be reached from each individual root root_reach(id, entity_ref) AS ( @@ -188,119 +202,87 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { AND refresh_state_references.target_entity_ref = "A" AND them.id IS NULL; */ - const removedCount = await tx('refresh_state') - .whereIn('entity_ref', function orphanedEntityRefs(orphans) { - return ( - orphans - // All the refs that can be reached from each individual root - .withRecursive('root_reach', function rootReach(outer) { - // Start with all roots - return outer - .select({ id: 'id', entity_ref: 'target_entity_ref' }) - .from('refresh_state_references') - .whereNotNull('source_key') - .union(function recurse(inner) { - return ( - inner - // For each match, select all children - .select({ - id: 'root_reach.id', - entity_ref: - 'refresh_state_references.target_entity_ref', - }) - .from('refresh_state_references') - .crossJoin('root_reach', { - 'root_reach.entity_ref': - 'refresh_state_references.source_entity_ref', - }) - ); - }); - }) - .select('us.entity_ref') - // Start out with our own matching row - .from('refresh_state_references') - .where('source_key', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - // Expand the entire tree that emanates from that row - .leftJoin({ us: 'root_reach' }, function us() { - this.on('us.id', '=', 'refresh_state_references.id'); - }) - // Expand with all roots that target the same node but aren't ourselves - .leftOuterJoin({ them: 'root_reach' }, function them() { - this.on('them.entity_ref', '=', 'us.entity_ref'); - this.andOn('them.id', '!=', 'us.id'); - }) - // Keep only the matches that had no other rooots - .whereNull('them.id') - ); - }) - .delete(); + const removedCount = await tx('refresh_state') + .whereIn('entity_ref', function orphanedEntityRefs(orphans) { + return ( + orphans + // All the refs that can be reached from each individual root + .withRecursive('root_reach', function rootReach(outer) { + // Start with all roots + return outer + .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .whereNotNull('source_key') + .union(function recurse(inner) { + return ( + inner + // For each match, select all children + .select({ + id: 'root_reach.id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('refresh_state_references') + .crossJoin('root_reach', { + 'root_reach.entity_ref': + 'refresh_state_references.source_entity_ref', + }) + ); + }); + }) + .select('us.entity_ref') + // Start out with our own matching row + .from('refresh_state_references') + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + // Expand the entire tree that emanates from that row + .leftJoin({ us: 'root_reach' }, function us() { + this.on('us.id', '=', 'refresh_state_references.id'); + }) + // Expand with all roots that target the same node but aren't ourselves + .leftOuterJoin({ them: 'root_reach' }, function them() { + this.on('them.entity_ref', '=', 'us.entity_ref'); + this.andOn('them.id', '!=', 'us.id'); + }) + // Keep only the matches that had no other rooots + .whereNull('them.id') + ); + }) + .delete(); - await tx('refresh_state_references') - .where('source_key', '=', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - .delete(); + await tx('refresh_state_references') + .where('source_key', '=', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .delete(); - console.log( - `REMOVED, ${removedCount} entities: ${JSON.stringify(toRemove)}`, - ); - } + this.logger.debug( + `removed, ${removedCount} entities: ${JSON.stringify(toRemove)}`, + ); + } - if (toAdd.length) { - const state: Knex.DbRecord[] = toAdd.map(item => ({ - entity_id: item.id, - entity_ref: item.ref, - unprocessed_entity: JSON.stringify(item.entity), - errors: '', - next_update_at: tx.fn.now(), - last_discovery_at: tx.fn.now(), - })); - const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( - item => ({ - source_key: options.sourceKey, - target_entity_ref: item.ref, - }), - ); - // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense - await tx.batchInsert('refresh_state', state, BATCH_SIZE); - await tx.batchInsert( - 'refresh_state_references', - stateReferences, - BATCH_SIZE, - ); - } + if (toAdd.length) { + const state: Knex.DbRecord[] = toAdd.map(entity => ({ + entity_id: uuid(), + entity_ref: stringifyEntityRef(entity), + unprocessed_entity: JSON.stringify(entity), + errors: '', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + })); - // for (const entity of options.items) { - // const entityRef = stringifyEntityRef(entity); - // await tx('refresh_state') - // .insert({ - // entity_id: uuid(), - // entity_ref: entityRef, - // unprocessed_entity: JSON.stringify(entity), - // errors: '', - // next_update_at: tx.fn.now(), - // last_discovery_at: tx.fn.now(), - // }) - // .onConflict('entity_ref') - // .merge(['unprocessed_entity', 'last_discovery_at']); - - // const [{ entity_id: entityId }] = await tx( - // 'refresh_state', - // ).where({ entity_ref: entityRef }); - // entityIds.push(entityId); - // } - // const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - // entityId => ({ - // source_key: options.sourceKey, - // target_entityRef: entityRef, - // }), - // ); - // await tx.batchInsert( - // 'refresh_state_references', - // referenceRows, - // BATCH_SIZE, - // ); - // return; + const stateReferences: DbRefreshStateReferencesRow[] = toAdd.map( + entity => ({ + source_key: options.sourceKey, + target_entity_ref: stringifyEntityRef(entity), + }), + ); + // TODO(freben): Concurrency? If we did these one by one, a .onConflict().merge would have made sense + await tx.batchInsert('refresh_state', state, BATCH_SIZE); + await tx.batchInsert( + 'refresh_state_references', + stateReferences, + BATCH_SIZE, + ); } } From 11ef45c0972a439085dbf772057d299dadccc63c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 10:14:45 +0200 Subject: [PATCH 51/88] catalog-backend: Remove obervables Signed-off-by: Johan Haals --- plugins/catalog-backend/package.json | 4 +- .../src/next/DatabaseLocationProvider.ts | 71 ------------------- .../next/DefaultCatalogProcessingEngine.ts | 27 +------ .../src/next/DefaultLocationStore.ts | 4 -- plugins/catalog-backend/src/next/types.ts | 5 +- 5 files changed, 5 insertions(+), 106 deletions(-) delete mode 100644 plugins/catalog-backend/src/next/DatabaseLocationProvider.ts diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 9afb537637..5f70ec60e1 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -33,7 +33,6 @@ "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/plugin-search-backend-node": "^0.1.3", @@ -61,8 +60,7 @@ "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.3", - "zen-observable": "^0.8.15" + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.6.9", diff --git a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts b/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts deleted file mode 100644 index eaf5a78556..0000000000 --- a/plugins/catalog-backend/src/next/DatabaseLocationProvider.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Observable } from '@backstage/core'; -import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { EntityProvider, LocationStore, EntityMessage } from './types'; -import ObservableImpl from 'zen-observable'; -import { - locationSpecToLocationEntity, - locationSpecToMetadataName, -} from './util'; - -export class DatabaseLocationProvider implements EntityProvider { - private subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - constructor(private readonly store: LocationStore) { - store.location$().subscribe({ - next: locations => { - if ('all' in locations) { - this.notify({ - all: locations.all.map(l => locationSpecToLocationEntity(l)), - }); - } else { - this.notify({ - added: locations.added.map(l => locationSpecToLocationEntity(l)), - removed: locations.removed.map(l => ({ - kind: 'Location', - namespace: ENTITY_DEFAULT_NAMESPACE, - name: locationSpecToMetadataName(l), - })), - }); - } - }, - }); - } - - private notify(message: EntityMessage) { - for (const subscriber of this.subscribers) { - subscriber.next(message); - } - } - - entityChange$(): Observable { - return new ObservableImpl(subscriber => { - this.store.listLocations().then(locations => { - subscriber.next({ - all: locations.map(l => locationSpecToLocationEntity(l)), - }); - this.subscribers.add(subscriber); - }); - return () => { - this.subscribers.delete(subscriber); - }; - }); - } -} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 7f49bf832d..b71b2c1045 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { Subscription } from '@backstage/core'; import { CatalogProcessingEngine, EntityProvider, - EntityMessage, EntityProviderConnection, EntityProviderMutation, ProcessingStateManager, @@ -40,7 +38,7 @@ class Connection implements EntityProviderConnection { async applyMutation(mutation: EntityProviderMutation): Promise { if (mutation.type === 'full') { await this.config.stateManager.replaceProcessingItems({ - id: this.config.id, + sourceKey: this.config.id, type: 'full', items: mutation.entities, }); @@ -49,7 +47,7 @@ class Connection implements EntityProviderConnection { } await this.config.stateManager.replaceProcessingItems({ - id: this.config.id, + sourceKey: this.config.id, type: 'delta', added: mutation.added, removed: mutation.removed, @@ -120,25 +118,4 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async stop() { this.running = false; } - - private async onNext(id: string, message: EntityMessage) { - if ('all' in message) { - // TODO unhandled rejection - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.all, - }); - } - - if ('added' in message) { - await this.stateManager.addProcessingItems({ - id, - type: 'provider', - entities: message.added, - }); - - // TODO deletions of message.removed - } - } } diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index dff424b1a9..21206f0a7b 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -25,10 +25,6 @@ import { v4 as uuidv4 } from 'uuid'; import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; -export type LocationMessage = - | { all: Location[] } - | { added: Location[]; removed: Location[] }; - export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index f8b4de1c41..a39ab9a8ff 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -16,7 +16,6 @@ import { Entity, - EntityName, LocationSpec, Location, EntityRelationSpec, @@ -116,12 +115,12 @@ export type ProcessingItem = { export type ReplaceProcessingItemsRequest = | { - id: string; + sourceKey: string; items: Entity[]; type: 'full'; } | { - id: string; + sourceKey: string; added: Entity[]; removed: Entity[]; type: 'delta'; From fd7cd5d44959a3bd1d356eca4837dfd419372eb4 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 13:56:32 +0200 Subject: [PATCH 52/88] Refactor deferredEntities processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Signed-off-by: Johan Haals --- .../src/next/DefaultLocationStore.ts | 8 + .../src/next/DefaultProcessingStateManager.ts | 39 ++- .../src/next/NextCatalogBuilder.ts | 1 - plugins/catalog-backend/src/next/Stitcher.ts | 4 +- .../DefaultProcessingDatabase.test.ts | 83 ++++-- .../database/DefaultProcessingDatabase.ts | 236 ++++++++++-------- .../src/next/database/types.ts | 7 +- 7 files changed, 222 insertions(+), 156 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 21206f0a7b..40870cff7b 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -104,5 +104,13 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async connect(connection: EntityProviderConnection): Promise { this._connection = connection; + const locations = await this.db.locations(); + const entities = locations.map(location => { + return locationSpecToLocationEntity(location); + }); + await this.connection.applyMutation({ + type: 'full', + entities, + }); } } diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index d2765c3101..0003b7a7cf 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -49,35 +49,28 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { async addProcessingItems(request: AddProcessingItemRequest) { return this.db.transaction(async tx => { - await this.db.addUnprocessedEntities(tx, request); + // await this.db.addUnprocessedEntities(tx, request); }); } async getNextProcessingItem(): Promise { - const entities = await new Promise(resolve => - this.popFromQueue(resolve), - ); - const { id, state, unprocessedEntity } = entities[0]; - return { - id, - entity: unprocessedEntity, - state, - }; - } - - async popFromQueue(resolve: (rows: RefreshStateItem[]) => void) { - const entities = await this.db.transaction(async tx => { - return this.db.getProcessableEntities(tx, { - processBatchSize: 1, + for (;;) { + const { items } = await this.db.transaction(async tx => { + return this.db.getProcessableEntities(tx, { + processBatchSize: 1, + }); }); - }); - // No entities require refresh, wait and try again. - if (!entities.items.length) { - setTimeout(() => this.popFromQueue(resolve), 1000); - return; + if (items.length) { + const { id, state, unprocessedEntity } = items[0]; + return { + id, + entity: unprocessedEntity, + state, + }; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); } - - resolve(entities.items); } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 1796847164..d720bd331a 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -67,7 +67,6 @@ import { LocationAnalyzer } from '../ingestion/types'; import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; -import { DatabaseLocationProvider } from '../next/DatabaseLocationProvider'; import { DefaultLocationStore } from './DefaultLocationStore'; import { DefaultProcessingStateManager } from './DefaultProcessingStateManager'; import { CatalogProcessingEngine } from '../next/types'; diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 3e5e3ac24b..f53b7c6fad 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -76,8 +76,8 @@ export class Stitcher { const [reference_count_result] = await tx( 'refresh_state_references', ) - .where({ target_entity_id: entity.metadata.uid }) - .count({ reference_count: 'target_entity_id' }); + .where({ target_entity_ref: entityRef }) + .count({ reference_count: 'target_entity_ref' }); if (Number(reference_count_result.reference_count) === 0) { this.logger.debug(`${entityRef} is orphan`); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index c4784c081d..14990a2a79 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -27,27 +27,26 @@ import * as uuid from 'uuid'; import { getVoidLogger } from '@backstage/backend-common'; describe('Default Processing Database', () => { - let db: Knex | undefined; - let processingDatabase: DefaultProcessingDatabase | undefined; - + let db: Knex; + let processingDatabase: DefaultProcessingDatabase; const logger = getVoidLogger(); beforeEach(async () => { db = await DatabaseManager.createTestDatabaseConnection(); await DatabaseManager.createDatabase(db); - processingDatabase = new DefaultProcessingDatabase(db!, logger); + processingDatabase = new DefaultProcessingDatabase(db, logger); }); describe('replaceUnprocessedEntities', () => { const insertRefRow = async (ref: DbRefreshStateReferencesRow) => { - return db!( - 'refresh_state_references', - ).insert(ref); + return db('refresh_state_references').insert( + ref, + ); }; const insertRefreshStateRow = async (ref: DbRefreshStateRow) => { - await db!('refresh_state').insert(ref); + await db('refresh_state').insert(ref); }; const createLocations = async (entityRefs: string[]) => { @@ -101,8 +100,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/root-2', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'full', sourceKey: 'config', items: [ @@ -117,11 +116,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -205,8 +204,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/root-2', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'full', sourceKey: 'config', items: [ @@ -221,11 +220,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -296,8 +295,8 @@ describe('Default Processing Database', () => { }); it('should add new locations using the delta options', async () => { - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'delta', sourceKey: 'lols', removed: [], @@ -313,11 +312,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); @@ -336,6 +335,42 @@ describe('Default Processing Database', () => { ).toBeTruthy(); }); + it('should not remove locations that are referenced elsewhere', async () => { + /* + config-1 -> location:default/root + config-2 -> location:default/root + */ + await createLocations(['location:default/root']); + + await insertRefRow({ + source_key: 'config-1', + target_entity_ref: 'location:default/root', + }); + await insertRefRow({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }); + + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'config-1', + items: [], + }); + }); + + const currentRefRowState = await db( + 'refresh_state_references', + ).select(); + + expect(currentRefRowState).toEqual({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }); + + // TODO: assert that root wasn't removed + }); + it('should remove old locations using the delta options', async () => { await createLocations(['location:default/new-root']); @@ -344,8 +379,8 @@ describe('Default Processing Database', () => { target_entity_ref: 'location:default/new-root', }); - await processingDatabase!.transaction(async tx => { - await processingDatabase!.replaceUnprocessedEntities(tx, { + await processingDatabase.transaction(async tx => { + await processingDatabase.replaceUnprocessedEntities(tx, { type: 'delta', sourceKey: 'lols', added: [], @@ -361,11 +396,11 @@ describe('Default Processing Database', () => { }); }); - const currentRefreshState = await db!( + const currentRefreshState = await db( 'refresh_state', ).select(); - const currentRefRowState = await db!( + const currentRefRowState = await db( 'refresh_state_references', ).select(); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index a80325d469..dc5e6036f5 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -26,10 +26,12 @@ import { UpdateProcessedEntityOptions, GetProcessableEntitiesResult, ReplaceUnprocessedEntitiesOptions, + RefreshStateItem, } from './types'; import type { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/config'; export type DbRefreshStateRow = { entity_id: string; @@ -88,7 +90,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { errors, }) .where('entity_id', id); - if (refreshResult === 0) { throw new NotFoundError(`Processing state not found for ${id}`); } @@ -96,8 +97,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { // Schedule all deferred entities for future processing. await this.addUnprocessedEntities(tx, { entities: deferredEntities, - id, - type: 'entity', + entityRef: stringifyEntityRef(processedEntity), }); // Update fragments @@ -172,80 +172,104 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { if (toRemove.length) { // TODO(freben): Batch split, to not hit variable limits? /* - WITH RECURSIVE - -- All the refs that can be reached from each individual root - root_reach(id, entity_ref) AS ( - -- Start with all roots - SELECT id, target_entity_ref - FROM refresh_state_references - WHERE source_key IS NOT NULL - UNION - -- For each match, select all children - SELECT root_reach.id, target_entity_ref - FROM refresh_state_references, root_reach - WHERE source_entity_ref = root_reach.entity_ref - ) - -- Start out with our own matching row (see the WHERE that - -- matches on source_key and target_entity_ref below) - SELECT us.entity_ref - FROM refresh_state_references - -- Expand the entire tree that emanates from that row - JOIN root_reach AS us - ON us.id = refresh_state_references.id - -- Expand with all roots that target the same node but - -- aren't ourselves - LEFT OUTER JOIN root_reach AS them - ON them.entity_ref = us.entity_ref - AND them.id != us.id - -- Keep only the matches that had no other rooots - WHERE refresh_state_references.source_key = "R1" - AND refresh_state_references.target_entity_ref = "A" - AND them.id IS NULL; - */ + WITH RECURSIVE + -- All the nodes that can be reached downwards from our root + descendants(root_id, entity_ref) AS ( + SELECT id, target_entity_ref + FROM refresh_state_references + WHERE source_key = "R1" AND target_entity_ref = "A" + UNION + SELECT descendants.root_id, target_entity_ref + FROM descendants + JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref + ), + -- All the nodes that can be reached upwards from the descendants + ancestors(root_id, via_entity_ref, to_entity_ref) AS ( + SELECT NULL, entity_ref, entity_ref + FROM descendants + UNION + SELECT + CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END, + source_entity_ref, + ancestors.to_entity_ref + FROM ancestors + JOIN refresh_state_references ON target_entity_ref = ancestors.via_entity_ref + ) + -- Start out with all of the descendants + SELECT descendants.entity_ref + FROM descendants + -- Expand with all ancestors that point to those, but aren't the current root + LEFT OUTER JOIN ancestors + ON ancestors.to_entity_ref = descendants.entity_ref + AND ancestors.root_id IS NOT NULL + AND ancestors.root_id != descendants.root_id + -- Exclude all lines that had such a foreign ancestor + WHERE ancestors.root_id IS NULL; + */ const removedCount = await tx('refresh_state') .whereIn('entity_ref', function orphanedEntityRefs(orphans) { return ( orphans - // All the refs that can be reached from each individual root - .withRecursive('root_reach', function rootReach(outer) { - // Start with all roots + // All the nodes that can be reached downwards from our root + .withRecursive('descendants', function descendants(outer) { return outer - .select({ id: 'id', entity_ref: 'target_entity_ref' }) + .select({ root_id: 'id', entity_ref: 'target_entity_ref' }) .from('refresh_state_references') - .whereNotNull('source_key') - .union(function recurse(inner) { - return ( - inner - // For each match, select all children - .select({ - id: 'root_reach.id', - entity_ref: - 'refresh_state_references.target_entity_ref', - }) - .from('refresh_state_references') - .crossJoin('root_reach', { - 'root_reach.entity_ref': - 'refresh_state_references.source_entity_ref', - }) - ); + .where('source_key', options.sourceKey) + .whereIn('target_entity_ref', toRemove) + .union(function recursive(inner) { + return inner + .select({ + root_id: 'descendants.root_id', + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('descendants') + .join('refresh_state_references', { + 'descendants.entity_ref': + 'refresh_state_references.source_entity_ref', + }); }); }) - .select('us.entity_ref') - // Start out with our own matching row - .from('refresh_state_references') - .where('source_key', options.sourceKey) - .whereIn('target_entity_ref', toRemove) - // Expand the entire tree that emanates from that row - .leftJoin({ us: 'root_reach' }, function us() { - this.on('us.id', '=', 'refresh_state_references.id'); + // All the nodes that can be reached upwards from the descendants + .withRecursive('ancestors', function ancestors(outer) { + return outer + .select({ + root_id: tx.raw('NULL', []), + via_entity_ref: 'entity_ref', + to_entity_ref: 'entity_ref', + }) + .from('descendants') + .union(function recursive(inner) { + return inner + .select({ + root_id: tx.raw( + 'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END', + [], + ), + via_entity_ref: 'source_entity_ref', + to_entity_ref: 'ancestors.to_entity_ref', + }) + .from('ancestors') + .join('refresh_state_references', { + target_entity_ref: 'ancestors.via_entity_ref', + }); + }); }) - // Expand with all roots that target the same node but aren't ourselves - .leftOuterJoin({ them: 'root_reach' }, function them() { - this.on('them.entity_ref', '=', 'us.entity_ref'); - this.andOn('them.id', '!=', 'us.id'); + // Start out with all of the descendants + .select('descendants.entity_ref') + .from('descendants') + // Expand with all ancestors that point to those, but aren't the current root + .leftOuterJoin('ancestors', function keepaliveRoots() { + this.on( + 'ancestors.to_entity_ref', + '=', + 'descendants.entity_ref', + ); + this.andOnNotNull('ancestors.root_id'); + this.andOn('ancestors.root_id', '!=', 'descendants.root_id'); }) - // Keep only the matches that had no other rooots - .whereNull('them.id') + .whereNull('ancestors.root_id') ); }) .delete(); @@ -291,44 +315,45 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { options: AddUnprocessedEntitiesOptions, ): Promise { const tx = txOpaque as Knex.Transaction; - const entityIds = new Array(); - for (const entity of options.entities) { - const entityRef = stringifyEntityRef(entity); - await tx('refresh_state') - .insert({ + const stateRows = options.entities.map( + entity => + ({ entity_id: uuid(), - entity_ref: entityRef, + entity_ref: stringifyEntityRef(entity), unprocessed_entity: JSON.stringify(entity), errors: '', next_update_at: tx.fn.now(), last_discovery_at: tx.fn.now(), - }) + } as Knex.DbRecord), + ); + const stateReferenceRows = stateRows.map( + stateRow => + ({ + source_entity_ref: options.entityRef, + target_entity_ref: stateRow.entity_ref, + } as Knex.DbRecord), + ); + + // Upsert all of the unprocessed entities into the refresh_state table, by + // their entity ref. + // TODO(freben): Can this be batched somehow? + for (const row of stateRows) { + await tx('refresh_state') + .insert(row) .onConflict('entity_ref') .merge(['unprocessed_entity', 'last_discovery_at']); - - const [{ entity_id: entityId }] = await tx( - 'refresh_state', - ).where({ entity_ref: entityRef }); - entityIds.push(entityId); } - const key = - options.type === 'provider' - ? { source_special_key: options.id } - : { source_entity_id: options.id }; - // copied from update refs + // Replace all references for the originating entity before creating new ones await tx('refresh_state_references') - .where(key) + .where({ source_entity_ref: options.entityRef }) .delete(); - - const referenceRows: DbRefreshStateReferencesRow[] = entityIds.map( - entityId => ({ - ...key, - target_entity_id: entityId, - }), + await tx.batchInsert( + 'refresh_state_references', + stateReferenceRows, + BATCH_SIZE, ); - await tx.batchInsert('refresh_state_references', referenceRows, BATCH_SIZE); } async getProcessableEntities( @@ -356,16 +381,23 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { }); return { - items: items.map(i => ({ - id: i.entity_id, - entityRef: i.entity_ref, - unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, - processedEntity: JSON.parse(i.processed_entity) as Entity, - nextUpdateAt: i.next_update_at, - lastDiscoveryAt: i.last_discovery_at, - state: JSON.parse(i.cache), - errors: i.errors, - })), + items: items.map( + i => + ({ + id: i.entity_id, + entityRef: i.entity_ref, + unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity, + processedEntity: i.processed_entity + ? (JSON.parse(i.processed_entity) as Entity) + : undefined, + nextUpdateAt: i.next_update_at, + lastDiscoveryAt: i.last_discovery_at, + state: i.cache + ? JSON.parse(i.cache) + : new Map(), + errors: i.errors, + } as RefreshStateItem), + ), }; } diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index b926676887..d7a1dcd20b 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -19,8 +19,7 @@ import { JsonObject } from '@backstage/config'; import { Transaction } from '../../database/types'; export type AddUnprocessedEntitiesOptions = { - type: 'entity' | 'provider'; - id: string; + entityRef: string; entities: Entity[]; }; @@ -39,11 +38,11 @@ export type RefreshStateItem = { id: string; entityRef: string; unprocessedEntity: Entity; - processedEntity: Entity; + processedEntity?: Entity; nextUpdateAt: string; lastDiscoveryAt: string; // remove? state: Map; - errors: string; + errors?: string; }; export type GetProcessableEntitiesResult = { From 19b07a6f67b3c5ba58d11258be9db4a6c2596326 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:02:41 +0200 Subject: [PATCH 53/88] Remove addProcessingItems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/Stitcher.ts | 4 ++-- plugins/catalog-backend/src/next/types.ts | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index f53b7c6fad..57f5ae9167 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -19,7 +19,7 @@ import { Logger } from 'winston'; import { Transaction } from '../database'; import { ConflictError } from '@backstage/errors'; import { - DbRefreshStateReferences, + DbRefreshStateReferencesRow, DbRefreshStateRow, DbRelationsRow, } from './database/DefaultProcessingDatabase'; @@ -73,7 +73,7 @@ export class Stitcher { return; } - const [reference_count_result] = await tx( + const [reference_count_result] = await tx( 'refresh_state_references', ) .where({ target_entity_ref: entityRef }) diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index a39ab9a8ff..ef3bab797b 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -101,12 +101,6 @@ export type ProcessingItemResult = { deferredEntities: Entity[]; }; -export type AddProcessingItemRequest = { - type: 'entity' | 'provider'; - id: string; - entities: Entity[]; -}; - export type ProcessingItem = { id: string; entity: Entity; @@ -128,6 +122,5 @@ export type ReplaceProcessingItemsRequest = export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; getNextProcessingItem(): Promise; - addProcessingItems(request: AddProcessingItemRequest): Promise; replaceProcessingItems(request: ReplaceProcessingItemsRequest): Promise; } From e0c39cd7e308a20f5e91b4cb04283589c5bc2251 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:12:57 +0200 Subject: [PATCH 54/88] Add moar tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index 14990a2a79..f67b8603db 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -359,16 +359,26 @@ describe('Default Processing Database', () => { }); }); + const currentRefreshState = await db( + 'refresh_state', + ).select(); + const currentRefRowState = await db( 'refresh_state_references', ).select(); - expect(currentRefRowState).toEqual({ - source_key: 'config-2', - target_entity_ref: 'location:default/root', - }); + expect(currentRefRowState).toEqual([ + expect.objectContaining({ + source_key: 'config-2', + target_entity_ref: 'location:default/root', + }), + ]); - // TODO: assert that root wasn't removed + expect(currentRefreshState).toEqual([ + expect.objectContaining({ + entity_ref: 'location:default/root', + }), + ]); }); it('should remove old locations using the delta options', async () => { From ac48004b7d8f60e3389ca30890a4abdc5d00493e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 14:15:29 +0200 Subject: [PATCH 55/88] Remove dead code Signed-off-by: Johan Haals --- .../src/next/DefaultProcessingStateManager.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts index 0003b7a7cf..9c1d20ae9c 100644 --- a/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts +++ b/plugins/catalog-backend/src/next/DefaultProcessingStateManager.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { ProcessingDatabase, RefreshStateItem } from './database/types'; +import { ProcessingDatabase } from './database/types'; import { - AddProcessingItemRequest, ProcessingItem, ProcessingItemResult, ProcessingStateManager, @@ -47,12 +46,6 @@ export class DefaultProcessingStateManager implements ProcessingStateManager { }); } - async addProcessingItems(request: AddProcessingItemRequest) { - return this.db.transaction(async tx => { - // await this.db.addUnprocessedEntities(tx, request); - }); - } - async getNextProcessingItem(): Promise { for (;;) { const { items } = await this.db.transaction(async tx => { From c58f8fd1477b6ca536d54ce6ef3aae2b92cd944a Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 15:38:31 +0200 Subject: [PATCH 56/88] Add all migration files to migrationsv2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../migrationsv2/20200511113813_init.js | 133 ++++++++++ ...0200520140700_location_update_log_table.js | 43 ++++ ...7114117_location_update_log_latest_view.js | 45 ++++ .../migrationsv2/20200702153613_entities.js | 236 ++++++++++++++++++ ..._location_update_log_latest_deduplicate.js | 44 ++++ ...904_location_update_log_duplication_fix.js | 87 +++++++ .../20200807120600_entitySearch.js | 41 +++ .../20200809202832_add_bootstrap_location.js | 42 ++++ .../20200923104503_case_insensitivity.js | 32 +++ .../20201005122705_add_entity_full_name.js | 60 +++++ .../20201006130744_entity_data_column.js | 66 +++++ ...6203131_entity_remove_redundant_columns.js | 50 ++++ .../20201007201501_index_entity_search.js | 37 +++ .../20201019130742_add_relations_table.js | 54 ++++ .../20201123205611_relations_table_uniq.js | 93 +++++++ .../migrationsv2/20201210185851_fk_index.js | 45 ++++ .../20201230103504_update_log_varchar.js | 73 ++++++ .../20210209121210_locations_fk_index.js | 45 ++++ .../src/next/database/DatabaseManager.ts | 15 -- 19 files changed, 1226 insertions(+), 15 deletions(-) create mode 100644 plugins/catalog-backend/migrationsv2/20200511113813_init.js create mode 100644 plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js create mode 100644 plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js create mode 100644 plugins/catalog-backend/migrationsv2/20200702153613_entities.js create mode 100644 plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js create mode 100644 plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js create mode 100644 plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js create mode 100644 plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js create mode 100644 plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js create mode 100644 plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js create mode 100644 plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js create mode 100644 plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js create mode 100644 plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js create mode 100644 plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js create mode 100644 plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js create mode 100644 plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js create mode 100644 plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js create mode 100644 plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js diff --git a/plugins/catalog-backend/migrationsv2/20200511113813_init.js b/plugins/catalog-backend/migrationsv2/20200511113813_init.js new file mode 100644 index 0000000000..7f3d75e35c --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200511113813_init.js @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return ( + knex.schema + // + // locations + // + .createTable('locations', table => { + table.comment( + 'Registered locations that shall be contiuously scanned for catalog item updates', + ); + table + .uuid('id') + .primary() + .notNullable() + .comment('Auto-generated ID of the location'); + table.string('type').notNullable().comment('The type of location'); + table + .string('target') + .notNullable() + .comment('The actual target of the location'); + }) + // + // entities + // + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }) + // + // entities_search + // + .createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }) + ); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema + .dropTable('entities_search') + .alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }) + .dropTable('entities') + .dropTable('locations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js new file mode 100644 index 0000000000..d8093fc9b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200520140700_location_update_log_table.js @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + return knex.schema.createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.dropTableIfExists('location_update_log'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js new file mode 100644 index 0000000000..a0f0f33a65 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200527114117_location_update_log_latest_view.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Get list sorted by created_at timestamp in descending order + // Grouped by location_id + return knex.schema.raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200702153613_entities.js b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js new file mode 100644 index 0000000000..0f1c204f9b --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200702153613_entities.js @@ -0,0 +1,236 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // SQLite does not support FK and PK + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.dropForeign(['entity_id']); + }); + await knex.schema.alterTable('entities', table => { + table.dropPrimary('entities_pkey'); + }); + } + await knex.schema.alterTable('entities', table => { + table.dropUnique([], 'entities_unique_name'); + }); + + // Setup temporary tables + await knex.schema.renameTable('entities_search', 'tmp_entities_search'); + await knex.schema.renameTable('entities', 'tmp_entities'); + + // + // entities + // + await knex.schema + .createTable('entities', table => { + table.comment('All entities currently stored in the catalog'); + table.uuid('id').primary().comment('Auto-generated ID of the entity'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .nullable() + .comment('The location that originated the entity'); + table + .string('etag') + .notNullable() + .comment( + 'An opaque string that changes for each update operation to any part of the entity, including metadata.', + ); + table + .string('generation') + .notNullable() + .unsigned() + .comment( + 'A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes.', + ); + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table + .string('kind') + .notNullable() + .comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + table + .string('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .string('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + }) + .alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['kind', 'name', 'namespace'], 'entities_unique_name'); + }); + + await knex.schema.raw(`INSERT INTO entities SELECT * FROM tmp_entities`); + + // + // entities_search + // + await knex.schema.createTable('entities_search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + }); + await knex.schema.raw( + `INSERT INTO entities_search SELECT * FROM tmp_entities_search`, + ); + + // Clean up + await knex.schema.dropTable('tmp_entities'); + return knex.schema.dropTable('tmp_entities_search'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js new file mode 100644 index 0000000000..87b41a80fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200721115244_location_update_log_latest_deduplicate.js @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema.raw(`DROP VIEW location_update_log_latest;`).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; +`); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + knex.schema.raw(`DROP VIEW location_update_log_latest;`); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js new file mode 100644 index 0000000000..de2b194cff --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200805163904_location_update_log_duplication_fix.js @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = function up(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.bigIncrements('id').primary(); // instead of uuid, so we can MAX it + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function down(knex) { + return knex.schema + .raw('DROP VIEW location_update_log_latest;') + .dropTable('location_update_log') + .createTable('location_update_log', table => { + table.uuid('id').primary(); + table.enum('status', ['success', 'fail']).notNullable(); + table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable(); + table.string('message'); + table + .uuid('location_id') + .references('id') + .inTable('locations') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.string('entity_name').nullable(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(created_at) AS MAXDATE + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.created_at = t2.MAXDATE + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js new file mode 100644 index 0000000000..45226e53b4 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200807120600_entitySearch.js @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.text('value').nullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // Sqlite does not support alter column. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_search', table => { + table.string('value').nullable().alter(); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js new file mode 100644 index 0000000000..a90813fe85 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200809202832_add_bootstrap_location.js @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Adds a single 'bootstrap' location that can be used to trigger work in processors. + // This is primarily here to fulfill foreign key constraints. + await knex('locations').insert({ + id: require('uuid').v4(), + type: 'bootstrap', + target: 'bootstrap', + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex('locations') + .where({ + type: 'bootstrap', + target: 'bootstrap', + }) + .del(); +}; diff --git a/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js new file mode 100644 index 0000000000..ea5ba9e58d --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20200923104503_case_insensitivity.js @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex('entities') + .where({ namespace: null }) + .update({ namespace: 'default' }); + await knex('entities_search').update({ + key: knex.raw('LOWER(key)'), + value: knex.raw('LOWER(value)'), + }); +}; + +exports.down = async function down() {}; diff --git a/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js new file mode 100644 index 0000000000..aae1861658 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201005122705_add_entity_full_name.js @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.text('full_name').nullable(); + }); + + await knex('entities').update({ + full_name: knex.raw( + "LOWER(kind) || ':' || LOWER(COALESCE(namespace, 'default')) || '/' || LOWER(name)", + ), + }); + + // SQLite does not support alter column + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('full_name').notNullable().alter(); + }); + } + + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['full_name'], 'entities_unique_full_name'); + table.dropUnique([], 'entities_unique_name'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.dropUnique([], 'entities_unique_full_name'); + table.unique(['kind', 'namespace', 'name'], 'entities_unique_name'); + }); + + await knex.schema.alterTable('entities_search', table => { + table.dropColumn('full_name'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js new file mode 100644 index 0000000000..a8964efbf6 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006130744_entity_data_column.js @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('data') + .nullable() + .comment('The entire JSON data blob of the entity'); + }); + + await knex('entities').update({ + // apiVersion and kind should not contain any JSON unsafe chars, and both + // metadata and spec are already valid serialized JSON + data: knex.raw( + `'{"apiVersion":"' || api_version || '","kind":"' || kind || '","metadata":' || metadata || COALESCE(',"spec":' || spec, '') || '}'`, + ), + }); + + await knex.schema.alterTable('entities', table => { + table.dropColumn('metadata'); + table.dropColumn('spec'); + }); + + // SQLite does not support ALTER COLUMN. + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.text('data').notNullable().alter(); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .text('metadata') + .notNullable() + .comment('The entire metadata JSON blob of the entity'); + table + .text('spec') + .nullable() + .comment('The entire spec JSON blob of the entity'); + table.dropColumn('data'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js new file mode 100644 index 0000000000..f40df5f73e --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201006203131_entity_remove_redundant_columns.js @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.dropColumn('api_version'); + table.dropColumn('kind'); + table.dropColumn('name'); + table.dropColumn('namespace'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + table + .string('api_version') + .notNullable() + .comment('The apiVersion field of the entity'); + table.string('kind').notNullable().comment('The kind field of the entity'); + table + .string('name') + .nullable() + .comment('The metadata.name field of the entity'); + table + .string('namespace') + .nullable() + .comment('The metadata.namespace field of the entity'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js new file mode 100644 index 0000000000..77bf0529eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201007201501_index_entity_search.js @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities_search', table => { + table.index(['key'], 'entities_search_key'); + table.index(['value'], 'entities_search_value'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities_search', table => { + table.dropIndex('', 'entities_search_key'); + table.dropIndex('', 'entities_search_value'); + }); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js new file mode 100644 index 0000000000..85e729f814 --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201019130742_add_relations_table.js @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('entities_relations'); +}; diff --git a/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js new file mode 100644 index 0000000000..9e8198b5eb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201123205611_relations_table_uniq.js @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + // sqlite doesn't support dropPrimary so we recreate it properly instead + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + table.index('source_full_name', 'source_full_name_idx'); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropPrimary(); + table.index('source_full_name', 'source_full_name_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'source_full_name_idx'); + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js new file mode 100644 index 0000000000..abb26cd5fc --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201210185851_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.index('originating_entity_id', 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_search', table => { + table.index('entity_id', 'entity_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'originating_entity_id_idx'); + }); + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'entity_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js new file mode 100644 index 0000000000..d924b0414a --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20201230103504_update_log_varchar.js @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + // We actually just want to widen columns, but can't do that while a + // view is dependent on them - so we just reconstruct it exactly as it was + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.text('message').alter(); + table.text('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema + .raw('DROP VIEW location_update_log_latest;') + .alterTable('location_update_log', table => { + table.string('message').alter(); + table.string('entity_name').nullable().alter(); + }).raw(` + CREATE VIEW location_update_log_latest AS + SELECT t1.* FROM location_update_log t1 + JOIN + ( + SELECT location_id, MAX(id) AS MAXID + FROM location_update_log + GROUP BY location_id + ) t2 + ON t1.location_id = t2.location_id + AND t1.id = t2.MAXID + GROUP BY t1.location_id, t1.id + ORDER BY created_at DESC; + `); + } +}; diff --git a/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js new file mode 100644 index 0000000000..ccfb1faffb --- /dev/null +++ b/plugins/catalog-backend/migrationsv2/20210209121210_locations_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.index('location_id', 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.index('location_id', 'update_log_location_id_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.dropIndex([], 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.dropIndex([], 'update_log_location_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts index 4c9e45950f..f660f73ecb 100644 --- a/plugins/catalog-backend/src/next/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -20,7 +20,6 @@ import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; import { CommonDatabase } from '../../database/CommonDatabase'; import { Database } from '../../database/types'; -import fs from 'fs-extra'; export type CreateDatabaseOptions = { logger: Logger; @@ -35,16 +34,10 @@ export class DatabaseManager { knex: Knex, options: Partial = {}, ): Promise { - const allMigrations = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrations', - ); - const migrationsDir = resolvePackagePath( '@backstage/plugin-catalog-backend', 'migrationsv2', ); - await fs.copy(allMigrations, migrationsDir); await knex.migrate.latest({ directory: migrationsDir, @@ -79,14 +72,6 @@ export class DatabaseManager { public static async createTestDatabaseConnection(): Promise { const config: Knex.Config = { - /* - client: 'pg', - connection: { - host: 'localhost', - user: 'postgres', - password: 'postgres', - }, - */ client: 'sqlite3', connection: ':memory:', useNullAsDefault: true, From aad89f55460c1d9f96165d677e188edd7d304daf Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 15:43:22 +0200 Subject: [PATCH 57/88] Add configLocationProvider and provider identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .../src/next/ConfigLocationProvider.ts | 49 +++++++++++++++++++ .../next/DefaultCatalogProcessingEngine.ts | 9 ++-- .../src/next/DefaultLocationStore.ts | 4 ++ .../src/next/NextCatalogBuilder.ts | 7 +-- plugins/catalog-backend/src/next/types.ts | 1 + 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 plugins/catalog-backend/src/next/ConfigLocationProvider.ts diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts new file mode 100644 index 0000000000..5b93fcfd98 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityProviderConnection, EntityProvider } from './types'; +import path from 'path'; +import { Config } from '@backstage/config'; +import { locationSpecToLocationEntity } from './util'; + +export class ConfigLocationProvider implements EntityProvider { + private connection: EntityProviderConnection | undefined; + + constructor(private readonly config: Config) {} + + getProviderName(): string { + return 'ConfigLocationProvider'; + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + + const locationConfigs = + this.config.getOptionalConfigArray('catalog.locations') ?? []; + + const entities = locationConfigs.map(location => + locationSpecToLocationEntity({ + type: location.getString('type'), + target: path.resolve(location.getString('target')), + }), + ); + + await this.connection.applyMutation({ + type: 'full', + entities, + }); + } +} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index b71b2c1045..1934b0aea7 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -68,9 +68,12 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - // TODO: this ID should be some form of identifier for the EntityProvider - const id = 'databaseProvider'; - provider.connect(new Connection({ stateManager: this.stateManager, id })); + provider.connect( + new Connection({ + stateManager: this.stateManager, + id: provider.getProviderName(), + }), + ); } this.running = true; diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 40870cff7b..a56e5d1585 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -30,6 +30,10 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { constructor(private readonly db: Database) {} + getProviderName(): string { + return 'DefaultLocationStore'; + } + createLocation(spec: LocationSpec): Promise { return this.db.transaction(async tx => { // TODO: id should really be type and target combined and not a uuid. diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index d720bd331a..0699a13f58 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -50,6 +50,7 @@ import { GithubDiscoveryProcessor, GithubOrgReaderProcessor, LdapOrgReaderProcessor, + LocationEntityProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -73,6 +74,7 @@ import { CatalogProcessingEngine } from '../next/types'; import { NextEntitiesCatalog } from './NextEntitiesCatalog'; import { Stitcher } from './Stitcher'; import { CommonDatabase } from '../database/CommonDatabase'; +import { ConfigLocationProvider } from './ConfigLocationProvider'; export type CatalogEnvironment = { logger: Logger; @@ -272,9 +274,10 @@ export class NextCatalogBuilder { const locationStore = new DefaultLocationStore(db); const stitcher = new Stitcher(dbClient, logger); + const configLocationProvider = new ConfigLocationProvider(config); const processingEngine = new DefaultCatalogProcessingEngine( logger, - [locationStore], // entityproviders + [locationStore, configLocationProvider], stateManager, orchestrator, stitcher, @@ -322,7 +325,6 @@ export class NextCatalogBuilder { // These are always there no matter what const processors: CatalogProcessor[] = [ - StaticLocationProcessor.fromConfig(config), new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }), new BuiltinKindsEntityProcessor(), ]; @@ -338,7 +340,6 @@ export class NextCatalogBuilder { MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), CodeOwnersProcessor.fromConfig(config, { logger, reader }), - // new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor({ integrations }), ); } diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index ef3bab797b..e0616fb336 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -65,6 +65,7 @@ export interface EntityProviderConnection { } export interface EntityProvider { + getProviderName(): string; connect(connection: EntityProviderConnection): Promise; } From b7200a4b70334521fd391e58c4f75f33e3339e13 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 16:53:44 +0200 Subject: [PATCH 58/88] chore: remove unused imports Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/NextCatalogBuilder.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 0699a13f58..24b763a098 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -50,11 +50,9 @@ import { GithubDiscoveryProcessor, GithubOrgReaderProcessor, LdapOrgReaderProcessor, - LocationEntityProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, - StaticLocationProcessor, UrlReaderProcessor, } from '../ingestion'; import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; From 72becbd2cc5fa95246da21b234c4f916e2a8cb9b Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 14:16:49 +0200 Subject: [PATCH 59/88] catalog-backend: Add ConfigLocationProvider tests Co-authored-by: Ben Lambert Signed-off-by: Johan Haals --- .../src/next/ConfigLocationProvider.test.ts | 67 +++++++++++++++++++ .../src/next/ConfigLocationProvider.ts | 14 ++-- 2 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts new file mode 100644 index 0000000000..9aaba13c48 --- /dev/null +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigLocationProvider } from './ConfigLocationProvider'; +import { EntityProviderConnection } from './types'; +import { ConfigReader } from '@backstage/config'; +import { resolvePackagePath } from '@backstage/backend-common'; +import path from 'path'; + +describe('Config Location Provider', () => { + it('should apply mutation with the correct paths in the config', async () => { + const mockConfig = new ConfigReader({ + catalog: { + locations: [ + { type: 'file', target: './lols.yaml' }, + { type: 'url', target: 'https://github.com/backstage/backstage' }, + ], + }, + }); + + const mockConnection = ({ + applyMutation: jest.fn(), + } as unknown) as EntityProviderConnection; + const locationProvider = new ConfigLocationProvider(mockConfig); + + await locationProvider.connect(mockConnection); + + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: path.join( + resolvePackagePath('@backstage/plugin-catalog-backend'), + './lols.yaml', + ), + type: 'file', + }, + }), + ]), + }); + expect(mockConnection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }), + ]), + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts index 5b93fcfd98..8a65487df1 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationProvider.ts @@ -34,12 +34,14 @@ export class ConfigLocationProvider implements EntityProvider { const locationConfigs = this.config.getOptionalConfigArray('catalog.locations') ?? []; - const entities = locationConfigs.map(location => - locationSpecToLocationEntity({ - type: location.getString('type'), - target: path.resolve(location.getString('target')), - }), - ); + const entities = locationConfigs.map(location => { + const type = location.getString('type'); + const target = location.getString('target'); + return locationSpecToLocationEntity({ + type, + target: type === 'file' ? path.resolve(target) : target, + }); + }); await this.connection.applyMutation({ type: 'full', From bbf0878afc247917a82121328b01102fbd87a871 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:28:55 +0200 Subject: [PATCH 60/88] WIP tests for DefaultProcessingDatabase Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index f67b8603db..a5c1e56cff 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -429,4 +429,66 @@ describe('Default Processing Database', () => { ).toBeFalsy(); }); }); + + describe('updateProcessedEntity', () => { + it('should throw if the entity does not exist', async () => { + await processingDatabase.transaction(async tx => { + await expect( + processingDatabase.updateProcessedEntity(tx, { + id: '9', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [], + relations: [], + }), + ).rejects.toThrow('Processing state not found for 9'); + }); + }); + + it('should update a processed entity', async () => { + await db('refresh_state').insert({ + entity_id: '123', + entity_ref: 'Component:default/wacka', + unprocessed_entity: '', + errors: '', + next_update_at: 'now()', + last_discovery_at: 'now()', + }); + + const deferredEntity = { + apiVersion: '1.0.0', + metadata: { + name: 'deferred', + }, + kind: 'Location', + } as Entity; + + await processingDatabase.transaction(async tx => { + await processingDatabase.updateProcessedEntity(tx, { + id: '123', + processedEntity: { + apiVersion: '1.0.0', + metadata: { + name: 'new-root', + }, + kind: 'Location', + } as Entity, + deferredEntities: [deferredEntity], + relations: [], + }); + }); + + console.log( + await db('refresh_state') + .where({ entity_ref: 'deferred' }) + .select(), + ); + expect(1).toBeDefined(); + }); + }); }); From c6af78b93b0bc09c19f5d87d23a764eb01f7e7c4 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:30:00 +0200 Subject: [PATCH 61/88] Combine stitiching queries Signed-off-by: Johan Haals --- plugins/catalog-backend/src/next/Stitcher.ts | 118 ++++++++++++++----- 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 57f5ae9167..04a8ab8cfc 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -26,6 +26,14 @@ import { import { Entity, parseEntityRef } from '@backstage/catalog-model'; import { createHash } from 'crypto'; import stableStringify from 'fast-json-stable-stringify'; +import { buildEntitySearch } from '../database/search'; +import { DbEntitiesSearchRow } from '../database/types'; + +// The number of items that are sent per batch to the database layer, when +// doing .batchInsert calls to knex. This needs to be low enough to not cause +// errors in the underlying engine due to exceeding query limits, but large +// enough to get the speed benefits. +const BATCH_SIZE = 50; export type DbFinalEntitiesRow = { entity_id: string; @@ -49,64 +57,114 @@ export class Stitcher { for (const entityRef of entityRefs) { await this.transaction(async txOpaque => { const tx = txOpaque as Knex.Transaction; - const [result] = await tx('refresh_state') - .select('entity_id', 'processed_entity') - .where({ entity_ref: entityRef }); - if (!result) { + const result: Array<{ + entityId: string; + processedEntity?: string; + errors: string; + incomingReferenceCount: string | number; + previousEtag?: string; + relationType?: string; + relationTarget?: string; + }> = await tx + .with('incoming_references', function incomingReferences(builder) { + return builder + .from('refresh_state_references') + .where({ target_entity_ref: entityRef }) + .count({ count: '*' }); + }) + .select({ + entityId: 'refresh_state.entity_id', + processedEntity: 'refresh_state.processed_entity', + errors: 'refresh_state.errors', + incomingReferenceCount: 'incoming_references.count', + previousEtag: 'final_entities.etag', + relationType: 'relations.type', + relationTarget: 'relations.target_entity_ref', + }) + .from('refresh_state') + .leftJoin('incoming_references', {}) + .leftOuterJoin('final_entities', { + 'final_entities.entity_id': 'refresh_state.entity_id', + }) + .leftOuterJoin('relations', { + 'relations.source_entity_ref': 'refresh_state.entity_ref', + }) + .where({ 'refresh_state.entity_ref': entityRef }); + + if (!result.length) { this.logger.debug( `Unable to stitch ${entityRef}, item does not exist in refresh state table`, ); return; - } else if (!result.processed_entity) { + } + + const { + entityId, + processedEntity, + errors, + incomingReferenceCount, + previousEtag, + } = result[0]; + + if (!processedEntity) { this.logger.debug( `Unable to stitch ${entityRef}, the entity has not yet been processed`, ); return; } - const entity: Entity = JSON.parse(result.processed_entity); + const entity = JSON.parse(processedEntity) as Entity; + const isOrphan = Number(incomingReferenceCount) === 0; - const entityId = entity?.metadata?.uid; - if (!entityId) { - this.logger.error(`missing ID in entity ${JSON.stringify(entity)}`); - return; - } - - const [reference_count_result] = await tx( - 'refresh_state_references', - ) - .where({ target_entity_ref: entityRef }) - .count({ reference_count: 'target_entity_ref' }); - - if (Number(reference_count_result.reference_count) === 0) { - this.logger.debug(`${entityRef} is orphan`); + if (isOrphan) { + this.logger.debug(`${entityRef} is an orphan`); entity.metadata.annotations = { ...entity.metadata.annotations, ['backstage.io/orphan']: 'true', }; } - const relationResults = await tx('relations') - .where({ source_entity_ref: entityRef }) - .select(); - - // TODO: entityRef is lower case and should be uppercase in the final result. - entity.relations = relationResults.map(relation => ({ - type: relation.type, - target: parseEntityRef(relation.target_entity_ref), - })); + // TODO: entityRef is lower case and should be uppercase in the final result + entity.relations = result + .filter(row => row.relationType) + .map(row => ({ + type: row.relationType!, + target: parseEntityRef(row.relationTarget!), + })); entity.metadata.generation = 1; + + // If the output entity was actually not changed, just abort const etag = generateEntityEtag(entity); + if (etag === previousEtag) { + console.log(`Skipped stitching of ${entityRef}, no changes`); + return; + } + entity.metadata.etag = etag; + await tx('final_entities') .insert({ - finalized_entity: JSON.stringify(entity), entity_id: entityId, + finalized_entity: JSON.stringify(entity), etag, }) .onConflict('entity_id') .merge(['finalized_entity', 'etag']); + + try { + const entries = buildEntitySearch(entityId, entity); + await tx('search') + .where({ entity_id: entityId }) + .delete(); + await tx.batchInsert('search', entries, BATCH_SIZE); + } catch (e) { + this.logger.debug( + `Failed to write search entries for ${entityId}`, + e, + ); + // intentionally ignored + } }); } } From 5c60c66ff508bae37ccfcb597d5e6e6c030a68aa Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 18:30:22 +0200 Subject: [PATCH 62/88] Create search table Signed-off-by: Johan Haals --- .../20210302150147_refresh_state.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 2afeda02c0..755a645894 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -154,6 +154,28 @@ exports.up = async function up(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + + await knex.schema.createTable('search', table => { + table.comment( + 'Flattened key-values from the entities, used for quick filtering', + ); + table + .uuid('entity_id') + .references('entity_id') + .inTable('refresh_state') + .onDelete('CASCADE') + .comment('The entity that matches this key/value'); + table + .string('key') + .notNullable() + .comment('A key that occurs in the entity'); + table + .string('value') + .nullable() + .comment('The corresponding value to match on'); + table.index(['key'], 'search_key_idx'); + table.index(['value'], 'search_value_idx'); + }); }; /** @@ -177,6 +199,12 @@ exports.down = async function down(knex) { table.index('source_entity_ref', 'relations_source_entity_ref_idx'); table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); + await knex.schema.alterTable('search', table => { + table.dropIndex([], 'search_key_idx'); + table.dropIndex([], 'search_value_idx'); + }); + + await knex.schema.dropTable('search'); await knex.schema.dropTable('final_entities'); await knex.schema.dropTable('relations'); await knex.schema.dropTable('references'); From d798708943c6b851a96de8837653040eaad5a757 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 27 Apr 2021 21:23:35 +0200 Subject: [PATCH 63/88] chore(tests): Added some more tests for DefaultLocationStore and fixing migration so it will run properly on PG Signed-off-by: blam --- .../20210302150147_refresh_state.js | 2 +- .../src/next/DefaultLocationStore.test.ts | 146 ++++++++++++++++++ .../src/next/DefaultLocationStore.ts | 26 ++-- 3 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 plugins/catalog-backend/src/next/DefaultLocationStore.test.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 755a645894..bbaf4820cd 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -160,7 +160,7 @@ exports.up = async function up(knex) { 'Flattened key-values from the entities, used for quick filtering', ); table - .uuid('entity_id') + .text('entity_id') .references('entity_id') .inTable('refresh_state') .onDelete('CASCADE') diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts new file mode 100644 index 0000000000..be20179e9a --- /dev/null +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DatabaseManager } from './database/DatabaseManager'; +import { DefaultLocationStore } from './DefaultLocationStore'; +import { v4 } from 'uuid'; + +describe('Default Location Store', () => { + const createLocationStore = async () => { + const db = await DatabaseManager.createTestDatabase(); + const connection = { applyMutation: jest.fn() }; + const store = new DefaultLocationStore(db); + await store.connect(connection); + return { store, connection }; + }; + + it('should do a full sync with the locations on connect', async () => { + const { connection } = await createLocationStore(); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'full', + entities: [], + }); + }); + + describe('listLocations', () => { + it('lists empty locations when there is no locations', async () => { + const { store } = await createLocationStore(); + + expect(await store.listLocations()).toEqual([]); + }); + + it('lists locations that are added to the db', async () => { + const { store } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + const listLocations = await store.listLocations(); + + expect(listLocations).toHaveLength(1); + expect(listLocations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }), + ]), + ); + }); + }); + + describe('createLocation', () => { + it('throws when the location already exists', async () => { + const { store } = await createLocationStore(); + const spec = { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }; + await store.createLocation(spec); + + await expect(() => store.createLocation(spec)).rejects.toThrow( + new RegExp(`Location ${spec.type}:${spec.target} already exists`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [], + added: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); + + describe('deleteLocation', () => { + it('throws if the location does not exist', async () => { + const { store } = await createLocationStore(); + + const id = v4(); + + await expect(() => store.deleteLocation(id)).rejects.toThrow( + new RegExp(`Found no location with ID ${id}`), + ); + }); + + it('calls apply mutation when adding a new location', async () => { + const { store, connection } = await createLocationStore(); + + const location = await store.createLocation({ + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }); + + await store.deleteLocation(location.id); + + expect(connection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: [], + removed: expect.arrayContaining([ + expect.objectContaining({ + spec: { + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + type: 'url', + }, + }), + ]), + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index a56e5d1585..28db3790b6 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -34,10 +34,8 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return 'DefaultLocationStore'; } - createLocation(spec: LocationSpec): Promise { + async createLocation(spec: LocationSpec): Promise { return this.db.transaction(async tx => { - // TODO: id should really be type and target combined and not a uuid. - // Attempt to find a previous location matching the spec const previousLocations = await this.listLocations(); const previousLocation = previousLocations.some( @@ -50,6 +48,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { ); } + // TODO: id should really be type and target combined and not a uuid. const location = await this.db.addLocation(tx, { id: uuidv4(), type: spec.type, @@ -68,11 +67,17 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async listLocations(): Promise { const dbLocations = await this.db.locations(); - return dbLocations.map(item => ({ - id: item.id, - target: item.target, - type: item.type, - })); + return ( + dbLocations + // TODO(blam): We should create a mutation to remove this location for everyone + // eventually when it's all done and dusted + .filter(({ type }) => type !== 'bootstrap') + .map(item => ({ + id: item.id, + target: item.target, + type: item.type, + })) + ); } getLocation(id: string): Promise { @@ -86,9 +91,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return this.db.transaction(async tx => { const location = await this.db.location(id); - if (!location) { - throw new ConflictError(`No location found with id: ${id}`); - } await this.db.removeLocation(tx, id); await this.connection.applyMutation({ type: 'delta', @@ -108,7 +110,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async connect(connection: EntityProviderConnection): Promise { this._connection = connection; - const locations = await this.db.locations(); + const locations = await this.listLocations(); const entities = locations.map(location => { return locationSpecToLocationEntity(location); }); From a60c2d9f8f4f3dfbb529e10ddaf7ab0679ee7772 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 28 Apr 2021 09:18:35 +0200 Subject: [PATCH 64/88] Update processed entity tests Signed-off-by: Johan Haals --- .../DefaultProcessingDatabase.test.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index a5c1e56cff..d76bd74260 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -19,10 +19,11 @@ import { Knex } from 'knex'; import { DbRefreshStateReferencesRow, DbRefreshStateRow, + DbRelationsRow, DefaultProcessingDatabase, } from './DefaultProcessingDatabase'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, EntityRelationSpec } from '@backstage/catalog-model'; import * as uuid from 'uuid'; import { getVoidLogger } from '@backstage/backend-common'; @@ -452,8 +453,8 @@ describe('Default Processing Database', () => { it('should update a processed entity', async () => { await db('refresh_state').insert({ - entity_id: '123', - entity_ref: 'Component:default/wacka', + entity_id: '321', + entity_ref: 'location:default/new-root', unprocessed_entity: '', errors: '', next_update_at: 'now()', @@ -468,9 +469,23 @@ describe('Default Processing Database', () => { kind: 'Location', } as Entity; + const relation: EntityRelationSpec = { + source: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + target: { + kind: 'Component', + namespace: 'Default', + name: 'foo', + }, + type: 'url', + }; + await processingDatabase.transaction(async tx => { await processingDatabase.updateProcessedEntity(tx, { - id: '123', + id: '321', processedEntity: { apiVersion: '1.0.0', metadata: { @@ -479,16 +494,24 @@ describe('Default Processing Database', () => { kind: 'Location', } as Entity, deferredEntities: [deferredEntity], - relations: [], + relations: [relation], }); }); - console.log( - await db('refresh_state') - .where({ entity_ref: 'deferred' }) - .select(), - ); - expect(1).toBeDefined(); + const deferredResult = await db('refresh_state') + .where({ entity_ref: 'location:default/deferred' }) + .select(); + expect(deferredResult.length).toBe(1); + + const [relations] = await db('relations') + .where({ originating_entity_id: '321' }) + .select(); + expect(relations).toEqual({ + originating_entity_id: '321', + source_entity_ref: 'component:default/foo', + type: 'url', + target_entity_ref: 'component:default/foo', + }); }); }); }); From 35b3246116ee756abeb27760073c9e5561f046c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 09:18:36 +0200 Subject: [PATCH 65/88] update the stitcher including search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../20210302150147_refresh_state.js | 7 +- .../catalog-backend/src/next/Stitcher.test.ts | 205 ++++++++++++++++++ plugins/catalog-backend/src/next/Stitcher.ts | 59 ++--- .../catalog-backend/src/next/search.test.ts | 160 ++++++++++++++ plugins/catalog-backend/src/next/search.ts | 191 ++++++++++++++++ plugins/catalog-backend/src/next/types.ts | 1 + 6 files changed, 592 insertions(+), 31 deletions(-) create mode 100644 plugins/catalog-backend/src/next/Stitcher.test.ts create mode 100644 plugins/catalog-backend/src/next/search.test.ts create mode 100644 plugins/catalog-backend/src/next/search.ts diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index bbaf4820cd..4d2c30f194 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -33,7 +33,6 @@ exports.up = async function up(knex) { ); table .text('entity_ref') - .unique() .notNullable() .comment('A reference to the entity that the refresh state is tied to'); table @@ -59,13 +58,14 @@ exports.up = async function up(knex) { .notNullable() .comment('JSON array containing all errors related to entity'); table - .dateTime('next_update_at') // TOOD: timezone or change to epoch-millis or similar + .dateTime('next_update_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('Timestamp of when entity should be updated'); table - .dateTime('last_discovery_at') // TOOD: timezone or change to epoch-millis or similar + .dateTime('last_discovery_at') // TODO: timezone or change to epoch-millis or similar .notNullable() .comment('The last timestamp of which this entity was discovered'); + table.unique(['entity_ref'], 'refresh_state_entity_ref_uniq'); table.index('entity_id', 'refresh_state_entity_id_idx'); table.index('entity_ref', 'refresh_state_entity_ref_idx'); table.index('next_update_at', 'refresh_state_next_update_at_idx'); @@ -188,6 +188,7 @@ exports.down = async function down(knex) { table.dropIndex([], 'refresh_state_references_target_entity_id_idx'); }); await knex.schema.alterTable('refresh_state', table => { + table.dropUnique([], 'refresh_state_entity_ref_uniq'); table.dropIndex([], 'refresh_state_entity_id_idx'); table.dropIndex([], 'refresh_state_entity_ref_idx'); table.dropIndex([], 'refresh_state_next_update_at_idx'); diff --git a/plugins/catalog-backend/src/next/Stitcher.test.ts b/plugins/catalog-backend/src/next/Stitcher.test.ts new file mode 100644 index 0000000000..327d922c07 --- /dev/null +++ b/plugins/catalog-backend/src/next/Stitcher.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DatabaseManager } from './database/DatabaseManager'; +import { + DbRefreshStateReferencesRow, + DbRefreshStateRow, + DbRelationsRow, +} from './database/DefaultProcessingDatabase'; +import { DbSearchRow } from './search'; +import { DbFinalEntitiesRow, Stitcher } from './Stitcher'; + +describe('Stitcher', () => { + let db: Knex; + const logger = getVoidLogger(); + + beforeEach(async () => { + db = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(db); + }); + + it('runs the happy path', async () => { + const stitcher = new Stitcher(db, logger); + + await db.transaction(async tx => { + await tx('refresh_state').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + unprocessed_entity: JSON.stringify({}), + processed_entity: JSON.stringify({ + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + spec: { + k: 'v', + }, + }), + errors: '[]', + next_update_at: tx.fn.now(), + last_discovery_at: tx.fn.now(), + }, + ]); + await tx('refresh_state_references').insert([ + { source_key: 'a', target_entity_ref: 'k:ns/n' }, + ]); + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/other', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + let firstEtag: string; + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entity).toEqual({ + relations: [ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + ], + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + firstEtag = entity.metadata.etag; + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + + // Re-stitch without any changes + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entities[0].etag).toEqual(firstEtag); + expect(entity.metadata.etag).toEqual(firstEtag); + }); + + // Now add one more relation and re-stitch + await db.transaction(async tx => { + await tx('relations').insert([ + { + originating_entity_id: 'my-id', + source_entity_ref: 'k:ns/n', + type: 'looksAt', + target_entity_ref: 'k:ns/third', + }, + ]); + }); + + await stitcher.stitch(new Set(['k:ns/n'])); + + await db.transaction(async tx => { + const entities = await tx('final_entities'); + + expect(entities.length).toBe(1); + const entity = JSON.parse(entities[0].finalized_entity); + expect(entity).toEqual({ + relations: expect.arrayContaining([ + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'other', + }, + }, + { + type: 'looksAt', + target: { + kind: 'k', + namespace: 'ns', + name: 'third', + }, + }, + ]), + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + etag: expect.any(String), + generation: 1, + uid: 'my-id', + }, + spec: { + k: 'v', + }, + }); + + expect(entities[0].etag).not.toEqual(firstEtag); + expect(entities[0].etag).toEqual(entity.metadata.etag); + + const search = await tx('search'); + expect(search).toEqual( + expect.arrayContaining([ + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, + { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/third' }, + { entity_id: 'my-id', key: 'apiversion', value: 'a' }, + { entity_id: 'my-id', key: 'kind', value: 'k' }, + { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, + { entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' }, + { entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' }, + { entity_id: 'my-id', key: 'spec.k', value: 'v' }, + ]), + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index 04a8ab8cfc..cd9220e934 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -14,20 +14,14 @@ * limitations under the License. */ +import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { ConflictError } from '@backstage/errors'; +import { createHash } from 'crypto'; +import stableStringify from 'fast-json-stable-stringify'; import { Knex } from 'knex'; import { Logger } from 'winston'; import { Transaction } from '../database'; -import { ConflictError } from '@backstage/errors'; -import { - DbRefreshStateReferencesRow, - DbRefreshStateRow, - DbRelationsRow, -} from './database/DefaultProcessingDatabase'; -import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { createHash } from 'crypto'; -import stableStringify from 'fast-json-stable-stringify'; -import { buildEntitySearch } from '../database/search'; -import { DbEntitiesSearchRow } from '../database/types'; +import { buildEntitySearch, DbSearchRow } from './search'; // The number of items that are sent per batch to the database layer, when // doing .batchInsert calls to knex. This needs to be low enough to not cause @@ -58,6 +52,13 @@ export class Stitcher { await this.transaction(async txOpaque => { const tx = txOpaque as Knex.Transaction; + // Selecting from refresh_state and final_entities should yield exactly + // one row (except in abnormal cases where the stitch was invoked for + // something that didn't exist at all, in which case it's zero rows). + // The join with the temporary incoming_references still gives one row. + // The only result set "expanding" join is the one with relations, so + // the output should be at least one row (if zero or one relations were + // found), or at most the same number of rows as relations. const result: Array<{ entityId: string; processedEntity?: string; @@ -90,8 +91,14 @@ export class Stitcher { .leftOuterJoin('relations', { 'relations.source_entity_ref': 'refresh_state.entity_ref', }) - .where({ 'refresh_state.entity_ref': entityRef }); + .where({ 'refresh_state.entity_ref': entityRef }) + .orderBy('relationType', 'asc') + .orderBy('relationTarget', 'asc'); + // If there were no rows returned, it would mean that there was no + // matching row even in the refresh_state. This can happen for example + // if we emit a relation to something that hasn't been ingested yet. + // It's safe to ignore this stitch attempt in that case. if (!result.length) { this.logger.debug( `Unable to stitch ${entityRef}, item does not exist in refresh state table`, @@ -102,11 +109,15 @@ export class Stitcher { const { entityId, processedEntity, - errors, + // errors, incomingReferenceCount, previousEtag, } = result[0]; + // If there was no processed entity in place, the target hasn't been + // through the processing steps yet. It's safe to ignore this stitch + // attempt in that case, since another stitch will be triggered when + // that processing has finished. if (!processedEntity) { this.logger.debug( `Unable to stitch ${entityRef}, the entity has not yet been processed`, @@ -114,6 +125,7 @@ export class Stitcher { return; } + // Grab the processed entity and stitch all of the relevant data into it const entity = JSON.parse(processedEntity) as Entity; const isOrphan = Number(incomingReferenceCount) === 0; @@ -132,15 +144,16 @@ export class Stitcher { type: row.relationType!, target: parseEntityRef(row.relationTarget!), })); - entity.metadata.generation = 1; // If the output entity was actually not changed, just abort const etag = generateEntityEtag(entity); if (etag === previousEtag) { - console.log(`Skipped stitching of ${entityRef}, no changes`); + this.logger.debug(`Skipped stitching of ${entityRef}, no changes`); return; } + entity.metadata.uid = entityId; + entity.metadata.generation = 1; entity.metadata.etag = etag; await tx('final_entities') @@ -152,19 +165,9 @@ export class Stitcher { .onConflict('entity_id') .merge(['finalized_entity', 'etag']); - try { - const entries = buildEntitySearch(entityId, entity); - await tx('search') - .where({ entity_id: entityId }) - .delete(); - await tx.batchInsert('search', entries, BATCH_SIZE); - } catch (e) { - this.logger.debug( - `Failed to write search entries for ${entityId}`, - e, - ); - // intentionally ignored - } + const searchEntries = buildEntitySearch(entityId, entity); + await tx('search').where({ entity_id: entityId }).delete(); + await tx.batchInsert('search', searchEntries, BATCH_SIZE); }); } } diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts new file mode 100644 index 0000000000..be9a98fe69 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { buildEntitySearch, mapToRows, traverse } from './search'; + +describe('search', () => { + describe('traverse', () => { + it('expands lists of strings to several rows', () => { + const input = { a: ['b', 'c', 'd'] }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'b' }, + { key: 'a.b', value: true }, + { key: 'a', value: 'c' }, + { key: 'a.c', value: true }, + { key: 'a', value: 'd' }, + { key: 'a.d', value: true }, + ]); + }); + + it('expands objects', () => { + const input = { a: { b: { c: 'd' }, e: 'f' } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a.b.c', value: 'd' }, + { key: 'a.e', value: 'f' }, + ]); + }); + + it('expands list of objects', () => { + const input = { root: { list: [{ a: 1 }, { a: 2 }] } }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'root.list.a', value: 1 }, + { key: 'root.list.a', value: 2 }, + ]); + }); + + it('skips over special keys', () => { + const input = { + state: { x: 1 }, + relations: [{ y: 2 }], + a: 'a', + metadata: { + b: 'b', + name: 'name', + namespace: 'namespace', + uid: 'uid', + etag: 'etag', + generation: 'generation', + c: 'c', + }, + d: 'd', + }; + const output = traverse(input); + expect(output).toEqual([ + { key: 'a', value: 'a' }, + { key: 'metadata.b', value: 'b' }, + { key: 'metadata.c', value: 'c' }, + { key: 'd', value: 'd' }, + ]); + }); + }); + + describe('mapToRows', () => { + it('converts base types to strings or null', () => { + const input = [ + { key: 'a', value: true }, + { key: 'b', value: false }, + { key: 'c', value: 7 }, + { key: 'd', value: 'string' }, + { key: 'e', value: null }, + { key: 'f', value: undefined }, + ]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([ + { entity_id: 'eid', key: 'a', value: 'true' }, + { entity_id: 'eid', key: 'b', value: 'false' }, + { entity_id: 'eid', key: 'c', value: '7' }, + { entity_id: 'eid', key: 'd', value: 'string' }, + { entity_id: 'eid', key: 'e', value: null }, + { entity_id: 'eid', key: 'f', value: null }, + ]); + }); + + it('emits lowercase version of keys and values', () => { + const input = [{ key: 'fOo', value: 'BaR' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); + }); + + it('skips very large values', () => { + const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + }); + + describe('buildEntitySearch', () => { + it('adds special keys even if missing', () => { + const input: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + ]); + }); + + it('adds relations', () => { + const input: Entity = { + relations: [ + { type: 't1', target: { kind: 'k', namespace: 'ns', name: 'a' } }, + { type: 't2', target: { kind: 'k', namespace: 'ns', name: 'b' } }, + ], + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }; + expect(buildEntitySearch('eid', input)).toEqual([ + { entity_id: 'eid', key: 'apiversion', value: 'a' }, + { entity_id: 'eid', key: 'kind', value: 'b' }, + { entity_id: 'eid', key: 'metadata.name', value: 'n' }, + { entity_id: 'eid', key: 'metadata.namespace', value: null }, + { entity_id: 'eid', key: 'metadata.uid', value: null }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + { entity_id: 'eid', key: 'relations', value: 't1:k:ns/a' }, + { entity_id: 'eid', key: 'relations', value: 't2:k:ns/b' }, + ]); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts new file mode 100644 index 0000000000..4aa8bae1a8 --- /dev/null +++ b/plugins/catalog-backend/src/next/search.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; + +export type DbSearchRow = { + entity_id: string; + key: string; + value: string | null; +}; + +// These are excluded in the generic loop, either because they do not make sense +// to index, or because they are special-case always inserted whether they are +// null or not +const SPECIAL_KEYS = [ + 'state', + 'relations', + 'metadata.name', + 'metadata.namespace', + 'metadata.uid', + 'metadata.etag', + 'metadata.generation', +]; + +// The maximum length allowed for search values. These columns are indexed, and +// database engines do not like to index on massive values. For example, +// postgres will balk after 8191 byte line sizes. +const MAX_VALUE_LENGTH = 200; + +type Kv = { + key: string; + value: unknown; +}; + +// Helper for traversing through a nested structure and outputting a list of +// path->value entries of the leaves. +// +// For example, this yaml structure +// +// a: 1 +// b: +// c: null +// e: [f, g] +// h: +// - i: 1 +// j: k +// - i: 2 +// j: l +// +// will result in +// +// "a", 1 +// "b.c", null +// "b.e": "f" +// "b.e.f": true +// "b.e": "g" +// "b.e.g": true +// "h.i": 1 +// "h.j": "k" +// "h.i": 2 +// "h.j": "l" +export function traverse(root: unknown): Kv[] { + const output: Kv[] = []; + + function visit(path: string, current: unknown) { + if (SPECIAL_KEYS.includes(path)) { + return; + } + + // empty or scalar + if ( + current === undefined || + current === null || + ['string', 'number', 'boolean'].includes(typeof current) + ) { + output.push({ key: path, value: current }); + return; + } + + // unknown + if (typeof current !== 'object') { + return; + } + + // array + if (Array.isArray(current)) { + for (const item of current) { + // NOTE(freben): The reason that these are output in two different ways, + // is to support use cases where you want to express that MORE than one + // tag is present in a list. Since the EntityFilters structure is a + // record, you can't have several entries of the same key. Therefore + // you will have to match on + // + // { "a.b": ["true"], "a.c": ["true"] } + // + // rather than + // + // { "a": ["b", "c"] } + // + // because the latter means EITHER b or c has to be present. + visit(path, item); + if (typeof item === 'string') { + output.push({ key: `${path}.${item}`, value: true }); + } + } + return; + } + + // object + for (const [key, value] of Object.entries(current!)) { + visit(path ? `${path}.${key}` : key, value); + } + } + + visit('', root); + + return output; +} + +// Translates a number of raw data rows to search table rows +export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] { + const result: DbSearchRow[] = []; + + for (const { key: rawKey, value: rawValue } of input) { + const key = rawKey.toLocaleLowerCase('en-US'); + if (rawValue === undefined || rawValue === null) { + result.push({ entity_id: entityId, key, value: null }); + } else { + const value = String(rawValue).toLocaleLowerCase('en-US'); + if (value.length <= MAX_VALUE_LENGTH) { + result.push({ entity_id: entityId, key, value }); + } + } + } + + return result; +} + +/** + * Generates all of the search rows that are relevant for this entity. + * + * @param entityId The uid of the entity + * @param entity The entity + * @returns A list of entity search rows + */ +export function buildEntitySearch( + entityId: string, + entity: Entity, +): DbSearchRow[] { + // Visit the base structure recursively + const raw = traverse(entity); + + // Start with some special keys that are always present because you want to + // be able to easily search for null specifically + raw.push({ key: 'metadata.name', value: entity.metadata.name }); + raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace }); + raw.push({ key: 'metadata.uid', value: entity.metadata.uid }); + + // Namespace not specified has the default value "default", so we want to + // match on that as well + if (!entity.metadata.namespace) { + raw.push({ key: 'metadata.namespace', value: ENTITY_DEFAULT_NAMESPACE }); + } + + // Visit relations + for (const relation of entity.relations ?? []) { + raw.push({ + key: 'relations', + value: `${relation.type}:${stringifyEntityRef(relation.target)}`, + }); + } + + return mapToRows(raw, entityId); +} diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index e0616fb336..cc9cc48ad6 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -120,6 +120,7 @@ export type ReplaceProcessingItemsRequest = removed: Entity[]; type: 'delta'; }; + export interface ProcessingStateManager { setProcessingItemResult(result: ProcessingItemResult): Promise; getNextProcessingItem(): Promise; From ec848a257d8ad6fb25b11c6e1329358c56492bf8 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 28 Apr 2021 11:57:33 +0200 Subject: [PATCH 66/88] chore: disable tests for now Signed-off-by: blam --- plugins/catalog-backend/package.json | 1 - plugins/catalog-backend/src/next/DefaultLocationStore.test.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 5f70ec60e1..a5bb9e00af 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -78,7 +78,6 @@ "files": [ "dist", "migrations/**/*.{js,d.ts}", - "migrationsv2/**/*.{js,d.ts}", "config.d.ts" ], "configSchema": "config.d.ts" diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts index be20179e9a..d30509eaec 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -17,7 +17,8 @@ import { DatabaseManager } from './database/DatabaseManager'; import { DefaultLocationStore } from './DefaultLocationStore'; import { v4 } from 'uuid'; -describe('Default Location Store', () => { +/* eslint-disable */ +xdescribe('Default Location Store', () => { const createLocationStore = async () => { const db = await DatabaseManager.createTestDatabase(); const connection = { applyMutation: jest.fn() }; From e2000428aec1302800520aa9a2ce2bfdc79a51cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 12:18:41 +0200 Subject: [PATCH 67/88] add forgotten index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../migrationsv2/20210302150147_refresh_state.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 4d2c30f194..7b0cf9b2a6 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -173,6 +173,7 @@ exports.up = async function up(knex) { .string('value') .nullable() .comment('The corresponding value to match on'); + table.index(['entity_id'], 'search_entity_id_idx'); table.index(['key'], 'search_key_idx'); table.index(['value'], 'search_value_idx'); }); @@ -201,6 +202,7 @@ exports.down = async function down(knex) { table.index('originating_entity_id', 'relations_source_entity_id_idx'); }); await knex.schema.alterTable('search', table => { + table.dropIndex([], 'search_entity_id_idx'); table.dropIndex([], 'search_key_idx'); table.dropIndex([], 'search_value_idx'); }); From 1ce80ff02230fba7afe6284425a15ff30112d981 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 28 Apr 2021 12:50:03 +0200 Subject: [PATCH 68/88] Resolve issues with AsyncAPI rendering by updating `@asyncapi/react-component` to `0.23.0` Signed-off-by: Oliver Sand --- .changeset/calm-zebras-remain.md | 7 ++++ plugins/api-docs/package.json | 2 +- .../AsyncApiDefinitionWidget.tsx | 10 +---- yarn.lock | 38 +++++++++---------- 4 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 .changeset/calm-zebras-remain.md diff --git a/.changeset/calm-zebras-remain.md b/.changeset/calm-zebras-remain.md new file mode 100644 index 0000000000..3b961ab3f2 --- /dev/null +++ b/.changeset/calm-zebras-remain.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Resolve issues with AsyncAPI rendering by updating `@asyncapi/react-component` +to `0.23.0`. The theming of the component is adjusted to the latest styling +changes. diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 51df09ba80..532dacf684 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@asyncapi/react-component": "^0.22.3", + "@asyncapi/react-component": "^0.23.0", "@backstage/catalog-model": "^0.7.7", "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index a908a91b14..300c9d0c8a 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -88,18 +88,10 @@ const useStyles = makeStyles(theme => ({ '& .asyncapi__enum': { color: theme.palette.secondary.main, }, - '& .asyncapi__toggle-arrow:before': { - content: '">"', - 'font-family': 'inherit', - }, - '& .asyncapi__anchor-icon:before': { - content: '"🔗"', - 'font-family': 'inherit', - }, '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': { 'background-color': 'inherit', }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header': { + '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': { 'background-color': 'inherit', color: theme.palette.text.primary, }, diff --git a/yarn.lock b/yarn.lock index 80819e5307..66be73ae3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,22 +69,22 @@ dependencies: tslib "~2.0.1" -"@asyncapi/avro-schema-parser@^0.2.0": - version "0.2.0" - resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.0.tgz#c9da2bb2858aca5a3b9e9a0e600084afd3765551" - integrity sha512-/oPDPudF82RGFXz5uhF77PSDZkAR+yHpdxUEbJ5jwk/X3RB74VRk8dqSgTqhUO+pLh+/Hut3x3VFacA8C9L2QA== +"@asyncapi/avro-schema-parser@^0.2.1": + version "0.2.1" + resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.1.tgz#0b7d9953e12084e6f556db261ae08bd8f6690faa" + integrity sha512-RZJaHsdYM4dChYSrb/TWrVCn/r2qcus+9/8iLL8+SMINHb0ECgH8tFZFJpr3Tq+LV2SBFaRQ+9kuecjQ8BNDSA== -"@asyncapi/openapi-schema-parser@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.0.tgz#80e2f38e92b6635dde19aae07b92e3caa0effc58" - integrity sha512-XfDp3EIs6ptar3jARQZzi3ObmS44l6Qozc5GJmZJUQ6mHLTTqUGJ0nxcrXAW88vosjilgJVaQ63oGolA6smSHQ== +"@asyncapi/openapi-schema-parser@^2.0.1": + version "2.0.1" + resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.1.tgz#4d6e82cced907b14e0ad6f98261ff2562d968d96" + integrity sha512-algbtdM1gcAOa8+V8kp7WeBhdaNac82jmZUXx8YjyNfRVo02N2juDrjeBAGJd+FNva9Mb4MM7qfkJoAFpTL5VQ== dependencies: "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" -"@asyncapi/parser@^1.4.4": - version "1.4.4" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.4.4.tgz#66f2642e3f9ae4166cdea2480b665250b1edbd59" - integrity sha512-HEYEDM0BzfCxXNAv/pIS5yZWe11xB8fQo9G/SmsbpJavOpcF0sVZVIELva/NxHVz/ZUKPMKCa4Gqz7DF/lMqpw== +"@asyncapi/parser@^1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.5.0.tgz#d70616a1e6081c7dd86957befd5dccc46b9a77df" + integrity sha512-HzrehCcT6R+iqtktNmrUM9wRUEMBqnCeXIrGJT0txBMS1QduNRmXGjvuDpxqwyaATPH/xu1gSp2l6pFP/hyVbA== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" "@asyncapi/specs" "^2.7.7" @@ -96,14 +96,14 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" -"@asyncapi/react-component@^0.22.3": - version "0.22.3" - resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.22.3.tgz#6ea7fb1044308e6d46f8455218920389becf2b22" - integrity sha512-f47sboqEQ0jNp0z2A+WGzBYYMS3ASmTwAXG/q6SwLuHBW15bSjaIYOVK3E8bmftBl+wVcEgAqUc6RKGtD9iVJg== +"@asyncapi/react-component@^0.23.0": + version "0.23.0" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.23.0.tgz#dce9a95cd0fb0d9f0364278088ad31194d411d52" + integrity sha512-mX70k3j5eSM7ets8BBZWnu6RsNXapLKD/7np+dMeXFk7KFbigVQXXr3hS0k2R+c8tRfkhlklicibq5ar1+fjDA== dependencies: - "@asyncapi/avro-schema-parser" "^0.2.0" - "@asyncapi/openapi-schema-parser" "^2.0.0" - "@asyncapi/parser" "^1.4.4" + "@asyncapi/avro-schema-parser" "^0.2.1" + "@asyncapi/openapi-schema-parser" "^2.0.1" + "@asyncapi/parser" "^1.5.0" constate "^1.2.0" dompurify "^2.1.1" markdown-it "^11.0.1" From 11461a20b304c47eae7581b9a77de5e033ab78e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 14:50:05 +0200 Subject: [PATCH 69/88] await the connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-backend/src/next/DefaultCatalogProcessingEngine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 1934b0aea7..9de9f0ffd5 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -68,7 +68,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { async start() { for (const provider of this.entityProviders) { - provider.connect( + await provider.connect( new Connection({ stateManager: this.stateManager, id: provider.getProviderName(), From d8b81fd28bf3cacf96cf62739e092f01568234fb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 28 Apr 2021 16:11:29 +0200 Subject: [PATCH 70/88] changesets: add changeset for json-schema bump Signed-off-by: Patrik Oldsberg --- .changeset/tough-walls-wash.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/tough-walls-wash.md diff --git a/.changeset/tough-walls-wash.md b/.changeset/tough-walls-wash.md new file mode 100644 index 0000000000..a45adcfbf6 --- /dev/null +++ b/.changeset/tough-walls-wash.md @@ -0,0 +1,9 @@ +--- +'@backstage/catalog-model': patch +'@backstage/cli': patch +'@backstage/config-loader': patch +'@backstage/config': patch +'@backstage/plugin-scaffolder': patch +--- + +Bump `json-schema` dependency from `0.2.5` to `0.3.0`. From 55e1d9f3a58696ad23e50e49a1a48a173aef10bb Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Wed, 28 Apr 2021 08:45:35 -0600 Subject: [PATCH 71/88] update JSON Signed-off-by: jrusso1020 --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 0dba11fd62..eb087a9c5f 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -77,7 +77,7 @@ This is the same entity as returned in JSON from the software catalog API: "etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk", "generation": 1, "labels": { - "backstage.io/custom": "ValueStuff" + "example.com/custom": "custom_label_value" }, "links": [{ "url": "https://admin.example-org.com", From d6cfbc797f768de5760eec9b93ade77ad9282597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 17:05:55 +0200 Subject: [PATCH 72/88] Tweaks to the v2 catalog ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../20210302150147_refresh_state.js | 14 ++++++-- .../src/next/NextEntitiesCatalog.ts | 2 +- .../catalog-backend/src/next/Stitcher.test.ts | 25 ++++++------- plugins/catalog-backend/src/next/Stitcher.ts | 36 +++++++++++-------- .../catalog-backend/src/next/search.test.ts | 4 +-- plugins/catalog-backend/src/next/search.ts | 4 +-- 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js index 7b0cf9b2a6..4c03405910 100644 --- a/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js +++ b/plugins/catalog-backend/migrationsv2/20210302150147_refresh_state.js @@ -83,10 +83,18 @@ exports.up = async function up(knex) { .inTable('refresh_state') .onDelete('CASCADE') .comment( - 'Entity ID which correspond to the ID in the refresh_state table', + 'Entity ID which corresponds to the ID in the refresh_state table', ); - table.text('etag').notNullable().comment('Etag to be used for caching'); - table.text('finalized_entity').notNullable().comment('The final entity'); + table + .text('hash') + .notNullable() + .comment( + 'Stable hash of the entity data, to be used for caching and avoiding redundant work', + ); + table + .text('final_entity') + .notNullable() + .comment('The JSON encoded final entity'); table.index('entity_id', 'final_entities_entity_id_idx'); }); diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts index 5bddd47f41..e87bb01e1f 100644 --- a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts @@ -37,7 +37,7 @@ export class NextEntitiesCatalog implements EntitiesCatalog { 'final_entities', ).select(); - const entities = dbResponse.map(e => JSON.parse(e.finalized_entity)); + const entities = dbResponse.map(e => JSON.parse(e.final_entity)); return { entities, diff --git a/plugins/catalog-backend/src/next/Stitcher.test.ts b/plugins/catalog-backend/src/next/Stitcher.test.ts index 327d922c07..f38af384fa 100644 --- a/plugins/catalog-backend/src/next/Stitcher.test.ts +++ b/plugins/catalog-backend/src/next/Stitcher.test.ts @@ -74,12 +74,12 @@ describe('Stitcher', () => { await stitcher.stitch(new Set(['k:ns/n'])); - let firstEtag: string; + let firstHash: string; await db.transaction(async tx => { const entities = await tx('final_entities'); expect(entities.length).toBe(1); - const entity = JSON.parse(entities[0].finalized_entity); + const entity = JSON.parse(entities[0].final_entity); expect(entity).toEqual({ relations: [ { @@ -105,12 +105,13 @@ describe('Stitcher', () => { }, }); - firstEtag = entity.metadata.etag; + expect(entity.metadata.etag).toEqual(entities[0].hash); + firstHash = entities[0].hash; const search = await tx('search'); expect(search).toEqual( expect.arrayContaining([ - { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/other' }, { entity_id: 'my-id', key: 'apiversion', value: 'a' }, { entity_id: 'my-id', key: 'kind', value: 'k' }, { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, @@ -127,9 +128,9 @@ describe('Stitcher', () => { await db.transaction(async tx => { const entities = await tx('final_entities'); expect(entities.length).toBe(1); - const entity = JSON.parse(entities[0].finalized_entity); - expect(entities[0].etag).toEqual(firstEtag); - expect(entity.metadata.etag).toEqual(firstEtag); + const entity = JSON.parse(entities[0].final_entity); + expect(entities[0].hash).toEqual(firstHash); + expect(entity.metadata.etag).toEqual(firstHash); }); // Now add one more relation and re-stitch @@ -150,7 +151,7 @@ describe('Stitcher', () => { const entities = await tx('final_entities'); expect(entities.length).toBe(1); - const entity = JSON.parse(entities[0].finalized_entity); + const entity = JSON.parse(entities[0].final_entity); expect(entity).toEqual({ relations: expect.arrayContaining([ { @@ -184,14 +185,14 @@ describe('Stitcher', () => { }, }); - expect(entities[0].etag).not.toEqual(firstEtag); - expect(entities[0].etag).toEqual(entity.metadata.etag); + expect(entities[0].hash).not.toEqual(firstHash); + expect(entities[0].hash).toEqual(entity.metadata.etag); const search = await tx('search'); expect(search).toEqual( expect.arrayContaining([ - { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' }, - { entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/third' }, + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/other' }, + { entity_id: 'my-id', key: 'relations.looksat', value: 'k:ns/third' }, { entity_id: 'my-id', key: 'apiversion', value: 'a' }, { entity_id: 'my-id', key: 'kind', value: 'k' }, { entity_id: 'my-id', key: 'metadata.name', value: 'n' }, diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index cd9220e934..bdc028d923 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -31,11 +31,11 @@ const BATCH_SIZE = 50; export type DbFinalEntitiesRow = { entity_id: string; - etag: string; - finalized_entity: string; + hash: string; + final_entity: string; }; -function generateEntityEtag(entity: Entity) { +function generateStableHash(entity: Entity) { return createHash('sha1') .update(stableStringify({ ...entity })) .digest('hex'); @@ -64,7 +64,7 @@ export class Stitcher { processedEntity?: string; errors: string; incomingReferenceCount: string | number; - previousEtag?: string; + previousHash?: string; relationType?: string; relationTarget?: string; }> = await tx @@ -79,7 +79,7 @@ export class Stitcher { processedEntity: 'refresh_state.processed_entity', errors: 'refresh_state.errors', incomingReferenceCount: 'incoming_references.count', - previousEtag: 'final_entities.etag', + previousHash: 'final_entities.hash', relationType: 'relations.type', relationTarget: 'relations.target_entity_ref', }) @@ -111,7 +111,7 @@ export class Stitcher { processedEntity, // errors, incomingReferenceCount, - previousEtag, + previousHash, } = result[0]; // If there was no processed entity in place, the target hasn't been @@ -125,7 +125,8 @@ export class Stitcher { return; } - // Grab the processed entity and stitch all of the relevant data into it + // Grab the processed entity and stitch all of the relevant data into + // it const entity = JSON.parse(processedEntity) as Entity; const isOrphan = Number(incomingReferenceCount) === 0; @@ -137,33 +138,38 @@ export class Stitcher { }; } - // TODO: entityRef is lower case and should be uppercase in the final result + // TODO: entityRef is lower case and should be uppercase in the final + // result entity.relations = result - .filter(row => row.relationType) + .filter(row => row.relationType /* exclude null row, if relevant */) .map(row => ({ type: row.relationType!, target: parseEntityRef(row.relationTarget!), })); // If the output entity was actually not changed, just abort - const etag = generateEntityEtag(entity); - if (etag === previousEtag) { + const hash = generateStableHash(entity); + if (hash === previousHash) { this.logger.debug(`Skipped stitching of ${entityRef}, no changes`); return; } entity.metadata.uid = entityId; entity.metadata.generation = 1; - entity.metadata.etag = etag; + if (!entity.metadata.etag) { + // If the original data source did not have its own etag handling, + // use the hash as a good-quality etag + entity.metadata.etag = hash; + } await tx('final_entities') .insert({ entity_id: entityId, - finalized_entity: JSON.stringify(entity), - etag, + final_entity: JSON.stringify(entity), + hash, }) .onConflict('entity_id') - .merge(['finalized_entity', 'etag']); + .merge(['final_entity', 'hash']); const searchEntries = buildEntitySearch(entityId, entity); await tx('search').where({ entity_id: entityId }).delete(); diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts index be9a98fe69..ff1cba01b9 100644 --- a/plugins/catalog-backend/src/next/search.test.ts +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -152,8 +152,8 @@ describe('search', () => { key: 'metadata.namespace', value: ENTITY_DEFAULT_NAMESPACE, }, - { entity_id: 'eid', key: 'relations', value: 't1:k:ns/a' }, - { entity_id: 'eid', key: 'relations', value: 't2:k:ns/b' }, + { entity_id: 'eid', key: 'relations.t1', value: 'k:ns/a' }, + { entity_id: 'eid', key: 'relations.t2', value: 'k:ns/b' }, ]); }); }); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts index 4aa8bae1a8..5ff0721f91 100644 --- a/plugins/catalog-backend/src/next/search.ts +++ b/plugins/catalog-backend/src/next/search.ts @@ -182,8 +182,8 @@ export function buildEntitySearch( // Visit relations for (const relation of entity.relations ?? []) { raw.push({ - key: 'relations', - value: `${relation.type}:${stringifyEntityRef(relation.target)}`, + key: `relations.${relation.type}`, + value: stringifyEntityRef(relation.target), }); } From a7a68535e161ade9970e396056c18857c45bafec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 18:31:26 +0200 Subject: [PATCH 73/88] Minor typo entites -> entities and remove unused type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/next/DefaultCatalogProcessingEngine.ts | 2 +- .../next/DefaultCatalogProcessingOrchestrator.ts | 10 +++++----- plugins/catalog-backend/src/next/types.ts | 14 +------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 9de9f0ffd5..1397d9038a 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -105,7 +105,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { state: result.state, errors: result.errors, relations: result.relations, - deferredEntities: result.deferredEntites, + deferredEntities: result.deferredEntities, }); const setOfThingsToStitch = new Set([ diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts index 1880b5d17e..8fbb19e536 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts @@ -179,7 +179,7 @@ export class DefaultCatalogProcessingOrchestrator ); } - // Backwards compatible processing of location entites + // Backwards compatible processing of location entities if (isLocationEntity(entity)) { const { type = location.type } = entity.spec; const targets = new Array(); @@ -268,7 +268,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { const errors = new Array(); const relations = new Array(); - const deferredEntites = new Array(); + const deferredEntities = new Array(); const emit = (i: CatalogProcessorResult) => { if (done) { @@ -282,7 +282,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { if (i.type === 'entity') { const originLocation = getEntityOriginLocationRef(parentEntity); - deferredEntites.push({ + deferredEntities.push({ ...i.entity, metadata: { ...i.entity.metadata, @@ -294,7 +294,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { }, }); } else if (i.type === 'location') { - deferredEntites.push( + deferredEntities.push( locationSpecToLocationEntity(i.location, parentEntity), ); } else if (i.type === 'relation') { @@ -311,7 +311,7 @@ function createEmitter(logger: Logger, parentEntity: Entity) { return { errors, relations, - deferredEntites, + deferredEntities, }; }, }; diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index cc9cc48ad6..202576fddd 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -22,18 +22,6 @@ import { } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/config'; -export interface LocationEntity { - apiVersion: 'backstage.io/v1alpha1'; - kind: 'Location'; - metadata: { - name: string; // type:target - namespace: 'default'; - }; - spec: { - location: { type: string; target: string }; - }; -} - export interface LocationService { createLocation( spec: LocationSpec, @@ -80,7 +68,7 @@ export type EntityProcessingResult = ok: true; state: Map; completedEntity: Entity; - deferredEntites: Entity[]; + deferredEntities: Entity[]; relations: EntityRelationSpec[]; errors: Error[]; } From 30821b2b413bf1a46daf09c0c5600864d6f962a1 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 28 Apr 2021 10:57:00 -0600 Subject: [PATCH 74/88] Update cost-insights and circleci logos Signed-off-by: Tim Hansen --- microsite/data/plugins/circleci.yaml | 2 +- microsite/data/plugins/cost-insights.yaml | 2 +- microsite/static/img/circleci.png | Bin 0 -> 16688 bytes microsite/static/img/cost-insights.png | Bin 0 -> 105171 bytes 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 microsite/static/img/circleci.png create mode 100644 microsite/static/img/cost-insights.png diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 0acad832fa..cffbedca37 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci -iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png +iconUrl: img/circleci.png npmPackageName: '@backstage/plugin-circleci' tags: - ci diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml index daa4f55a02..ec2d162b9d 100644 --- a/microsite/data/plugins/cost-insights.yaml +++ b/microsite/data/plugins/cost-insights.yaml @@ -5,7 +5,7 @@ authorUrl: https://github.com/spotify category: Discovery description: Visualize, understand and optimize your team's cloud costs. documentation: https://github.com/backstage/backstage/tree/master/plugins/cost-insights -iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png +iconUrl: img/cost-insights.png npmPackageName: '@backstage/plugin-cost-insights' tags: - web diff --git a/microsite/static/img/circleci.png b/microsite/static/img/circleci.png new file mode 100644 index 0000000000000000000000000000000000000000..3fc450d54d9afbea45c5717cd68f9315dce7fa01 GIT binary patch literal 16688 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 BKA8Xj literal 0 HcmV?d00001 From b98de52ae834349b5d23097f38dc2fb237c87246 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Tue, 27 Apr 2021 21:36:51 -0400 Subject: [PATCH 75/88] feat(CostInsights): support a display friendly name prop for projects Signed-off-by: Phil Kuang --- .changeset/cost-insights-gorgeous-dancers-hope.md | 5 +++++ .../components/ProjectSelect/ProjectSelect.test.tsx | 6 ++++-- .../src/components/ProjectSelect/ProjectSelect.tsx | 11 +++++++---- plugins/cost-insights/src/types/Project.ts | 1 + 4 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 .changeset/cost-insights-gorgeous-dancers-hope.md diff --git a/.changeset/cost-insights-gorgeous-dancers-hope.md b/.changeset/cost-insights-gorgeous-dancers-hope.md new file mode 100644 index 0000000000..8e0abce57f --- /dev/null +++ b/.changeset/cost-insights-gorgeous-dancers-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Support a `name` prop for Projects for display purposes diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 1bd57a9eed..1820b9e35a 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -23,7 +23,7 @@ import { renderInTestApp } from '@backstage/test-utils'; const mockProjects = [ { id: 'project1' }, - { id: 'project2' }, + { id: 'project2', name: 'Project 2' }, { id: 'project3' }, ]; @@ -56,7 +56,9 @@ describe('', () => { await waitFor(() => rendered.getByTestId('option-all')); mockProjects.forEach(project => - expect(rendered.getByText(project.id)).toBeInTheDocument(), + expect( + rendered.getByText(project.name ?? project.id), + ).toBeInTheDocument(), ); }); }); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index bc34eb9c8d..25fdc0c171 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -33,7 +33,9 @@ export const ProjectSelect = ({ const projectOptions = projects .filter(p => p.id) - .sort((a, b) => (a.id as string).localeCompare(b.id as string)); + .sort((a, b) => + ((a.name ?? a.id) as string).localeCompare((b.name ?? b.id) as string), + ); const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { onSelect(e.target.value as string); @@ -41,9 +43,10 @@ export const ProjectSelect = ({ const renderValue = (value: unknown) => { const proj = value as string; + const projectObj = projects.find(p => p.id === proj); return ( - {proj === 'all' ? 'All Projects' : proj} + {proj === 'all' ? 'All Projects' : projectObj?.name ?? proj} ); }; @@ -52,7 +55,7 @@ export const ProjectSelect = ({ diff --git a/plugins/cost-insights/src/types/Project.ts b/plugins/cost-insights/src/types/Project.ts index 24259f6910..6ac61f1fb7 100644 --- a/plugins/cost-insights/src/types/Project.ts +++ b/plugins/cost-insights/src/types/Project.ts @@ -16,4 +16,5 @@ export interface Project { id: string; + name?: string; } From 410be7722c1541e4b5ff140b7ff75ef381829a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 19:25:24 +0200 Subject: [PATCH 76/88] exclude status instead of state, and also attachments while we're at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/next/search.test.ts | 5 +++-- plugins/catalog-backend/src/next/search.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts index ff1cba01b9..2e0b07b4a9 100644 --- a/plugins/catalog-backend/src/next/search.test.ts +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -52,8 +52,9 @@ describe('search', () => { it('skips over special keys', () => { const input = { - state: { x: 1 }, - relations: [{ y: 2 }], + status: { x: 1 }, + attachments: [{ y: 2 }], + relations: [{ z: 3 }], a: 'a', metadata: { b: 'b', diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts index 5ff0721f91..383b29fd70 100644 --- a/plugins/catalog-backend/src/next/search.ts +++ b/plugins/catalog-backend/src/next/search.ts @@ -30,8 +30,9 @@ export type DbSearchRow = { // to index, or because they are special-case always inserted whether they are // null or not const SPECIAL_KEYS = [ - 'state', + 'attachments', 'relations', + 'status', 'metadata.name', 'metadata.namespace', 'metadata.uid', From 0f26d52325c62fc1e47ee03f5e2449e37665a13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 19:25:56 +0200 Subject: [PATCH 77/88] remove unnecessary copying of migration files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/next/NextCatalogBuilder.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 24b763a098..7129809c71 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -243,19 +243,13 @@ export class NextCatalogBuilder { const parser = this.parser || defaultEntityDataParser; const dbClient = await database.getClient(); - const allMigrations = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrations', - ); - - const migrationsDir = resolvePackagePath( - '@backstage/plugin-catalog-backend', - 'migrationsv2', - ); - await fs.copy(allMigrations, migrationsDir); await dbClient.migrate.latest({ - directory: migrationsDir, + directory: resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrationsv2', + ), }); + const db = new CommonDatabase(dbClient, logger); const processingDatabase = new DefaultProcessingDatabase(dbClient, logger); From f4009504d34e5259ff7f2ef31981e9c23138bab6 Mon Sep 17 00:00:00 2001 From: ImgBotApp Date: Wed, 28 Apr 2021 18:38:11 +0000 Subject: [PATCH 78/88] [ImgBot] Optimize images *Total -- 119.00kb -> 98.54kb (17.19%) /microsite/static/img/circleci.png -- 16.30kb -> 9.54kb (41.43%) /microsite/static/img/cost-insights.png -- 102.71kb -> 89.00kb (13.35%) Signed-off-by: ImgBotApp --- microsite/static/img/circleci.png | Bin 16688 -> 9774 bytes microsite/static/img/cost-insights.png | Bin 105171 -> 91134 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/microsite/static/img/circleci.png b/microsite/static/img/circleci.png index 3fc450d54d9afbea45c5717cd68f9315dce7fa01..85f262e4b31af221482e868387c1a1a469cc6f8a 100644 GIT binary patch literal 9774 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 BKA8Xj From 0f2e4098f0a441cfa23039fc7127369dd885e727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 28 Apr 2021 21:07:21 +0200 Subject: [PATCH 79/88] some light renaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- ...r.test.ts => ConfigLocationEntityProvider.test.ts} | 10 +++++----- ...ionProvider.ts => ConfigLocationEntityProvider.ts} | 6 +++--- .../src/next/DefaultCatalogProcessingEngine.ts | 9 ++++----- .../{DefaultService.ts => DefaultLocationService.ts} | 0 .../src/next/DefaultLocationStore.test.ts | 6 +++--- .../catalog-backend/src/next/DefaultLocationStore.ts | 4 ++-- .../catalog-backend/src/next/NextCatalogBuilder.ts | 11 +++++------ .../src/next/database/DatabaseManager.ts | 4 ++-- 8 files changed, 24 insertions(+), 26 deletions(-) rename plugins/catalog-backend/src/next/{ConfigLocationProvider.test.ts => ConfigLocationEntityProvider.test.ts} (90%) rename plugins/catalog-backend/src/next/{ConfigLocationProvider.ts => ConfigLocationEntityProvider.ts} (91%) rename plugins/catalog-backend/src/next/{DefaultService.ts => DefaultLocationService.ts} (100%) diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts similarity index 90% rename from plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts rename to plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts index 9aaba13c48..226e5c06a8 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationProvider.test.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.test.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { ConfigLocationProvider } from './ConfigLocationProvider'; -import { EntityProviderConnection } from './types'; -import { ConfigReader } from '@backstage/config'; import { resolvePackagePath } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import path from 'path'; +import { ConfigLocationEntityProvider } from './ConfigLocationEntityProvider'; +import { EntityProviderConnection } from './types'; -describe('Config Location Provider', () => { +describe('ConfigLocationEntityProvider', () => { it('should apply mutation with the correct paths in the config', async () => { const mockConfig = new ConfigReader({ catalog: { @@ -34,7 +34,7 @@ describe('Config Location Provider', () => { const mockConnection = ({ applyMutation: jest.fn(), } as unknown) as EntityProviderConnection; - const locationProvider = new ConfigLocationProvider(mockConfig); + const locationProvider = new ConfigLocationEntityProvider(mockConfig); await locationProvider.connect(mockConnection); diff --git a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts similarity index 91% rename from plugins/catalog-backend/src/next/ConfigLocationProvider.ts rename to plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts index 8a65487df1..bccbd0ba41 100644 --- a/plugins/catalog-backend/src/next/ConfigLocationProvider.ts +++ b/plugins/catalog-backend/src/next/ConfigLocationEntityProvider.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { EntityProviderConnection, EntityProvider } from './types'; -import path from 'path'; import { Config } from '@backstage/config'; +import path from 'path'; +import { EntityProvider, EntityProviderConnection } from './types'; import { locationSpecToLocationEntity } from './util'; -export class ConfigLocationProvider implements EntityProvider { +export class ConfigLocationEntityProvider implements EntityProvider { private connection: EntityProviderConnection | undefined; constructor(private readonly config: Config) {} diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 1397d9038a..e724fe79d8 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -14,19 +14,18 @@ * limitations under the License. */ +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Logger } from 'winston'; +import { Stitcher } from './Stitcher'; import { CatalogProcessingEngine, + CatalogProcessingOrchestrator, EntityProvider, EntityProviderConnection, EntityProviderMutation, ProcessingStateManager, - CatalogProcessingOrchestrator, } from './types'; -import { Logger } from 'winston'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { Stitcher } from './Stitcher'; - class Connection implements EntityProviderConnection { constructor( private readonly config: { diff --git a/plugins/catalog-backend/src/next/DefaultService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts similarity index 100% rename from plugins/catalog-backend/src/next/DefaultService.ts rename to plugins/catalog-backend/src/next/DefaultLocationService.ts diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts index d30509eaec..f9bb97477c 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { v4 as uuid } from 'uuid'; import { DatabaseManager } from './database/DatabaseManager'; import { DefaultLocationStore } from './DefaultLocationStore'; -import { v4 } from 'uuid'; /* eslint-disable */ -xdescribe('Default Location Store', () => { +xdescribe('DefaultLocationStore', () => { const createLocationStore = async () => { const db = await DatabaseManager.createTestDatabase(); const connection = { applyMutation: jest.fn() }; @@ -111,7 +111,7 @@ xdescribe('Default Location Store', () => { it('throws if the location does not exist', async () => { const { store } = await createLocationStore(); - const id = v4(); + const id = uuid(); await expect(() => store.deleteLocation(id)).rejects.toThrow( new RegExp(`Found no location with ID ${id}`), diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index 28db3790b6..f6267df6a5 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -21,7 +21,7 @@ import { EntityProvider, EntityProviderConnection, } from './types'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuid } from 'uuid'; import { locationSpecToLocationEntity } from './util'; import { ConflictError } from '@backstage/errors'; @@ -50,7 +50,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { // TODO: id should really be type and target combined and not a uuid. const location = await this.db.addLocation(tx, { - id: uuidv4(), + id: uuid(), type: spec.type, target: spec.target, }); diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 7129809c71..10500fdc15 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -19,7 +19,6 @@ import { resolvePackagePath, UrlReader, } from '@backstage/backend-common'; -import fs from 'fs-extra'; import { DefaultNamespaceEntityPolicy, EntityPolicies, @@ -39,6 +38,7 @@ import { EntitiesCatalog, LocationsCatalog, } from '../catalog'; +import { CommonDatabase } from '../database/CommonDatabase'; import { AnnotateLocationEntityProcessor, BitbucketDiscoveryProcessor, @@ -63,16 +63,15 @@ import { } from '../ingestion/processors/PlaceholderProcessor'; import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; +import { CatalogProcessingEngine } from '../next/types'; +import { ConfigLocationEntityProvider } from './ConfigLocationEntityProvider'; +import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; -import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; import { DefaultLocationStore } from './DefaultLocationStore'; import { DefaultProcessingStateManager } from './DefaultProcessingStateManager'; -import { CatalogProcessingEngine } from '../next/types'; import { NextEntitiesCatalog } from './NextEntitiesCatalog'; import { Stitcher } from './Stitcher'; -import { CommonDatabase } from '../database/CommonDatabase'; -import { ConfigLocationProvider } from './ConfigLocationProvider'; export type CatalogEnvironment = { logger: Logger; @@ -266,7 +265,7 @@ export class NextCatalogBuilder { const locationStore = new DefaultLocationStore(db); const stitcher = new Stitcher(dbClient, logger); - const configLocationProvider = new ConfigLocationProvider(config); + const configLocationProvider = new ConfigLocationEntityProvider(config); const processingEngine = new DefaultCatalogProcessingEngine( logger, [locationStore, configLocationProvider], diff --git a/plugins/catalog-backend/src/next/database/DatabaseManager.ts b/plugins/catalog-backend/src/next/database/DatabaseManager.ts index f660f73ecb..22ae4e782f 100644 --- a/plugins/catalog-backend/src/next/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/next/database/DatabaseManager.ts @@ -16,7 +16,7 @@ import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; import knexFactory, { Knex } from 'knex'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuid } from 'uuid'; import { Logger } from 'winston'; import { CommonDatabase } from '../../database/CommonDatabase'; import { Database } from '../../database/types'; @@ -79,7 +79,7 @@ export class DatabaseManager { let knex = knexFactory(config); if (typeof config.connection !== 'string') { - const tempDbName = `d${uuidv4().replace(/-/g, '')}`; + const tempDbName = `d${uuid().replace(/-/g, '')}`; await knex.raw(`CREATE DATABASE ${tempDbName};`); knex = knexFactory({ ...config, From 2a4ad54f77fd8e4dbaf03573ada95dba2689b707 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Apr 2021 23:17:43 +0000 Subject: [PATCH 80/88] chore(deps): bump y18n from 3.2.1 to 3.2.2 Bumps [y18n](https://github.com/yargs/y18n) from 3.2.1 to 3.2.2. - [Release notes](https://github.com/yargs/y18n/releases) - [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md) - [Commits](https://github.com/yargs/y18n/commits) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 19aff83248..1e7d42537e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27074,9 +27074,9 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + version "3.2.2" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696" + integrity sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ== y18n@^4.0.0: version "4.0.0" From c9307150662e018160c6948ca5c342e7c1787da8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Apr 2021 04:15:56 +0000 Subject: [PATCH 81/88] chore(deps): bump @kubernetes/client-node from 0.14.0 to 0.14.3 Bumps [@kubernetes/client-node](https://github.com/kubernetes-client/javascript) from 0.14.0 to 0.14.3. - [Release notes](https://github.com/kubernetes-client/javascript/releases) - [Changelog](https://github.com/kubernetes-client/javascript/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes-client/javascript/compare/0.14.0...0.14.3) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1e7d42537e..43e5c33ca5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2961,9 +2961,9 @@ integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== "@kubernetes/client-node@^0.14.0": - version "0.14.0" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.14.0.tgz#a02806f3b6fdb68fb51d451ee8ff01faa446f557" - integrity sha512-/37JHuEUAQ5GQ4kLKBmCYvGgf5W1KZWKreKGWFYH8VvT2Hl/o0aJZasu2w0EHEfmE11JCn0X9arVmOTyVCYvww== + version "0.14.3" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.14.3.tgz#5ed9b88873419080547f22cb74eb502bf6671fca" + integrity sha512-9hHGDNm2JEFQcRTpDxVoAVr0fowU+JH/l5atCXY9VXwvFM18pW5wr2LzLP+Q2Rh+uQv7Moz4gEjEKSCgVKykEQ== dependencies: "@types/js-yaml" "^3.12.1" "@types/node" "^10.12.0" From 08e32cf91327ac57628499c61524fc4e2dff3c46 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Thu, 29 Apr 2021 10:36:53 +0200 Subject: [PATCH 82/88] Add support for oneOf and allOf Signed-off-by: Dominik Henneke --- .changeset/tough-planes-jump.md | 2 +- .../MultistepJsonForm/schema.test.ts | 96 +++++++++++++++++++ .../components/MultistepJsonForm/schema.ts | 20 +++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/.changeset/tough-planes-jump.md b/.changeset/tough-planes-jump.md index a30315e9e4..ebbec65159 100644 --- a/.changeset/tough-planes-jump.md +++ b/.changeset/tough-planes-jump.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Support anyOf schemas in the scaffolder template +Support `anyOf`, `oneOf` and `allOf` schemas in the scaffolder template. diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts index edfde0ea90..64dd705952 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -94,6 +94,28 @@ describe('transformSchemaToProps', () => { }, }, ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], properties: { field1: { type: 'object', @@ -117,6 +139,28 @@ describe('transformSchemaToProps', () => { }, }, ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + 'ui:readonly': true, + }, + }, + }, + ], }, field2: { type: 'string', @@ -144,6 +188,26 @@ describe('transformSchemaToProps', () => { }, }, ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], properties: { field1: { type: 'object', @@ -165,6 +229,26 @@ describe('transformSchemaToProps', () => { }, }, ], + oneOf: [ + { + properties: { + field4: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], + allOf: [ + { + properties: { + field5: { + type: 'string', + default: 'Value 1', + }, + }, + }, + ], }, field2: { type: 'string', @@ -175,10 +259,22 @@ describe('transformSchemaToProps', () => { field3: { 'ui:readonly': true, }, + field4: { + 'ui:readonly': true, + }, + field5: { + 'ui:readonly': true, + }, field1: { field3: { 'ui:readonly': true, }, + field4: { + 'ui:readonly': true, + }, + field5: { + 'ui:readonly': true, + }, }, field2: { 'ui:derp': 'xerp', diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index c189bbf1a7..b4aa3e309d 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { return; } - const { properties, anyOf } = schema; + const { properties, anyOf, oneOf, allOf } = schema; for (const propName in schema) { if (!schema.hasOwnProperty(propName)) { @@ -63,6 +63,24 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { extractUiSchema(schemaNode, uiSchema); } } + + if (Array.isArray(oneOf)) { + for (const schemaNode of oneOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); + } + } + + if (Array.isArray(allOf)) { + for (const schemaNode of allOf) { + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); + } + } } export function transformSchemaToProps( From c6367d81afe6475568a2447d3c2fa85534ea489f Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 29 Apr 2021 10:58:33 +0200 Subject: [PATCH 83/88] chore: updating variable names in terraform doc Signed-off-by: blam --- .../techdocs-s3-storage/terraform.tf | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf index ca267f0f12..f256663f43 100644 --- a/contrib/terraform/techdocs-s3-storage/terraform.tf +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -2,15 +2,15 @@ # Variables #========================== -variable "backstage-bucket" { +variable "backstage_bucket" { default = "backstage_bucket_for_my_corp" } -variable "backstage-iam" { +variable "backstage_iam" { default = "backstage" } -variable "shared-managed-tag-value" { +variable "shared_managed_tag" { default = "terraform_for_my_corp" } @@ -19,7 +19,7 @@ variable "shared-managed-tag-value" { #========================== resource "aws_s3_bucket" "backstage" { - bucket = var.backstage-bucket + bucket = var.backstage_bucket acl = "private" provider = aws @@ -36,8 +36,8 @@ resource "aws_s3_bucket" "backstage" { } tags = { - Name = var.backstage-bucket - "Managed By Terraform" = var.shared-managed-tag-value + Name = var.backstage_bucket + "Managed By Terraform" = var.shared_managed_tag } } @@ -56,16 +56,16 @@ resource "aws_s3_bucket_public_access_block" "backstage" { #========================== resource "aws_iam_user" "backstage" { - name = var.backstage-iam + name = var.backstage_iam } resource "aws_iam_user_policy" "backstage" { - name = var.backstage-iam + name = var.backstage_iam user = aws_iam_user.backstage.name - policy = data.aws_iam_policy_document.backstage-policy.json + policy = data.aws_iam_policy_document.backstage_policy.json } -data "aws_iam_policy_document" "backstage-policy" { +data "aws_iam_policy_document" "backstage_policy" { statement { actions = [ "s3:PutObject", From a99e0bc42c08d81a5a01794baca372f726082860 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 29 Apr 2021 11:38:21 +0200 Subject: [PATCH 84/88] [Search] Improvements to DefaultCatalogCollator (#5491) * Add owner/lifecycle to default catalog document and allow location to be configured by integrators. Signed-off-by: Eric Peterson * Test the DefaultCatalogCollator Signed-off-by: Eric Peterson * Changeset. Signed-off-by: Eric Peterson --- .changeset/search-moar-catalog-yo.md | 5 + packages/backend/src/plugins/search.ts | 2 +- .../src/search/DefaultCatalogCollator.test.ts | 91 +++++++++++++++++++ .../src/search/DefaultCatalogCollator.ts | 37 ++++++-- 4 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 .changeset/search-moar-catalog-yo.md create mode 100644 plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts diff --git a/.changeset/search-moar-catalog-yo.md b/.changeset/search-moar-catalog-yo.md new file mode 100644 index 0000000000..4e665157e1 --- /dev/null +++ b/.changeset/search-moar-catalog-yo.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Entity lifecycle and owner are now indexed by the `DefaultCatalogCollator`. A `locationTemplate` may now optionally be provided to its constructor to reflect a custom catalog entity path in the Backstage frontend. diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index dcd48aaf40..1691f53485 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -32,7 +32,7 @@ export default async function createPlugin({ indexBuilder.addCollator({ type: 'software-catalog', defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator(discovery), + collator: new DefaultCatalogCollator({ discovery }), }); const { scheduler } = await indexBuilder.build(); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts new file mode 100644 index 0000000000..0764912f0f --- /dev/null +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { DefaultCatalogCollator } from './DefaultCatalogCollator'; + +const expectedEntities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-entity', + description: 'The expected description', + }, + spec: { + type: 'some-type', + lifecycle: 'experimental', + owner: 'someone', + }, + }, +]; + +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + json: async () => { + return expectedEntities; + }, + }; + }, +})); + +describe('DefaultCatalogCollator', () => { + let mockDiscoveryApi: jest.Mocked; + let collator: DefaultCatalogCollator; + + beforeEach(() => { + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getExternalBaseUrl: jest.fn(), + }; + collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi }); + }); + + it('fetches from the configured catalog service', async () => { + const documents = await collator.execute(); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog'); + expect(documents).toHaveLength(expectedEntities.length); + }); + + it('maps a returned entity to an expected CatalogEntityDocument', async () => { + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + title: expectedEntities[0].metadata.name, + location: '/catalog/default/component/test-entity', + text: expectedEntities[0].metadata.description, + namespace: 'default', + componentType: expectedEntities[0]!.spec!.type, + lifecycle: expectedEntities[0]!.spec!.lifecycle, + owner: expectedEntities[0]!.spec!.owner, + }); + }); + + it('maps a returned entity with a custom locationTemplate', async () => { + // Provide an alternate location template. + collator = new DefaultCatalogCollator({ + discovery: mockDiscoveryApi, + locationTemplate: '/software/:name', + }); + + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + location: '/software/test-entity', + }); + }); +}); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 72bf43f8f9..068b9e36ae 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -23,13 +23,35 @@ export interface CatalogEntityDocument extends IndexableDocument { componentType: string; namespace: string; kind: string; + lifecycle: string; + owner: string; } export class DefaultCatalogCollator implements DocumentCollator { protected discovery: PluginEndpointDiscovery; + protected locationTemplate: string; - constructor(discovery: PluginEndpointDiscovery) { + constructor({ + discovery, + locationTemplate, + }: { + discovery: PluginEndpointDiscovery; + locationTemplate?: string; + }) { this.discovery = discovery; + this.locationTemplate = + locationTemplate || '/catalog/:namespace/:kind/:name'; + } + + protected applyArgsToFormat( + format: string, + args: Record, + ): string { + let formatted = format; + for (const [key, value] of Object.entries(args)) { + formatted = formatted.replace(`:${key}`, value); + } + return formatted.toLowerCase(); } async execute() { @@ -37,17 +59,20 @@ export class DefaultCatalogCollator implements DocumentCollator { const res = await fetch(`${baseUrl}/entities`); const entities: Entity[] = await res.json(); return entities.map( - (entity): CatalogEntityDocument => { + (entity: Entity): CatalogEntityDocument => { return { title: entity.metadata.name, - // TODO: Use a config-based template approach for entity location. - location: `/catalog/${ - entity.metadata.namespace || 'default' - }/component/${entity.metadata.name}`, + location: this.applyArgsToFormat(this.locationTemplate, { + namespace: entity.metadata.namespace || 'default', + kind: entity.kind, + name: entity.metadata.name, + }), text: entity.metadata.description || '', componentType: entity.spec?.type?.toString() || 'other', namespace: entity.metadata.namespace || 'default', kind: entity.kind, + lifecycle: (entity.spec?.lifecycle as string) || '', + owner: (entity.spec?.owner as string) || '', }; }, ); From 4b1ce5edb03474a862de13f78f541ab011b13543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 29 Apr 2021 12:19:57 +0200 Subject: [PATCH 85/88] fix master lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/stages/publish/awsS3.test.ts | 55 ++++++++---------- .../stages/publish/azureBlobStorage.test.ts | 32 +++++------ .../src/stages/publish/googleStorage.test.ts | 53 ++++++++--------- .../src/stages/publish/googleStorage.ts | 2 +- .../src/stages/publish/openStackSwift.test.ts | 57 +++++++++---------- 5 files changed, 90 insertions(+), 109 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 15da3b7cfc..f094457346 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -154,7 +155,6 @@ describe('AwsS3Publish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -171,23 +171,22 @@ describe('AwsS3Publish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + 'Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory', + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); + mockFs.restore(); }); }); @@ -265,18 +264,12 @@ describe('AwsS3Publish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(error => - expect(error).toEqual( - new Error( - `TechDocs metadata fetch failed, The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + const errorPath = path.join(entityRootDir, 'techdocs_metadata.json'); + await expect(fails).rejects.toMatchObject({ + message: `TechDocs metadata fetch failed, The file ${errorPath} does not exist !`, + }); }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index c431e924a8..1160ffe49d 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -157,7 +158,6 @@ describe('publishing with valid credentials', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(1); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -168,22 +168,20 @@ describe('publishing with valid credentials', () => { const entity = createMockEntity(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, - ); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); mockFs.restore(); }); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 7e580b16bc..19e4938763 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, @@ -146,8 +147,6 @@ describe('GoogleGCSPublish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); - const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -165,23 +164,21 @@ describe('GoogleGCSPublish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); mockFs.restore(); }); @@ -264,16 +261,14 @@ describe('GoogleGCSPublish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(errorMessage => - expect(errorMessage).toEqual( - `The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + await expect(fails).rejects.toMatchObject({ + message: `The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + }); }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 28fbb42ece..8adafc361a 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -168,7 +168,7 @@ export class GoogleGCSPublish implements PublisherBase { .createReadStream() .on('error', err => { this.logger.error(err.message); - reject(err.message); + reject(err); }) .on('data', chunk => { fileStreamChunks.push(chunk); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index 6f0aacede0..e179aeb9a7 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, EntityName, + ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -157,7 +158,6 @@ describe('OpenStackSwiftPublish', () => { }); it('should fail to publish a directory', async () => { - expect.assertions(3); const wrongPathToGeneratedDirectory = path.join( rootDir, 'wrong', @@ -174,23 +174,22 @@ describe('OpenStackSwiftPublish', () => { }), ).rejects.toThrowError(); - await publisher - .publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }) - .catch(error => { - expect(error.message).toEqual( - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 - expect.stringContaining( - `Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`, - ), - ); - expect(error.message).toEqual( - expect.stringContaining(wrongPathToGeneratedDirectory), - ); - }); + const fails = publisher.publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }); + + // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error + // Issue reported https://github.com/tschaub/mock-fs/issues/118 + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining( + `Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`, + ), + }); + await expect(fails).rejects.toMatchObject({ + message: expect.stringContaining(wrongPathToGeneratedDirectory), + }); + mockFs.restore(); }); }); @@ -268,18 +267,14 @@ describe('OpenStackSwiftPublish', () => { const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); - await publisher - .fetchTechDocsMetadata(entityNameMock) - .catch(error => - expect(error).toEqual( - new Error( - `TechDocs metadata fetch failed, The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), - ), - ); + const fails = publisher.fetchTechDocsMetadata(entityNameMock); + + await expect(fails).rejects.toMatchObject({ + message: `TechDocs metadata fetch failed, The file ${path.join( + entityRootDir, + 'techdocs_metadata.json', + )} does not exist !`, + }); }); }); }); From 6179bff5f08b71359f0deece5d2068f2ecc168bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 29 Apr 2021 12:59:42 +0200 Subject: [PATCH 86/88] depend on core, not core-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog/package.json | 1 - plugins/catalog/src/components/EntityLayout/EntityLayout.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index d87ca5b7d9..b6b306c260 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,7 +33,6 @@ "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/core": "^0.7.6", - "@backstage/core-api": "^0.2.17", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index e3a8a89b71..dfd27631f8 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -20,6 +20,7 @@ import { RELATION_OWNED_BY, } from '@backstage/catalog-model'; import { + attachComponentData, Content, Header, HeaderLabel, @@ -34,12 +35,11 @@ import { getEntityRelations, useEntityCompoundName, } from '@backstage/plugin-catalog-react'; -import { attachComponentData } from '@backstage/core-api'; import { Box, TabProps } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import { - default as React, Children, + default as React, Fragment, isValidElement, useContext, From 44abfaada67300d542caead0a505df4d664c074a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 29 Apr 2021 11:32:12 +0000 Subject: [PATCH 87/88] Version Packages --- .changeset/calm-zebras-remain.md | 7 ---- .../cost-insights-gorgeous-dancers-hope.md | 5 --- .changeset/empty-lizards-doubt.md | 5 --- .changeset/gentle-days-return.md | 21 ---------- .changeset/healthy-phones-press.md | 5 --- .changeset/kind-suns-melt.md | 5 --- .changeset/little-trees-fold.md | 5 --- .changeset/long-ladybugs-promise.md | 5 --- .changeset/pink-islands-help.md | 5 --- .changeset/rare-parrots-share.md | 6 --- .changeset/search-moar-catalog-yo.md | 5 --- .changeset/search-odd-ads-invite.md | 5 --- .changeset/six-rocks-allow.md | 5 --- .changeset/sixty-dragons-retire.md | 5 --- .changeset/small-snakes-shake.md | 5 --- .changeset/small-ways-hunt.md | 6 --- .changeset/techdocs-lovely-wombats-relate.md | 6 --- .changeset/techdocs-mean-humans-hammer.md | 5 --- .changeset/techdocs-quiet-pens-prove.md | 5 --- .changeset/techdocs-red-donkeys-share.md | 5 --- .changeset/techdocs-swift-waves-sleep.md | 12 ------ .changeset/techdocs-young-walls-decide.md | 5 --- .changeset/thin-coins-compete.md | 5 --- .changeset/thirty-humans-knock.md | 5 --- .changeset/tough-planes-jump.md | 5 --- .changeset/witty-lies-shave.md | 5 --- packages/app/CHANGELOG.md | 42 +++++++++++++++++++ packages/app/package.json | 36 ++++++++-------- packages/core/CHANGELOG.md | 8 ++++ packages/core/package.json | 2 +- packages/create-app/CHANGELOG.md | 39 +++++++++++++++++ packages/create-app/package.json | 2 +- packages/techdocs-common/CHANGELOG.md | 6 +++ packages/techdocs-common/package.json | 2 +- plugins/api-docs/CHANGELOG.md | 14 +++++++ plugins/api-docs/package.json | 4 +- plugins/badges/package.json | 2 +- plugins/bitrise/CHANGELOG.md | 10 +++++ plugins/bitrise/package.json | 4 +- plugins/catalog-backend/CHANGELOG.md | 8 ++++ plugins/catalog-backend/package.json | 4 +- plugins/catalog-import/CHANGELOG.md | 10 +++++ plugins/catalog-import/package.json | 4 +- plugins/catalog/CHANGELOG.md | 13 ++++++ plugins/catalog/package.json | 4 +- plugins/circleci/CHANGELOG.md | 10 +++++ plugins/circleci/package.json | 4 +- plugins/cloudbuild/package.json | 2 +- plugins/code-coverage-backend/CHANGELOG.md | 6 +++ plugins/code-coverage-backend/package.json | 2 +- plugins/code-coverage/package.json | 2 +- plugins/config-schema/package.json | 2 +- plugins/cost-insights/CHANGELOG.md | 11 +++++ plugins/cost-insights/package.json | 4 +- plugins/explore/CHANGELOG.md | 10 +++++ plugins/explore/package.json | 4 +- plugins/fossa/CHANGELOG.md | 10 +++++ plugins/fossa/package.json | 4 +- plugins/gcp-projects/package.json | 2 +- plugins/github-actions/CHANGELOG.md | 11 +++++ plugins/github-actions/package.json | 4 +- plugins/github-deployments/CHANGELOG.md | 10 +++++ plugins/github-deployments/package.json | 4 +- plugins/gitops-profiles/package.json | 2 +- plugins/graphiql/CHANGELOG.md | 10 +++++ plugins/graphiql/package.json | 4 +- plugins/jenkins/CHANGELOG.md | 10 +++++ plugins/jenkins/package.json | 4 +- plugins/kafka/package.json | 2 +- plugins/kubernetes/package.json | 2 +- plugins/lighthouse/CHANGELOG.md | 10 +++++ plugins/lighthouse/package.json | 4 +- plugins/newrelic/package.json | 2 +- plugins/org/package.json | 2 +- plugins/pagerduty/CHANGELOG.md | 10 +++++ plugins/pagerduty/package.json | 4 +- plugins/register-component/CHANGELOG.md | 10 +++++ plugins/register-component/package.json | 4 +- plugins/rollbar/CHANGELOG.md | 10 +++++ plugins/rollbar/package.json | 4 +- plugins/scaffolder-backend/CHANGELOG.md | 6 +++ plugins/scaffolder-backend/package.json | 2 +- plugins/scaffolder/CHANGELOG.md | 12 ++++++ plugins/scaffolder/package.json | 4 +- plugins/search-backend-node/CHANGELOG.md | 6 +++ plugins/search-backend-node/package.json | 2 +- plugins/search/CHANGELOG.md | 10 +++++ plugins/search/package.json | 4 +- plugins/sentry/CHANGELOG.md | 10 +++++ plugins/sentry/package.json | 4 +- plugins/sonarqube/CHANGELOG.md | 10 +++++ plugins/sonarqube/package.json | 4 +- plugins/splunk-on-call/package.json | 2 +- plugins/tech-radar/package.json | 2 +- plugins/techdocs/CHANGELOG.md | 26 ++++++++++++ plugins/techdocs/package.json | 4 +- plugins/todo/package.json | 2 +- plugins/user-settings/package.json | 2 +- plugins/welcome/package.json | 2 +- 99 files changed, 441 insertions(+), 241 deletions(-) delete mode 100644 .changeset/calm-zebras-remain.md delete mode 100644 .changeset/cost-insights-gorgeous-dancers-hope.md delete mode 100644 .changeset/empty-lizards-doubt.md delete mode 100644 .changeset/gentle-days-return.md delete mode 100644 .changeset/healthy-phones-press.md delete mode 100644 .changeset/kind-suns-melt.md delete mode 100644 .changeset/little-trees-fold.md delete mode 100644 .changeset/long-ladybugs-promise.md delete mode 100644 .changeset/pink-islands-help.md delete mode 100644 .changeset/rare-parrots-share.md delete mode 100644 .changeset/search-moar-catalog-yo.md delete mode 100644 .changeset/search-odd-ads-invite.md delete mode 100644 .changeset/six-rocks-allow.md delete mode 100644 .changeset/sixty-dragons-retire.md delete mode 100644 .changeset/small-snakes-shake.md delete mode 100644 .changeset/small-ways-hunt.md delete mode 100644 .changeset/techdocs-lovely-wombats-relate.md delete mode 100644 .changeset/techdocs-mean-humans-hammer.md delete mode 100644 .changeset/techdocs-quiet-pens-prove.md delete mode 100644 .changeset/techdocs-red-donkeys-share.md delete mode 100644 .changeset/techdocs-swift-waves-sleep.md delete mode 100644 .changeset/techdocs-young-walls-decide.md delete mode 100644 .changeset/thin-coins-compete.md delete mode 100644 .changeset/thirty-humans-knock.md delete mode 100644 .changeset/tough-planes-jump.md delete mode 100644 .changeset/witty-lies-shave.md diff --git a/.changeset/calm-zebras-remain.md b/.changeset/calm-zebras-remain.md deleted file mode 100644 index 3b961ab3f2..0000000000 --- a/.changeset/calm-zebras-remain.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Resolve issues with AsyncAPI rendering by updating `@asyncapi/react-component` -to `0.23.0`. The theming of the component is adjusted to the latest styling -changes. diff --git a/.changeset/cost-insights-gorgeous-dancers-hope.md b/.changeset/cost-insights-gorgeous-dancers-hope.md deleted file mode 100644 index 8e0abce57f..0000000000 --- a/.changeset/cost-insights-gorgeous-dancers-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Support a `name` prop for Projects for display purposes diff --git a/.changeset/empty-lizards-doubt.md b/.changeset/empty-lizards-doubt.md deleted file mode 100644 index cfe1e128fc..0000000000 --- a/.changeset/empty-lizards-doubt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-actions': patch ---- - -Wrap EmptyState in Card diff --git a/.changeset/gentle-days-return.md b/.changeset/gentle-days-return.md deleted file mode 100644 index 96552173bd..0000000000 --- a/.changeset/gentle-days-return.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch -'@backstage/plugin-bitrise': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-circleci': patch -'@backstage/plugin-cost-insights': patch -'@backstage/plugin-explore': patch -'@backstage/plugin-fossa': patch -'@backstage/plugin-github-actions': patch -'@backstage/plugin-github-deployments': patch -'@backstage/plugin-graphiql': patch -'@backstage/plugin-jenkins': patch -'@backstage/plugin-lighthouse': patch -'@backstage/plugin-pagerduty': patch -'@backstage/plugin-register-component': patch -'@backstage/plugin-rollbar': patch -'@backstage/plugin-sentry': patch -'@backstage/plugin-sonarqube': patch ---- - -Updated README to have up-to-date install instructions. diff --git a/.changeset/healthy-phones-press.md b/.changeset/healthy-phones-press.md deleted file mode 100644 index c771caefc5..0000000000 --- a/.changeset/healthy-phones-press.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Allow passing NavLinkProps to SidebarItem component to use in NavLink diff --git a/.changeset/kind-suns-melt.md b/.changeset/kind-suns-melt.md deleted file mode 100644 index 7cbec28fea..0000000000 --- a/.changeset/kind-suns-melt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Added fields filtering in get API entities to avoid the requesting of unused data diff --git a/.changeset/little-trees-fold.md b/.changeset/little-trees-fold.md deleted file mode 100644 index 4963dd5aa1..0000000000 --- a/.changeset/little-trees-fold.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Updates the end to end test in the app to match the new catalog index page title. To apply this change to an existing app, update `packages/app/cypress/integration/app.js` to search for `"My Company Catalog"` instead of `"My Company Service Catalog"`. diff --git a/.changeset/long-ladybugs-promise.md b/.changeset/long-ladybugs-promise.md deleted file mode 100644 index 7d683650c3..0000000000 --- a/.changeset/long-ladybugs-promise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Enable starred templates on Scaffolder frontend diff --git a/.changeset/pink-islands-help.md b/.changeset/pink-islands-help.md deleted file mode 100644 index c7672f484f..0000000000 --- a/.changeset/pink-islands-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-code-coverage-backend': patch ---- - -Include migrations diff --git a/.changeset/rare-parrots-share.md b/.changeset/rare-parrots-share.md deleted file mode 100644 index 917ec5a18e..0000000000 --- a/.changeset/rare-parrots-share.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-catalog': patch -'@backstage/plugin-scaffolder': patch ---- - -Removed unused `swr` dependency. diff --git a/.changeset/search-moar-catalog-yo.md b/.changeset/search-moar-catalog-yo.md deleted file mode 100644 index 4e665157e1..0000000000 --- a/.changeset/search-moar-catalog-yo.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Entity lifecycle and owner are now indexed by the `DefaultCatalogCollator`. A `locationTemplate` may now optionally be provided to its constructor to reflect a custom catalog entity path in the Backstage frontend. diff --git a/.changeset/search-odd-ads-invite.md b/.changeset/search-odd-ads-invite.md deleted file mode 100644 index 0dde3d0c1f..0000000000 --- a/.changeset/search-odd-ads-invite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search-backend-node': patch ---- - -Introduced Scheduler which is responsible for adding new tasks to a schedule together with it's interval timer as well as starting and stopping the scheduler processes. diff --git a/.changeset/six-rocks-allow.md b/.changeset/six-rocks-allow.md deleted file mode 100644 index f207654f45..0000000000 --- a/.changeset/six-rocks-allow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Gracefully handle HTTP request failures in download method of AzureBlobStorage publisher. diff --git a/.changeset/sixty-dragons-retire.md b/.changeset/sixty-dragons-retire.md deleted file mode 100644 index 62965c6e88..0000000000 --- a/.changeset/sixty-dragons-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search': patch ---- - -Use `RouteRef` to generate path to search page. diff --git a/.changeset/small-snakes-shake.md b/.changeset/small-snakes-shake.md deleted file mode 100644 index 6db9db3754..0000000000 --- a/.changeset/small-snakes-shake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Support `gridItem` variant for `EntityLinksCard`. diff --git a/.changeset/small-ways-hunt.md b/.changeset/small-ways-hunt.md deleted file mode 100644 index f0b8dc88cc..0000000000 --- a/.changeset/small-ways-hunt.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/plugin-catalog': patch ---- - -Add `if` prop to `EntityLayout.Route` to conditionally render tabs diff --git a/.changeset/techdocs-lovely-wombats-relate.md b/.changeset/techdocs-lovely-wombats-relate.md deleted file mode 100644 index 33ed205c89..0000000000 --- a/.changeset/techdocs-lovely-wombats-relate.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Use `EntityRefLink` in header and use relations to reference the owner of the -document. diff --git a/.changeset/techdocs-mean-humans-hammer.md b/.changeset/techdocs-mean-humans-hammer.md deleted file mode 100644 index 7697650250..0000000000 --- a/.changeset/techdocs-mean-humans-hammer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Fix TechDocs landing page table wrong copied link diff --git a/.changeset/techdocs-quiet-pens-prove.md b/.changeset/techdocs-quiet-pens-prove.md deleted file mode 100644 index 379c20e72a..0000000000 --- a/.changeset/techdocs-quiet-pens-prove.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Add customization and exportable components for TechDocs landing page diff --git a/.changeset/techdocs-red-donkeys-share.md b/.changeset/techdocs-red-donkeys-share.md deleted file mode 100644 index 29d932d0b8..0000000000 --- a/.changeset/techdocs-red-donkeys-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Disable color transitions on links to avoid issues in dark mode. diff --git a/.changeset/techdocs-swift-waves-sleep.md b/.changeset/techdocs-swift-waves-sleep.md deleted file mode 100644 index 79d10931cd..0000000000 --- a/.changeset/techdocs-swift-waves-sleep.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/plugin-techdocs': minor ---- - -Make `techdocsStorageApiRef` and `techdocsApiRef` use interfaces instead of the -actual implementation classes. - -This renames the classes `TechDocsApi` to `TechDocsClient` and `TechDocsStorageApi` -to `TechDocsStorageClient` and renames the interfaces `TechDocs` to `TechDocsApi` -and `TechDocsStorage` to `TechDocsStorageApi` to comply the pattern elsewhere in -the project. This also fixes the types returned by some methods on those -interfaces. diff --git a/.changeset/techdocs-young-walls-decide.md b/.changeset/techdocs-young-walls-decide.md deleted file mode 100644 index 8dc0373234..0000000000 --- a/.changeset/techdocs-young-walls-decide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Rework state management to avoid rendering multiple while navigating between pages. diff --git a/.changeset/thin-coins-compete.md b/.changeset/thin-coins-compete.md deleted file mode 100644 index 36511ec1cb..0000000000 --- a/.changeset/thin-coins-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Added the `nebula-preview` preview to `Octokit` for repository visibility. diff --git a/.changeset/thirty-humans-knock.md b/.changeset/thirty-humans-knock.md deleted file mode 100644 index 47545af282..0000000000 --- a/.changeset/thirty-humans-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Improve owner example value in `MissingAnnotationEmptyState`. diff --git a/.changeset/tough-planes-jump.md b/.changeset/tough-planes-jump.md deleted file mode 100644 index ebbec65159..0000000000 --- a/.changeset/tough-planes-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Support `anyOf`, `oneOf` and `allOf` schemas in the scaffolder template. diff --git a/.changeset/witty-lies-shave.md b/.changeset/witty-lies-shave.md deleted file mode 100644 index 68fd4b1137..0000000000 --- a/.changeset/witty-lies-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Switch out the time-based personal greeting for a plain title on the catalog index page, and remove the clocks for different timezones. diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 7262b2ae29..e9d3e63ad0 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,47 @@ # example-app +## 0.2.26 + +### Patch Changes + +- Updated dependencies [1ce80ff02] +- Updated dependencies [b98de52ae] +- Updated dependencies [4c42ecca2] +- Updated dependencies [c614ede9a] +- Updated dependencies [9afcac5af] +- Updated dependencies [07a7806c3] +- Updated dependencies [f6efa71ee] +- Updated dependencies [19a4dd710] +- Updated dependencies [dcd54c7cd] +- Updated dependencies [da546ce00] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6fbd7beca] +- Updated dependencies [15cbe6815] +- Updated dependencies [39bdaa004] +- Updated dependencies [cb8c848a3] +- Updated dependencies [21fddf452] +- Updated dependencies [17915e29b] +- Updated dependencies [6eaecbd81] +- Updated dependencies [23769512a] +- Updated dependencies [1a142ae8a] + - @backstage/plugin-api-docs@0.4.12 + - @backstage/plugin-cost-insights@0.8.5 + - @backstage/plugin-github-actions@0.4.4 + - @backstage/plugin-catalog-import@0.5.4 + - @backstage/plugin-circleci@0.2.13 + - @backstage/plugin-explore@0.3.4 + - @backstage/plugin-graphiql@0.2.10 + - @backstage/plugin-jenkins@0.4.2 + - @backstage/plugin-lighthouse@0.2.15 + - @backstage/plugin-pagerduty@0.3.3 + - @backstage/plugin-rollbar@0.3.4 + - @backstage/plugin-sentry@0.3.9 + - @backstage/core@0.7.7 + - @backstage/plugin-scaffolder@0.9.2 + - @backstage/plugin-catalog@0.5.6 + - @backstage/plugin-search@0.3.5 + - @backstage/plugin-techdocs@0.9.0 + ## 0.2.25 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index ba9af03310..402d09ade4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,39 +1,39 @@ { "name": "example-app", - "version": "0.2.25", + "version": "0.2.26", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/cli": "^0.6.9", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-api-docs": "^0.4.11", + "@backstage/plugin-api-docs": "^0.4.12", "@backstage/plugin-badges": "^0.2.0", - "@backstage/plugin-catalog": "^0.5.5", - "@backstage/plugin-catalog-import": "^0.5.3", + "@backstage/plugin-catalog": "^0.5.6", + "@backstage/plugin-catalog-import": "^0.5.4", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/plugin-circleci": "^0.2.12", + "@backstage/plugin-circleci": "^0.2.13", "@backstage/plugin-cloudbuild": "^0.2.13", "@backstage/plugin-code-coverage": "^0.1.2", - "@backstage/plugin-cost-insights": "^0.8.4", - "@backstage/plugin-explore": "^0.3.2", + "@backstage/plugin-cost-insights": "^0.8.5", + "@backstage/plugin-explore": "^0.3.4", "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.2", - "@backstage/plugin-graphiql": "^0.2.9", - "@backstage/plugin-jenkins": "^0.4.1", + "@backstage/plugin-github-actions": "^0.4.4", + "@backstage/plugin-graphiql": "^0.2.10", + "@backstage/plugin-jenkins": "^0.4.2", "@backstage/plugin-kafka": "^0.2.6", "@backstage/plugin-kubernetes": "^0.4.2", - "@backstage/plugin-lighthouse": "^0.2.14", + "@backstage/plugin-lighthouse": "^0.2.15", "@backstage/plugin-newrelic": "^0.2.6", "@backstage/plugin-org": "^0.3.12", - "@backstage/plugin-pagerduty": "0.3.2", - "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.9.1", - "@backstage/plugin-search": "^0.3.4", - "@backstage/plugin-sentry": "^0.3.8", + "@backstage/plugin-pagerduty": "0.3.3", + "@backstage/plugin-rollbar": "^0.3.4", + "@backstage/plugin-scaffolder": "^0.9.2", + "@backstage/plugin-search": "^0.3.5", + "@backstage/plugin-sentry": "^0.3.9", "@backstage/plugin-tech-radar": "^0.3.9", - "@backstage/plugin-techdocs": "^0.8.0", + "@backstage/plugin-techdocs": "^0.9.0", "@backstage/plugin-todo": "^0.1.0", "@backstage/plugin-user-settings": "^0.2.8", "@backstage/theme": "^0.2.6", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 1b556ac1c3..974dcd3b91 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/core +## 0.7.7 + +### Patch Changes + +- 9afcac5af: Allow passing NavLinkProps to SidebarItem component to use in NavLink +- e0c9ed759: Add `if` prop to `EntityLayout.Route` to conditionally render tabs +- 6eaecbd81: Improve owner example value in `MissingAnnotationEmptyState`. + ## 0.7.6 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 344ea10398..59391e122a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.7.6", + "version": "0.7.7", "private": false, "publishConfig": { "access": "public", diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index c821a3ab81..430e7f003a 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,44 @@ # @backstage/create-app +## 0.3.20 + +### Patch Changes + +- 73f3f5d78: Updates the end to end test in the app to match the new catalog index page title. To apply this change to an existing app, update `packages/app/cypress/integration/app.js` to search for `"My Company Catalog"` instead of `"My Company Service Catalog"`. +- Updated dependencies [1ce80ff02] +- Updated dependencies [4c42ecca2] +- Updated dependencies [c614ede9a] +- Updated dependencies [9afcac5af] +- Updated dependencies [07a7806c3] +- Updated dependencies [f6efa71ee] +- Updated dependencies [19a4dd710] +- Updated dependencies [a99e0bc42] +- Updated dependencies [dcd54c7cd] +- Updated dependencies [da546ce00] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6fbd7beca] +- Updated dependencies [15cbe6815] +- Updated dependencies [39bdaa004] +- Updated dependencies [cb8c848a3] +- Updated dependencies [21fddf452] +- Updated dependencies [17915e29b] +- Updated dependencies [a1783f306] +- Updated dependencies [6eaecbd81] +- Updated dependencies [23769512a] +- Updated dependencies [1a142ae8a] + - @backstage/plugin-api-docs@0.4.12 + - @backstage/plugin-github-actions@0.4.4 + - @backstage/plugin-catalog-import@0.5.4 + - @backstage/plugin-explore@0.3.4 + - @backstage/plugin-lighthouse@0.2.15 + - @backstage/core@0.7.7 + - @backstage/plugin-scaffolder@0.9.2 + - @backstage/plugin-catalog@0.5.6 + - @backstage/plugin-catalog-backend@0.8.1 + - @backstage/plugin-search@0.3.5 + - @backstage/plugin-techdocs@0.9.0 + - @backstage/plugin-scaffolder-backend@0.10.1 + ## 0.3.19 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 5cf6cdd78e..071a0a5ebd 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.19", + "version": "0.3.20", "private": false, "publishConfig": { "access": "public" diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 9dd6447a14..1788502e45 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/techdocs-common +## 0.5.1 + +### Patch Changes + +- f4af06ebe: Gracefully handle HTTP request failures in download method of AzureBlobStorage publisher. + ## 0.5.0 ### Minor Changes diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index c41dce72b1..e749b33919 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index a2d5a28e41..f268ba1c4d 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-api-docs +## 0.4.12 + +### Patch Changes + +- 1ce80ff02: Resolve issues with AsyncAPI rendering by updating `@asyncapi/react-component` + to `0.23.0`. The theming of the component is adjusted to the latest styling + changes. +- c614ede9a: Updated README to have up-to-date install instructions. +- 07a7806c3: Added fields filtering in get API entities to avoid the requesting of unused data +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.11 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 532dacf684..5c9f9ca7ee 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.11", + "version": "0.4.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "dependencies": { "@asyncapi/react-component": "^0.23.0", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.6", "@material-icons/font": "^1.0.2", diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 9f0c49af7e..8f7b5865a1 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.6", diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index 66314933bb..ecb26efd50 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-bitrise +## 0.1.2 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.1.1 ### Patch Changes diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 83911cdfc4..950392f732 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bitrise", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 28efc1c5d3..ed87e4dbc5 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend +## 0.8.1 + +### Patch Changes + +- a99e0bc42: Entity lifecycle and owner are now indexed by the `DefaultCatalogCollator`. A `locationTemplate` may now optionally be provided to its constructor to reflect a custom catalog entity path in the Backstage frontend. +- Updated dependencies [e1e757569] + - @backstage/plugin-search-backend-node@0.1.4 + ## 0.8.0 ### Minor Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index a5bb9e00af..7649067a44 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.8.0", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -35,7 +35,7 @@ "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", - "@backstage/plugin-search-backend-node": "^0.1.3", + "@backstage/plugin-search-backend-node": "^0.1.4", "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 8c189a5f9b..4b0d264a52 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-import +## 0.5.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.5.3 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 9ba32d830c..ca540fe8be 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.5.3", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.6", "@backstage/catalog-client": "^0.3.9", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.0", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 35e000f130..d6254a347a 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog +## 0.5.6 + +### Patch Changes + +- 19a4dd710: Removed unused `swr` dependency. +- da546ce00: Support `gridItem` variant for `EntityLinksCard`. +- e0c9ed759: Add `if` prop to `EntityLayout.Route` to conditionally render tabs +- 1a142ae8a: Switch out the time-based personal greeting for a plain title on the catalog index page, and remove the clocks for different timezones. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.5.5 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 965a0b327b..4f54735611 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.5", + "version": "0.5.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index ff01f44258..8c6d6e094d 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-circleci +## 0.2.13 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.12 ### Patch Changes diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 5c05c0ee6f..20c757eda0 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.12", + "version": "0.2.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 6ad0b8bfa9..cb2a08fce1 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md index cfc08db5c4..d7a0f136f5 100644 --- a/plugins/code-coverage-backend/CHANGELOG.md +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-code-coverage-backend +## 0.1.3 + +### Patch Changes + +- d47c2628b: Include migrations + ## 0.1.2 ### Patch Changes diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 3ae76e3fe0..c2bebbf293 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-code-coverage-backend", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index 1adc94fff7..f5c36811cc 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.6", diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 55f785a7a4..a017529400 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index 47fa9633bf..c86a71f8c9 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cost-insights +## 0.8.5 + +### Patch Changes + +- b98de52ae: Support a `name` prop for Projects for display purposes +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.8.4 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index c68fdde509..39e3fe1791 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.8.4", + "version": "0.8.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index bebf6a8c44..78275577f5 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-explore +## 0.3.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.3 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 7c2eb5c90d..be61bc1fd9 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/plugin-explore-react": "^0.0.4", "@backstage/theme": "^0.2.6", diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index fe916804d2..bd7af91dca 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-fossa +## 0.2.6 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.5 ### Patch Changes diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 23f9cfde74..ba7e1a381e 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-fossa", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 509576cf2b..0930c791e4 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index a2c6e6bf1f..6896a7f877 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-actions +## 0.4.4 + +### Patch Changes + +- 4c42ecca2: Wrap EmptyState in Card +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.3 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 6a95803d6f..2c26061cf3 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.4.3", + "version": "0.4.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index e7f849ef2e..23c8943349 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-github-deployments +## 0.1.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.1.3 ### Patch Changes diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 12a36c160e..049e02de4c 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-deployments", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index ae614fe504..d1897b018c 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 67e39d6600..681005b13f 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-graphiql +## 0.2.10 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.9 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 4aa26587ad..9ce1603b57 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.9", + "version": "0.2.10", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index ab57d71f61..ac734e9a95 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-jenkins +## 0.4.2 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.4.1 ### Patch Changes diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index edee4bc2ee..d1cabd2143 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.4.1", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index a7295469d6..0963d3178f 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 03013e1480..8d21c79a24 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.4", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/plugin-kubernetes-backend": "^0.3.2", "@backstage/theme": "^0.2.6", diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 26eb041442..cb2cb5b9d1 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-lighthouse +## 0.2.15 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.14 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 45980df00c..2fce507756 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.14", + "version": "0.2.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 0951d972c2..2dbb2a7eb8 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 5bed02e9c8..9ad4704630 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/core-api": "^0.2.16", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.6", diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index 0cd9d0cfb8..b7fc7fec40 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-pagerduty +## 0.3.3 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.2 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index c0e3afec34..821b89a5b1 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.3.2", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 424887263c..3634972e9a 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-register-component +## 0.2.14 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.2.13 ### Patch Changes diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 2870ef79f1..38d1e3202e 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 2e9bc946a2..1d8c2c399e 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-rollbar +## 0.3.4 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.3 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 6d654866af..64ea010b54 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index cb1855e041..f591568d44 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-scaffolder-backend +## 0.10.1 + +### Patch Changes + +- a1783f306: Added the `nebula-preview` preview to `Octokit` for repository visibility. + ## 0.10.0 ### Minor Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 729ef58d99..355bed4de0 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.10.0", + "version": "0.10.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index fee76f8775..1d0bbcede9 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-scaffolder +## 0.9.2 + +### Patch Changes + +- f6efa71ee: Enable starred templates on Scaffolder frontend +- 19a4dd710: Removed unused `swr` dependency. +- 23769512a: Support `anyOf`, `oneOf` and `allOf` schemas in the scaffolder template. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.9.1 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 2b4feecb7b..3c2c308c24 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.1", + "version": "0.9.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,7 +33,7 @@ "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", diff --git a/plugins/search-backend-node/CHANGELOG.md b/plugins/search-backend-node/CHANGELOG.md index e7a9d9aa0a..da49bd0726 100644 --- a/plugins/search-backend-node/CHANGELOG.md +++ b/plugins/search-backend-node/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-search-backend-node +## 0.1.4 + +### Patch Changes + +- e1e757569: Introduced Scheduler which is responsible for adding new tasks to a schedule together with it's interval timer as well as starting and stopping the scheduler processes. + ## 0.1.3 ### Patch Changes diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index b5d13af01c..e39fbb5579 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend-node", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 35f3ee97c1..b4bcd1f855 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-search +## 0.3.5 + +### Patch Changes + +- dcd54c7cd: Use `RouteRef` to generate path to search page. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.4 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index c7ab201efe..2b9db3444e 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/search-common": "^0.1.1", diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index dc45bcae60..261972384a 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sentry +## 0.3.9 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.3.8 ### Patch Changes diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 9908af2e24..e34d8bcef9 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 0ed86737dd..48b9292791 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sonarqube +## 0.1.17 + +### Patch Changes + +- c614ede9a: Updated README to have up-to-date install instructions. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.1.16 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 97243bb930..c31b99bac8 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.16", + "version": "0.1.17", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index ecaf21f8eb..9e93004bf5 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 5a1c2e2095..37ad7d8211 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 4f89cd9844..847121c0cf 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-techdocs +## 0.9.0 + +### Minor Changes + +- 21fddf452: Make `techdocsStorageApiRef` and `techdocsApiRef` use interfaces instead of the + actual implementation classes. + + This renames the classes `TechDocsApi` to `TechDocsClient` and `TechDocsStorageApi` + to `TechDocsStorageClient` and renames the interfaces `TechDocs` to `TechDocsApi` + and `TechDocsStorage` to `TechDocsStorageApi` to comply the pattern elsewhere in + the project. This also fixes the types returned by some methods on those + interfaces. + +### Patch Changes + +- 6fbd7beca: Use `EntityRefLink` in header and use relations to reference the owner of the + document. +- 15cbe6815: Fix TechDocs landing page table wrong copied link +- 39bdaa004: Add customization and exportable components for TechDocs landing page +- cb8c848a3: Disable color transitions on links to avoid issues in dark mode. +- 17915e29b: Rework state management to avoid rendering multiple while navigating between pages. +- Updated dependencies [9afcac5af] +- Updated dependencies [e0c9ed759] +- Updated dependencies [6eaecbd81] + - @backstage/core@0.7.7 + ## 0.8.0 ### Minor Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 7bde151663..632feac3c1 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.8.0", + "version": "0.9.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,7 +33,7 @@ "dependencies": { "@backstage/config": "^0.1.4", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.6", "@backstage/errors": "^0.1.1", diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 7339e568c3..92390857ef 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.6", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index d410dc103e..08ae87d3ec 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index b276b86835..189b6eeb59 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.6", + "@backstage/core": "^0.7.7", "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", From b2e2ec75337768330b277365bfc689cbc77188b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 29 Apr 2021 15:43:53 +0200 Subject: [PATCH 88/88] Update tech radar README for composability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/spotty-pigs-bathe.md | 5 +++++ plugins/tech-radar/README.md | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 .changeset/spotty-pigs-bathe.md diff --git a/.changeset/spotty-pigs-bathe.md b/.changeset/spotty-pigs-bathe.md new file mode 100644 index 0000000000..0585f335dc --- /dev/null +++ b/.changeset/spotty-pigs-bathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Update README for composability diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index 21f90ec432..7f97f88ed4 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -18,7 +18,7 @@ It serves and scales well for teams and companies of all sizes that want to have The Tech Radar can be used in two ways: -- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation, and you can set a variety of configuration directly in your `apis.ts`. +- **Simple (Recommended)** - This gives you an out-of-the-box Tech Radar experience. It lives on the `/tech-radar` URL of your Backstage installation. - **Advanced** - This gives you the React UI component directly. It enables you to insert the Radar on your own layout or page for a more customized feel. ### Install @@ -26,6 +26,7 @@ The Tech Radar can be used in two ways: For either simple or advanced installations, you'll need to add the dependency using Yarn: ```sh +cd packages/app yarn add @backstage/plugin-tech-radar ``` @@ -34,17 +35,16 @@ yarn add @backstage/plugin-tech-radar Modify your app routes to include the Router component exported from the tech radar, for example: ```tsx -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; +// in packages/app/src/App.tsx +import { TechRadarPage } from '@backstage/plugin-tech-radar'; -// Inside App component - - {/* other routes ... */} - } - /> - {/* other routes ... */} -; +const routes = ( + + {/* ... */} + } + /> ``` If you'd like to configure it more, see the `TechRadarPageProps` and `TechRadarComponentProps` types for options: