From 0b793b6717a631fa2bd7650b670834e5268e8ae6 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Date: Thu, 11 Mar 2021 11:53:13 +0100 Subject: [PATCH 001/176] Create terraform.tf Signed-off-by: Jacob Valdemar Andreasen --- .../techdocs-s3-storage/terraform.tf | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 contrib/terraform/techdocs-s3-storage/terraform.tf diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf new file mode 100644 index 0000000000..d8d57e9cd6 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -0,0 +1,73 @@ +#========================== +# Variables +#========================== + +variable "backstage" { + default = "backstage" +} + +#========================== +# Bucket +#========================== + +resource "aws_s3_bucket" "backstage" { + bucket = var.backstage + acl = "private" + provider = aws + + lifecycle { + prevent_destroy = true + } + + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } + + tags = { + Name = var.backstage + "Managed By Terraform" = var.shared-managed-tag-value + } +} + +resource "aws_s3_bucket_public_access_block" "backstage" { + bucket = aws_s3_bucket.backstage.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + + +#========================== +# IAM +#========================== + +resource "aws_iam_user" "backstage" { + name = var.backstage +} + +resource "aws_iam_user_policy" "backstage" { + name = var.backstage + user = aws_iam_user.backstage.name + policy = data.aws_iam_policy_document.backstage-policy.json +} + +data "aws_iam_policy_document" "backstage-policy" { + statement { + actions = [ + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket" + ] + effect = "Allow" + resources = [ + "${aws_s3_bucket.backstage.arn}", + "${aws_s3_bucket.backstage.arn}/*", + ] + } +} From 602cb4152285c70324e1fdcea01fed3d3565f0a4 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Date: Thu, 11 Mar 2021 11:59:45 +0100 Subject: [PATCH 002/176] Create readme.md Signed-off-by: Jacob Valdemar Andreasen --- contrib/terraform/techdocs-s3-storage/readme.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 contrib/terraform/techdocs-s3-storage/readme.md diff --git a/contrib/terraform/techdocs-s3-storage/readme.md b/contrib/terraform/techdocs-s3-storage/readme.md new file mode 100644 index 0000000000..9615cc8d13 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/readme.md @@ -0,0 +1 @@ +This terraform file should create a S3 bucket and setup IAM with a user with an inline policy which gives the user access to the bucket. After you have created the bucket, user and policy you should go to the user in the AWS console and create an access key. This access key should be used as the env variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs). From 3eb3f81a4d177ad324ac2684637c31a0af8e9dff Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Thu, 11 Mar 2021 12:22:06 +0100 Subject: [PATCH 003/176] Update documentation Signed-off-by: Jacob Valdemar Andreasen --- docs/features/techdocs/using-cloud-storage.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index fd8f320859..2f5828331d 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -120,6 +120,7 @@ techdocs: Create a dedicated AWS S3 bucket for the storage of TechDocs sites. [Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). +[Terraform example](https://github.com/backstage/backstage/blob/master/contrib/terraform/techdocs-s3-storage/terraform.tf). TechDocs will publish documentation to this bucket and will fetch files from here to serve documentation in Backstage. Note that the bucket names are @@ -142,7 +143,9 @@ variables** You should follow the [AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). -TechDocs needs access to read files and metadata of the S3 bucket. +TechDocs needs access to read files and metadata of the S3 bucket. So if you are +creating a policy for a user you want to make sure it is granted access to +ListBucket, GetObject and PutObject. If the environment variables From 65a4980f3f1afec0e7431189a6367e9581dd35c9 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Thu, 11 Mar 2021 12:30:01 +0100 Subject: [PATCH 004/176] Fix typo Signed-off-by: Jacob Valdemar Andreasen --- docs/features/techdocs/using-cloud-storage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 2f5828331d..f10b379757 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -145,7 +145,7 @@ You should follow the TechDocs needs access to read files and metadata of the S3 bucket. So if you are creating a policy for a user you want to make sure it is granted access to -ListBucket, GetObject and PutObject. +ListObjects, GetObject and PutObject. If the environment variables From f22a8edaafcba019cf4dcc3b53e0e56a81f3590c Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Wed, 17 Mar 2021 10:15:48 +0100 Subject: [PATCH 005/176] Add and change terraform variables Signed-off-by: Jacob Valdemar Andreasen --- contrib/terraform/techdocs-s3-storage/terraform.tf | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf index d8d57e9cd6..6dea091492 100644 --- a/contrib/terraform/techdocs-s3-storage/terraform.tf +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -3,9 +3,17 @@ #========================== variable "backstage" { + default = "backstage_bucket_for_my_corp" +} + +variable "backstage-iam" { default = "backstage" } +variable "shared-managed-tag-value" { + default = "terraform_for_my_corp" +} + #========================== # Bucket #========================== @@ -48,11 +56,11 @@ resource "aws_s3_bucket_public_access_block" "backstage" { #========================== resource "aws_iam_user" "backstage" { - name = var.backstage + name = var.backstage-iam } resource "aws_iam_user_policy" "backstage" { - name = var.backstage + name = var.backstage-iam user = aws_iam_user.backstage.name policy = data.aws_iam_policy_document.backstage-policy.json } From 69d9259d260be837beeec5a6483223cb5a4419f8 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Date: Thu, 11 Mar 2021 11:53:13 +0100 Subject: [PATCH 006/176] Create terraform.tf Signed-off-by: Jacob Valdemar Andreasen --- .../techdocs-s3-storage/terraform.tf | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 contrib/terraform/techdocs-s3-storage/terraform.tf diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf new file mode 100644 index 0000000000..d8d57e9cd6 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -0,0 +1,73 @@ +#========================== +# Variables +#========================== + +variable "backstage" { + default = "backstage" +} + +#========================== +# Bucket +#========================== + +resource "aws_s3_bucket" "backstage" { + bucket = var.backstage + acl = "private" + provider = aws + + lifecycle { + prevent_destroy = true + } + + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } + + tags = { + Name = var.backstage + "Managed By Terraform" = var.shared-managed-tag-value + } +} + +resource "aws_s3_bucket_public_access_block" "backstage" { + bucket = aws_s3_bucket.backstage.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + + +#========================== +# IAM +#========================== + +resource "aws_iam_user" "backstage" { + name = var.backstage +} + +resource "aws_iam_user_policy" "backstage" { + name = var.backstage + user = aws_iam_user.backstage.name + policy = data.aws_iam_policy_document.backstage-policy.json +} + +data "aws_iam_policy_document" "backstage-policy" { + statement { + actions = [ + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket" + ] + effect = "Allow" + resources = [ + "${aws_s3_bucket.backstage.arn}", + "${aws_s3_bucket.backstage.arn}/*", + ] + } +} From 74748a99a0790fb28431282809a5eda14ba4506d Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Date: Thu, 11 Mar 2021 11:59:45 +0100 Subject: [PATCH 007/176] Create readme.md Signed-off-by: Jacob Valdemar Andreasen --- contrib/terraform/techdocs-s3-storage/readme.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 contrib/terraform/techdocs-s3-storage/readme.md diff --git a/contrib/terraform/techdocs-s3-storage/readme.md b/contrib/terraform/techdocs-s3-storage/readme.md new file mode 100644 index 0000000000..9615cc8d13 --- /dev/null +++ b/contrib/terraform/techdocs-s3-storage/readme.md @@ -0,0 +1 @@ +This terraform file should create a S3 bucket and setup IAM with a user with an inline policy which gives the user access to the bucket. After you have created the bucket, user and policy you should go to the user in the AWS console and create an access key. This access key should be used as the env variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs). From 26e8254ef289ef16cb0a3ac43e7318869f3833d6 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Thu, 11 Mar 2021 12:22:06 +0100 Subject: [PATCH 008/176] Update documentation Signed-off-by: Jacob Valdemar Andreasen --- docs/features/techdocs/using-cloud-storage.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index fd8f320859..2f5828331d 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -120,6 +120,7 @@ techdocs: Create a dedicated AWS S3 bucket for the storage of TechDocs sites. [Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). +[Terraform example](https://github.com/backstage/backstage/blob/master/contrib/terraform/techdocs-s3-storage/terraform.tf). TechDocs will publish documentation to this bucket and will fetch files from here to serve documentation in Backstage. Note that the bucket names are @@ -142,7 +143,9 @@ variables** You should follow the [AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). -TechDocs needs access to read files and metadata of the S3 bucket. +TechDocs needs access to read files and metadata of the S3 bucket. So if you are +creating a policy for a user you want to make sure it is granted access to +ListBucket, GetObject and PutObject. If the environment variables From 5da1459aeae6b196203fba71b0c0473211a083d6 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Thu, 11 Mar 2021 12:30:01 +0100 Subject: [PATCH 009/176] Fix typo Signed-off-by: Jacob Valdemar Andreasen --- docs/features/techdocs/using-cloud-storage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 2f5828331d..f10b379757 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -145,7 +145,7 @@ You should follow the TechDocs needs access to read files and metadata of the S3 bucket. So if you are creating a policy for a user you want to make sure it is granted access to -ListBucket, GetObject and PutObject. +ListObjects, GetObject and PutObject. If the environment variables From ea0723fd5f4bcc656b3d219cad335341f7fec6d8 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Wed, 17 Mar 2021 10:15:48 +0100 Subject: [PATCH 010/176] Add and change terraform variables Signed-off-by: Jacob Valdemar Andreasen --- contrib/terraform/techdocs-s3-storage/terraform.tf | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf index d8d57e9cd6..6dea091492 100644 --- a/contrib/terraform/techdocs-s3-storage/terraform.tf +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -3,9 +3,17 @@ #========================== variable "backstage" { + default = "backstage_bucket_for_my_corp" +} + +variable "backstage-iam" { default = "backstage" } +variable "shared-managed-tag-value" { + default = "terraform_for_my_corp" +} + #========================== # Bucket #========================== @@ -48,11 +56,11 @@ resource "aws_s3_bucket_public_access_block" "backstage" { #========================== resource "aws_iam_user" "backstage" { - name = var.backstage + name = var.backstage-iam } resource "aws_iam_user_policy" "backstage" { - name = var.backstage + name = var.backstage-iam user = aws_iam_user.backstage.name policy = data.aws_iam_policy_document.backstage-policy.json } From c65a85afb691700ddb795f2a4b9d9dc0b2648a13 Mon Sep 17 00:00:00 2001 From: Jacob Valdemar Andreasen Date: Wed, 7 Apr 2021 12:32:43 +0200 Subject: [PATCH 011/176] Change terraform variable and fix typo in docs Signed-off-by: Jacob Valdemar Andreasen --- contrib/terraform/techdocs-s3-storage/terraform.tf | 6 +++--- docs/features/techdocs/using-cloud-storage.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contrib/terraform/techdocs-s3-storage/terraform.tf b/contrib/terraform/techdocs-s3-storage/terraform.tf index 6dea091492..ca267f0f12 100644 --- a/contrib/terraform/techdocs-s3-storage/terraform.tf +++ b/contrib/terraform/techdocs-s3-storage/terraform.tf @@ -2,7 +2,7 @@ # Variables #========================== -variable "backstage" { +variable "backstage-bucket" { default = "backstage_bucket_for_my_corp" } @@ -19,7 +19,7 @@ variable "shared-managed-tag-value" { #========================== resource "aws_s3_bucket" "backstage" { - bucket = var.backstage + bucket = var.backstage-bucket acl = "private" provider = aws @@ -36,7 +36,7 @@ resource "aws_s3_bucket" "backstage" { } tags = { - Name = var.backstage + Name = var.backstage-bucket "Managed By Terraform" = var.shared-managed-tag-value } } diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index f10b379757..2f5828331d 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -145,7 +145,7 @@ You should follow the TechDocs needs access to read files and metadata of the S3 bucket. So if you are creating a policy for a user you want to make sure it is granted access to -ListObjects, GetObject and PutObject. +ListBucket, GetObject and PutObject. If the environment variables From 5d8339cfee72a399a748e80b025f40009d409559 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 9 Apr 2021 14:26:37 +0200 Subject: [PATCH 012/176] 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 013/176] 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 9afcac5af9d579b7252b3c588e81f81da1b4f7cb Mon Sep 17 00:00:00 2001 From: Ilya Lyamkin Date: Fri, 16 Apr 2021 16:59:26 +0200 Subject: [PATCH 014/176] Pass NavLinkProps to SidebarItem Signed-off-by: Ilya Lyamkin --- .changeset/healthy-phones-press.md | 5 +++++ packages/core/src/layout/Sidebar/Items.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/healthy-phones-press.md diff --git a/.changeset/healthy-phones-press.md b/.changeset/healthy-phones-press.md new file mode 100644 index 0000000000..c771caefc5 --- /dev/null +++ b/.changeset/healthy-phones-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Allow passing NavLinkProps to SidebarItem component to use in NavLink diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index e41a45a225..7c9c0a901f 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -32,7 +32,7 @@ import React, { useContext, useState, } from 'react'; -import { NavLink } from 'react-router-dom'; +import { NavLink, NavLinkProps } from 'react-router-dom'; import { sidebarConfig, SidebarContext } from './config'; const useStyles = makeStyles(theme => { @@ -138,7 +138,7 @@ type SidebarItemButtonProps = SidebarItemBaseProps & { type SidebarItemLinkProps = SidebarItemBaseProps & { to: string; onClick?: (ev: React.MouseEvent) => void; -}; +} & NavLinkProps; type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps; @@ -156,6 +156,7 @@ export const SidebarItem = forwardRef((props, ref) => { onClick, children, className, + ...navLinkProps } = props; const classes = useStyles(); // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component @@ -216,6 +217,7 @@ export const SidebarItem = forwardRef((props, ref) => { activeClassName={classes.selected} to={props.to} ref={ref} + {...navLinkProps} > {content} From d0addca8602ad42133415786693765d88e900317 Mon Sep 17 00:00:00 2001 From: alde Date: Wed, 21 Apr 2021 13:14:46 -0400 Subject: [PATCH 015/176] [code-coverage] fix so tests work in windows Use path.resolve, and trim end of scmFiles Signed-off-by: alde --- .../src/service/converter/cobertura.test.ts | 22 ++++++++++++++++--- .../src/service/converter/cobertura.ts | 4 +++- .../src/service/converter/jacoco.test.ts | 13 ++++++++--- .../src/service/converter/jacoco.ts | 4 +++- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts b/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts index 72d4018f94..0e67d35d7a 100644 --- a/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts +++ b/plugins/code-coverage-backend/src/service/converter/cobertura.test.ts @@ -15,6 +15,7 @@ */ import { parseString } from 'xml2js'; import fs from 'fs'; +import path from 'path'; import { Cobertura } from './cobertura'; import { CoberturaXML } from './types'; import { getVoidLogger } from '@backstage/backend-common'; @@ -27,7 +28,12 @@ describe('convert cobertura', () => { let fixture: CoberturaXML; parseString( fs.readFileSync( - `${__dirname}/../__fixtures__/cobertura-testdata-${idx}.xml`, + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-testdata-${idx}.xml`, + ), ), (_e, r) => { fixture = r; @@ -36,13 +42,23 @@ describe('convert cobertura', () => { const expected = JSON.parse( fs .readFileSync( - `${__dirname}/../__fixtures__/cobertura-jsoncoverage-files-${idx}.json`, + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-jsoncoverage-files-${idx}.json`, + ), ) .toString(), ); const scmFiles = fs .readFileSync( - `${__dirname}/../__fixtures__/cobertura-sourcefiles-${idx}.txt`, + path.resolve( + __dirname, + '..', + '__fixtures__', + `cobertura-sourcefiles-${idx}.txt`, + ), ) .toString() .split('\n'); diff --git a/plugins/code-coverage-backend/src/service/converter/cobertura.ts b/plugins/code-coverage-backend/src/service/converter/cobertura.ts index 02ac187387..fcaea839ee 100644 --- a/plugins/code-coverage-backend/src/service/converter/cobertura.ts +++ b/plugins/code-coverage-backend/src/service/converter/cobertura.ts @@ -62,7 +62,9 @@ export class Cobertura implements Converter { } }); - const currentFile = scmFiles.find(f => f.endsWith(packageAndFilename)); + const currentFile = scmFiles + .map(f => f.trimEnd()) + .find(f => f.endsWith(packageAndFilename)); this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`); if ( scmFiles.length === 0 || diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts index 48202748da..0507cf3a98 100644 --- a/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts @@ -15,6 +15,7 @@ */ import { parseString } from 'xml2js'; import fs from 'fs'; +import path from 'path'; import { Jacoco } from './jacoco'; import { JacocoXML } from './types'; import { getVoidLogger } from '@backstage/backend-common'; @@ -25,7 +26,9 @@ describe('convert jacoco', () => { const converter = new Jacoco(getVoidLogger()); let fixture: JacocoXML; parseString( - fs.readFileSync(`${__dirname}/../__fixtures__/jacoco-testdata-1.xml`), + fs.readFileSync( + path.resolve(`${__dirname}/../__fixtures__/jacoco-testdata-1.xml`), + ), (_e, r) => { fixture = r; }, @@ -33,12 +36,16 @@ describe('convert jacoco', () => { const expected = JSON.parse( fs .readFileSync( - `${__dirname}/../__fixtures__/jacoco-jsoncoverage-files-1.json`, + path.resolve( + `${__dirname}/../__fixtures__/jacoco-jsoncoverage-files-1.json`, + ), ) .toString(), ); const scmFiles = fs - .readFileSync(`${__dirname}/../__fixtures__/jacoco-sourcefiles-1.txt`) + .readFileSync( + path.resolve(`${__dirname}/../__fixtures__/jacoco-sourcefiles-1.txt`), + ) .toString() .split('\n'); diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.ts index 4c554fec91..e4a41f8bc4 100644 --- a/plugins/code-coverage-backend/src/service/converter/jacoco.ts +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.ts @@ -63,7 +63,9 @@ export class Jacoco implements Converter { }); const packageAndFilename = `${packageName}/${fileName}`; - const currentFile = scmFiles.find(f => f.endsWith(packageAndFilename)); + const currentFile = scmFiles + .map(f => f.trimEnd()) + .find(f => f.endsWith(packageAndFilename)); this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`); if (Object.keys(lineHits).length > 0 && currentFile) { jscov.push({ From 55b2fc0c0f02c46a7be0122769f97a5f3cdfa240 Mon Sep 17 00:00:00 2001 From: alde Date: Wed, 21 Apr 2021 14:59:56 -0400 Subject: [PATCH 016/176] create changeset Signed-off-by: alde --- .changeset/rotten-cups-bow.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/rotten-cups-bow.md diff --git a/.changeset/rotten-cups-bow.md b/.changeset/rotten-cups-bow.md new file mode 100644 index 0000000000..967681171d --- /dev/null +++ b/.changeset/rotten-cups-bow.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-code-coverage': patch +'@backstage/plugin-code-coverage-backend': patch +--- + +Update tests to function in windows From 938b266b863271f87d416d60b0360b228c25d7de Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 21 Apr 2021 15:32:36 -0600 Subject: [PATCH 017/176] Template `spec.type` is not optional Signed-off-by: Tim Hansen --- .../software-catalog/descriptor-format.md | 19 ++++--------------- .../kinds/Template.v1alpha1.schema.json | 2 +- .../schema/kinds/Template.v1beta2.schema.json | 2 +- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index f4e1f86dd8..d3b14dd888 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -639,22 +639,11 @@ A list of strings that can be associated with the template, e.g. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. -### `spec.type` [optional] +### `spec.type` [required] -The type of component as a string, e.g. `website`. This field is optional but -recommended. - -The software catalog accepts any type value, but an organization should take -great care to establish a proper taxonomy for these. Tools including Backstage -itself may read this field and behave differently depending on its value. For -example, a website type component may present tooling in the Backstage interface -that is specific to just websites. - -The current set of well-known and common values for this field is: - -- `service` - a backend service, typically exposing an API -- `website` - a website -- `library` - a software library, such as an npm module or a Java library +The type of component created by the template, e.g. `website`. This is used for +filtering templates, and should ideally match the Component +[spec.type](#spectype-required) created by the template. ### `spec.parameters` [required] diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 5bb83d116b..53109ac7ee 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -66,7 +66,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index ba3ccb9d1d..1e0b7d0ded 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -85,7 +85,7 @@ "properties": { "type": { "type": "string", - "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", "examples": ["service", "website", "library"], "minLength": 1 }, From b9028d0f0c36726a738fd83269274136d409c86d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Apr 2021 04:32:34 +0000 Subject: [PATCH 018/176] chore(deps-dev): bump @types/jest from 26.0.15 to 26.0.22 Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 26.0.15 to 26.0.22. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) Signed-off-by: dependabot[bot] --- yarn.lock | 46 +++++----------------------------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/yarn.lock b/yarn.lock index f614a7713a..dfa07a5397 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2955,17 +2955,6 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^26.6.1": - version "26.6.1" - resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" - integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^15.0.0" - chalk "^4.0.0" - "@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" @@ -6102,9 +6091,9 @@ "@types/jest" "*" "@types/jest@*", "@types/jest@^26.0.7": - version "26.0.15" - resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" - integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== + version "26.0.22" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.22.tgz#8308a1debdf1b807aa47be2838acdcd91e88fbe6" + integrity sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw== dependencies: jest-diff "^26.0.0" pretty-format "^26.0.0" @@ -11513,11 +11502,6 @@ diff-sequences@^24.9.0: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== -diff-sequences@^26.5.0: - version "26.5.0" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" - integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== - diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -16241,17 +16225,7 @@ jest-diff@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" -jest-diff@^26.0.0: - version "26.6.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" - integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== - dependencies: - chalk "^4.0.0" - diff-sequences "^26.5.0" - jest-get-type "^26.3.0" - pretty-format "^26.6.1" - -jest-diff@^26.6.2: +jest-diff@^26.0.0, jest-diff@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== @@ -21320,17 +21294,7 @@ pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^26.0.0, pretty-format@^26.6.1: - version "26.6.1" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" - integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== - dependencies: - "@jest/types" "^26.6.1" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^17.0.1" - -pretty-format@^26.6.2: +pretty-format@^26.0.0, pretty-format@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== From 0d9d875bc91a1e7673a9a1714959cc95ae3f6528 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Apr 2021 04:33:43 +0000 Subject: [PATCH 019/176] chore(deps-dev): bump @graphql-codegen/typescript from 1.21.1 to 1.22.0 Bumps [@graphql-codegen/typescript](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typescript) from 1.21.1 to 1.22.0. - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/plugins/typescript/typescript/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/@graphql-codegen/typescript@1.22.0/packages/plugins/typescript/typescript) Signed-off-by: dependabot[bot] --- yarn.lock | 92 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/yarn.lock b/yarn.lock index f614a7713a..376441c31b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2330,27 +2330,16 @@ "@graphql-tools/utils" "^6" tslib "~2.0.1" -"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.3": - version "1.18.3" - resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.3.tgz#607a8bc16d80b30d59cd07d70de2ba803b57bc4a" - integrity sha512-+LVxWFlcZW+FB32CyvkdaMN/tIMajO42pCg0Cy8Z8ZZtGutXW1w6UggrvrEUzMZc9GHZQe49q+w7QQxeooaIlA== +"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.4", "@graphql-codegen/plugin-helpers@^1.18.5": + version "1.18.5" + resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.5.tgz#e1d875cfb6a2f7bf4b4318135f7fee6e1200f3b0" + integrity sha512-xY8dWdU4+mm+253esLYcKQIgWZEgI3spKPmMRQ+oAxlrCn9oIcdhhiMqNxa9rHS20xpZtlAjozxHulrqjFLuyA== dependencies: "@graphql-tools/utils" "^7.0.0" common-tags "1.8.0" import-from "3.0.0" lodash "~4.17.20" - tslib "~2.1.0" - -"@graphql-codegen/plugin-helpers@^1.18.4": - version "1.18.4" - resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.4.tgz#0adc4c0f88386a50b7a69d358080b6ee54fc3b16" - integrity sha512-dpfhUmn9GOS8ByoOPIN3V4Nn9HX7sl9NR7Hf26TgN6Clg7cQvkT6XjHdS2e56Q3kWrxZT1zJ1sEa67D3tj9ZtQ== - dependencies: - "@graphql-tools/utils" "^7.0.0" - common-tags "1.8.0" - import-from "3.0.0" - lodash "~4.17.20" - tslib "~2.1.0" + tslib "~2.2.0" "@graphql-codegen/typescript-resolvers@^1.17.7": version "1.18.2" @@ -2365,30 +2354,30 @@ tslib "~2.1.0" "@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.21.0": - version "1.21.1" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.21.1.tgz#9bce3254b8ef30a6bf64e57ba3991f9be7a19b53" - integrity sha512-JF6Vsu5HSv3dAoS2ca3PFLUN0qVxotex/+BgWw/6SKhtd83MUPnzJ/RU3lACg4vuNTCWeQSeGvg8x5qrw9Go9w== + version "1.22.0" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.22.0.tgz#d05be3a971e5d75a076a43e123b6330f4366a6ab" + integrity sha512-YzN/3MBYHrP110m8JgUWQIHt7Ivi3JXiq0RT5XNx/F9mVOSbZz6Ezbaji8YJA3y04Gl2f6ZgtdGazWANUvcOcg== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.3" - "@graphql-codegen/visitor-plugin-common" "^1.19.0" + "@graphql-codegen/plugin-helpers" "^1.18.5" + "@graphql-codegen/visitor-plugin-common" "^1.20.0" auto-bind "~4.0.0" - tslib "~2.1.0" + tslib "~2.2.0" -"@graphql-codegen/visitor-plugin-common@^1.18.3", "@graphql-codegen/visitor-plugin-common@^1.19.0": - version "1.19.0" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.19.0.tgz#e302dd1ba55cf220079c40fa840a355dcf81526d" - integrity sha512-Vsh9FwB90SBLnSr4KTFY8cuwjC//JBVyqn4k4usBZHFLWLkPwWzdkUKABFg6ET2gnL2L1rSU/gM30eEBrLRXFA== +"@graphql-codegen/visitor-plugin-common@^1.18.3", "@graphql-codegen/visitor-plugin-common@^1.20.0": + version "1.20.0" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.20.0.tgz#38d829eab7370c79aa5229190788f94adcae8f76" + integrity sha512-AYrpy8NA3DpvhDLqYGerQRv44S+YAMPKtwT8x9GNVjzP0gVfmqi3gG1bDWbP5sm6kOZKvDC0kTxGePuBSZerxw== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.3" + "@graphql-codegen/plugin-helpers" "^1.18.5" "@graphql-tools/optimize" "^1.0.1" "@graphql-tools/relay-operation-optimizer" "^6" array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" - change-case-all "^1.0.12" + change-case-all "1.0.14" dependency-graph "^0.11.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" - tslib "~2.1.0" + tslib "~2.2.0" "@graphql-modules/core@^0.7.17": version "0.7.17" @@ -9406,7 +9395,7 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -change-case-all@1.0.12, change-case-all@^1.0.12: +change-case-all@1.0.12: version "1.0.12" resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.12.tgz#ae3e0faf5e610e8e25c5d5eaa4a6d5c2f1d68797" integrity sha512-zdQus7R0lkprF99lrWUC5bFj6Nog4Xt4YCEjQ/vM4vbc6b5JHFBQMxRPAjfx+HJH8WxMzH0E+lQ8yQJLgmPCBg== @@ -9422,7 +9411,23 @@ change-case-all@1.0.12, change-case-all@^1.0.12: upper-case "^2.0.1" upper-case-first "^2.0.1" -change-case@^4.1.1: +change-case-all@1.0.14: + version "1.0.14" + resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.14.tgz#bac04da08ad143278d0ac3dda7eccd39280bfba1" + integrity sha512-CWVm2uT7dmSHdO/z1CXT/n47mWonyypzBbuCy5tN7uMg22BsfkhwT6oHmFCAk+gL1LOOxhdbB9SZz3J1KTY3gA== + dependencies: + change-case "^4.1.2" + is-lower-case "^2.0.2" + is-upper-case "^2.0.2" + lower-case "^2.0.2" + lower-case-first "^2.0.2" + sponge-case "^1.0.1" + swap-case "^2.0.2" + title-case "^3.0.3" + upper-case "^2.0.2" + upper-case-first "^2.0.2" + +change-case@^4.1.1, change-case@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== @@ -15715,7 +15720,7 @@ is-lambda@^1.0.1: resolved "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= -is-lower-case@^2.0.1: +is-lower-case@^2.0.1, is-lower-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a" integrity sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ== @@ -15975,7 +15980,7 @@ is-unicode-supported@^0.1.0: resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== -is-upper-case@^2.0.1: +is-upper-case@^2.0.1, is-upper-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" integrity sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ== @@ -17880,7 +17885,7 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lower-case-first@^2.0.1: +lower-case-first@^2.0.1, lower-case-first@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz#64c2324a2250bf7c37c5901e76a5b5309301160b" integrity sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg== @@ -24062,7 +24067,7 @@ split@^1.0.0: dependencies: through "2" -sponge-case@^1.0.0: +sponge-case@^1.0.0, sponge-case@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c" integrity sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA== @@ -24811,7 +24816,7 @@ swagger-ui-react@^3.37.2: xml-but-prettier "^1.0.1" zenscroll "^4.0.2" -swap-case@^2.0.1: +swap-case@^2.0.1, swap-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz#671aedb3c9c137e2985ef51c51f9e98445bf70d9" integrity sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw== @@ -25193,7 +25198,7 @@ tinycolor2@^1.4.1: resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= -title-case@^3.0.2: +title-case@^3.0.2, title-case@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== @@ -25509,16 +25514,21 @@ tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" - integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== tslib@~2.0.0, tslib@~2.0.1: version "2.0.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== +tslib@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" From 49574a8a3e7365a3c1474b787685d5956b1730cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 22 Apr 2021 09:15:36 +0200 Subject: [PATCH 020/176] Fix some spleling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/great-years-battle.md | 14 ++++++++++++++ plugins/config-schema/dev/example-schema.json | 8 ++++---- plugins/rollbar-backend/config.d.ts | 2 +- plugins/scaffolder-backend/config.d.ts | 6 +++--- .../scaffolder/actions/builtin/publish/azure.ts | 2 +- .../actions/builtin/publish/bitbucket.ts | 4 ++-- .../scaffolder/actions/builtin/publish/github.ts | 6 +++--- .../scaffolder/actions/builtin/publish/gitlab.ts | 4 ++-- 8 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 .changeset/great-years-battle.md diff --git a/.changeset/great-years-battle.md b/.changeset/great-years-battle.md new file mode 100644 index 0000000000..a2d61be11a --- /dev/null +++ b/.changeset/great-years-battle.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-rollbar-backend': patch +'@backstage/plugin-scaffolder-backend': minor +--- + +Fix some `spleling`. + +The `scaffolder-backend` has a configuration schema change that may be breaking +in rare circumstances. Due to a typo in the schema, the +`scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and +`scaffolder.bitbucket.visibility` did not get proper validation that the value +is one of the supported strings (`public`, `internal` (not available for +Bitbucket), and `private`). If you had a value that was not one of these three, +you may have to adjust your config. diff --git a/plugins/config-schema/dev/example-schema.json b/plugins/config-schema/dev/example-schema.json index d1e577ff6f..ac5c2ad347 100644 --- a/plugins/config-schema/dev/example-schema.json +++ b/plugins/config-schema/dev/example-schema.json @@ -1247,7 +1247,7 @@ "required": ["accountToken"], "properties": { "accountToken": { - "description": "The autentication token for accessing the Rollbar API", + "description": "The authentication token for accessing the Rollbar API", "type": "string" }, "organization": { @@ -1320,7 +1320,7 @@ "type": "string" }, "properties": { - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["internal", "private", "public"], "type": "string" @@ -1337,7 +1337,7 @@ "type": "string" } }, - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["internal", "private", "public"], "type": "string" @@ -1369,7 +1369,7 @@ "type": "string" } }, - "visiblity": { + "visibility": { "description": "The visibility to set on created repositories.", "enum": ["private", "public"], "type": "string" diff --git a/plugins/rollbar-backend/config.d.ts b/plugins/rollbar-backend/config.d.ts index 473b27a506..bb48f38cf2 100644 --- a/plugins/rollbar-backend/config.d.ts +++ b/plugins/rollbar-backend/config.d.ts @@ -18,7 +18,7 @@ export interface Config { /** Configuration options for the rollbar-backend plugin */ rollbar?: { /** - * The autentication token for accessing the Rollbar API + * The authentication token for accessing the Rollbar API * @see https://explorer.docs.rollbar.com/#section/Authentication */ accountToken: string; diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 3b31b4f951..aefa6154ca 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -22,14 +22,14 @@ export interface Config { /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'internal' | 'private'; + visibility?: 'public' | 'internal' | 'private'; }; gitlab?: { api: { [key: string]: string }; /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'internal' | 'private'; + visibility?: 'public' | 'internal' | 'private'; }; azure?: { baseUrl: string; @@ -40,7 +40,7 @@ export interface Config { /** * The visibility to set on created repositories. */ - visiblity?: 'public' | 'private'; + visibility?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index e1a064b3e6..36f5341bbb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -50,7 +50,7 @@ export function createPublishAzureAction(options: { }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 13f639d745..243e515b19 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -184,13 +184,13 @@ export function createPublishBitbucketAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index e58f2c68aa..3ae43c5763 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -67,13 +67,13 @@ export function createPublishGithubAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public', 'internal'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, collaborators: { @@ -180,7 +180,7 @@ export function createPublishGithubAction(options: { repo, permission: 'admin', }); - // no need to add access if it's the person who own's the personal account + // No need to add access if it's the person who owns the personal account } else if (access && access !== owner) { await client.repos.addCollaborator({ owner, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 37074d6f57..0605f88983 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -44,13 +44,13 @@ export function createPublishGitlabAction(options: { type: 'string', }, repoVisibility: { - title: 'Repository Visiblity', + title: 'Repository Visibility', type: 'string', enum: ['private', 'public', 'internal'], }, sourcePath: { title: - 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', + 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, }, From d8cc7e67a3b078d3659ce444cdaba6447cb354aa Mon Sep 17 00:00:00 2001 From: Jussi Hallila Date: Fri, 19 Feb 2021 09:47:00 +0100 Subject: [PATCH 021/176] Exposing Material-UI extension point from tab components on HeaderTabs so additional information can be added to them Signed-off-by: Jussi Hallila --- .changeset/eighty-crews-rhyme.md | 5 +++ .../components/TabbedLayout/RoutedTabs.tsx | 7 +++- .../components/TabbedLayout/TabbedLayout.tsx | 6 ++-- .../core/src/components/TabbedLayout/types.ts | 4 +++ .../src/layout/HeaderTabs/HeaderTabs.test.tsx | 32 +++++++++++++++++++ .../core/src/layout/HeaderTabs/HeaderTabs.tsx | 4 ++- 6 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 .changeset/eighty-crews-rhyme.md diff --git a/.changeset/eighty-crews-rhyme.md b/.changeset/eighty-crews-rhyme.md new file mode 100644 index 0000000000..5a8379acb6 --- /dev/null +++ b/.changeset/eighty-crews-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Exposing Material UI extension point for tabs to be able to add additional information to them diff --git a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx index b17c8afb3b..6b7b39329b 100644 --- a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -48,7 +48,12 @@ export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => { const navigate = useNavigate(); const { index, route, element } = useSelectedSubRoute(routes); const headerTabs = useMemo( - () => routes.map(t => ({ id: t.path, label: t.title })), + () => + routes.map(t => ({ + id: t.path, + label: t.title, + tabProps: t.tabProps, + })), [routes], ); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx index f181ee5980..5a1b190d00 100644 --- a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx @@ -23,11 +23,13 @@ import React, { ReactNode, } from 'react'; import { RoutedTabs } from './RoutedTabs'; +import { TabProps } from '@material-ui/core'; type SubRoute = { path: string; title: string; children: JSX.Element; + tabProps?: TabProps; }; const Route: (props: SubRoute) => null = () => null; @@ -60,8 +62,8 @@ export function createSubRoutesFromChildren( throw new Error('Child of TabbedLayout must be an TabbedLayout.Route'); } - const { path, title, children } = child.props; - return [{ path, title, children }]; + const { path, title, children, tabProps } = child.props; + return [{ path, title, children, tabProps }]; }); } diff --git a/packages/core/src/components/TabbedLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts index 29ade88dc0..24ee011933 100644 --- a/packages/core/src/components/TabbedLayout/types.ts +++ b/packages/core/src/components/TabbedLayout/types.ts @@ -14,8 +14,12 @@ * limitations under the License. */ +import { TabProps } from '@material-ui/core'; +import * as React from 'react'; + export type SubRoute = { path: string; title: string; children: JSX.Element; + tabProps?: TabProps; }; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx index 81d2123049..9a5093f6e9 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { HeaderTabs } from './'; +import { Badge, makeStyles } from '@material-ui/core'; const mockTabs = [ { id: 'overview', label: 'Overview' }, @@ -46,4 +47,35 @@ describe('', () => { 'true', ); }); + it('should render extension component to tab if one present', async () => { + const useStyles = makeStyles(() => ({ + badge: { + margin: '20px 20px 0 0', + }, + })); + + const TextualBadge = React.forwardRef((props, ref) => ( + + + {props.children} + + + )); + const iconTab = [ + { + id: 'icon-tab', + label: 'Alarms', + tabProps: { component: TextualBadge }, + }, + ]; + + const rendered = await renderInTestApp(); + + expect(rendered.getByText('Alarms')).toBeInTheDocument(); + expect(rendered.getByText('three new alarms')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index 718a103f00..1fd4a18ba3 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,7 @@ // This is just a temporary solution to implementing tabs for now import React, { useState, useEffect } from 'react'; -import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core'; +import { makeStyles, Tabs, Tab as TabUI, TabProps } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ tabsWrapper: { @@ -41,6 +41,7 @@ const useStyles = makeStyles(theme => ({ export type Tab = { id: string; label: string; + tabProps?: TabProps; }; type HeaderTabsProps = { @@ -82,6 +83,7 @@ export const HeaderTabs = ({ > {tabs.map((tab, index) => ( Date: Thu, 22 Apr 2021 13:21:33 +0200 Subject: [PATCH 022/176] Fix owner references to guest user If the user guest is used as an example value of the owner field, we should make sure to specify it as user:guest instead as the default is group. Signed-off-by: Oliver Sand --- .changeset/thirty-humans-knock.md | 5 +++++ .../components/EmptyState/MissingAnnotationEmptyState.tsx | 2 +- .../src/ingestion/processors/util/parse.test.ts | 4 ++-- plugins/github-actions/README.md | 2 +- plugins/github-actions/examples/sample.yaml | 2 +- .../examples/dice-roller/catalog-info.yaml | 2 +- .../sample-templates/pull-request/template/catalog-info.yaml | 2 +- 7 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 .changeset/thirty-humans-knock.md diff --git a/.changeset/thirty-humans-knock.md b/.changeset/thirty-humans-knock.md new file mode 100644 index 0000000000..47545af282 --- /dev/null +++ b/.changeset/thirty-humans-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Improve owner example value in `MissingAnnotationEmptyState`. diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 377373a06d..6c04b34a90 100644 --- a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -30,7 +30,7 @@ metadata: spec: type: website lifecycle: production - owner: guest`; + owner: user:guest`; type Props = { annotation: string; diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts index 4d6f67de22..1d8e139fb3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts @@ -38,7 +38,7 @@ describe('parseEntityYaml', () => { spec: type: website lifecycle: production - owner: guest + owner: user:guest `, 'utf8', ), @@ -60,7 +60,7 @@ describe('parseEntityYaml', () => { spec: { type: 'website', lifecycle: 'production', - owner: 'guest', + owner: 'user:guest', }, }), ]); diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index d47144351e..e1569f16ce 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -30,7 +30,7 @@ TBD spec: type: website lifecycle: production - owner: guest + owner: user:guest ``` ### Standalone app requirements diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index a765bd395d..5b347388f7 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -9,4 +9,4 @@ metadata: spec: type: website lifecycle: production - owner: guest + owner: user:guest diff --git a/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml index a944232224..059b719563 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml @@ -10,4 +10,4 @@ metadata: spec: type: service lifecycle: production - owner: guest + owner: user:guest diff --git a/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml index dd1e0ebd09..04f055bd46 100644 --- a/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml @@ -5,4 +5,4 @@ metadata: spec: type: website lifecycle: experimental - owner: guest + owner: user:guest From 23afdba96f4ca6539cbd22ba487eb25ed599eed6 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 13:25:55 +0200 Subject: [PATCH 023/176] Use `EntityRefLink` in header and use relations to reference the owner of the document Signed-off-by: Oliver Sand --- .changeset/lovely-wombats-relate.md | 6 +++ .../reader/components/TechDocsPageHeader.tsx | 51 +++++++++---------- 2 files changed, 31 insertions(+), 26 deletions(-) create mode 100644 .changeset/lovely-wombats-relate.md diff --git a/.changeset/lovely-wombats-relate.md b/.changeset/lovely-wombats-relate.md new file mode 100644 index 0000000000..33ed205c89 --- /dev/null +++ b/.changeset/lovely-wombats-relate.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Use `EntityRefLink` in header and use relations to reference the owner of the +document. diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index 1d4321001d..10df7bff78 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ +import { EntityName, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Header, HeaderLabel, useRouteRef } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import CodeIcon from '@material-ui/icons/Code'; import React from 'react'; import { AsyncState } from 'react-use/lib/useAsync'; -import CodeIcon from '@material-ui/icons/Code'; -import { EntityName, parseEntityName } from '@backstage/catalog-model'; -import { Header, HeaderLabel, Link, useRouteRef } from '@backstage/core'; -import { TechDocsMetadata } from '../../types'; -import { EntityRefLink, entityRouteRef } from '@backstage/plugin-catalog-react'; import { rootRouteRef } from '../../plugin'; +import { TechDocsMetadata } from '../../types'; type TechDocsPageHeaderProps = { entityId: EntityName; @@ -50,15 +54,12 @@ export const TechDocsPageHeader = ({ const { locationMetadata, - spec: { owner, lifecycle }, + spec: { lifecycle }, } = entityMetadataValues || { spec: {} }; - const componentLink = useRouteRef(entityRouteRef); - - let ownerEntity; - if (owner) { - ownerEntity = parseEntityName(owner, { defaultKind: 'group' }); - } + const ownedByRelations = entityMetadataValues + ? getEntityRelations(entityMetadataValues, RELATION_OWNED_BY) + : []; const docsRootLink = useRouteRef(rootRouteRef)(); @@ -67,27 +68,25 @@ export const TechDocsPageHeader = ({ - {name} - + } /> - {owner ? ( + {ownedByRelations.length > 0 && ( - ) : ( - owner - ) + } /> - ) : null} + )} {lifecycle ? : null} {locationMetadata && locationMetadata.type !== 'dir' && From b9b2b4b7666d37ba650edcd4f4d518207d524151 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Thu, 22 Apr 2021 15:03:10 +0200 Subject: [PATCH 024/176] [Search] Lunr search engine support (#5290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add lunr package Signed-off-by: Emma Indal * add search translator type and search engine interface Signed-off-by: Emma Indal * (wip) add support for lunr search engine Signed-off-by: Emma Indal * lunr search engine support Signed-off-by: Emma Indal * clean up todo comments Signed-off-by: Emma Indal * typing and cleanups Signed-off-by: Emma Indal * move lunr type package from dev deps to deps Signed-off-by: Emma Indal * check if documents exist to index Signed-off-by: Emma Indal * test fixup Signed-off-by: Emma Indal * changeset Signed-off-by: Emma Indal * move LunrSearchEngine.ts to /engines and add tests Signed-off-by: Emma Indal * update imports Signed-off-by: Emma Indal * update error message Signed-off-by: Emma Indal * add comment to index rotation Signed-off-by: Emma Indal * Update plugins/search-backend-node/src/types.ts Signed-off-by: Fredrik Adelöw freben@gmail.com Co-authored-by: Fredrik Adelöw Signed-off-by: Emma Indal * Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts Signed-off-by: Emma Indal Co-authored-by: Fredrik Adelöw * Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts Signed-off-by: Emma Indal Co-authored-by: Fredrik Adelöw * fix imports Signed-off-by: Emma Indal * use type assertion to specify more specific ConcreteLunrQuery type Signed-off-by: Emma Indal * fix imports Signed-off-by: Emma Indal * consistent naming Signed-off-by: Emma Indal * change search engine to be parameter of constructor in indexBuilder Signed-off-by: Emma Indal * make engine required in router options and pass it through in createRouter used in standalone server Signed-off-by: Emma Indal * fix tests Signed-off-by: Emma Indal * delete import Signed-off-by: Emma Indal * add types to SearchQuery interface to make it possible to scope to specific index + test Signed-off-by: Emma Indal * clean up tests Signed-off-by: Emma Indal * handle case when a filter is added on a field that does not exist on all documents + test Signed-off-by: Emma Indal Co-authored-by: Fredrik Adelöw --- .changeset/dry-carrots-impress.md | 6 + packages/backend/src/plugins/search.ts | 9 +- packages/search-common/src/types.ts | 1 + plugins/search-backend-node/package.json | 4 +- .../search-backend-node/src/IndexBuilder.ts | 19 +- .../src/engines/LunrSearchEngine.test.ts | 325 ++++++++++++++++++ .../src/engines/LunrSearchEngine.ts | 134 ++++++++ .../search-backend-node/src/engines/index.ts | 17 + plugins/search-backend-node/src/index.test.ts | 9 +- plugins/search-backend-node/src/index.ts | 2 + plugins/search-backend-node/src/types.ts | 25 +- plugins/search-backend/package.json | 1 + .../search-backend/src/service/router.test.ts | 10 +- plugins/search-backend/src/service/router.ts | 16 +- .../src/service/standaloneServer.ts | 10 + yarn.lock | 10 + 16 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 .changeset/dry-carrots-impress.md create mode 100644 plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts create mode 100644 plugins/search-backend-node/src/engines/LunrSearchEngine.ts create mode 100644 plugins/search-backend-node/src/engines/index.ts diff --git a/.changeset/dry-carrots-impress.md b/.changeset/dry-carrots-impress.md new file mode 100644 index 0000000000..aa057beb58 --- /dev/null +++ b/.changeset/dry-carrots-impress.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-backend': patch +'@backstage/plugin-search-backend-node': patch +--- + +Lunr Search Engine support diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 7bd473756d..a980661c85 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -15,7 +15,10 @@ */ import { useHotCleanup } from '@backstage/backend-common'; import { createRouter } from '@backstage/plugin-search-backend'; -import { IndexBuilder } from '@backstage/plugin-search-backend-node'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; @@ -23,7 +26,8 @@ export default async function createPlugin({ logger, discovery, }: PluginEnvironment) { - const indexBuilder = new IndexBuilder({ logger }); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ type: 'software-catalog', @@ -38,6 +42,7 @@ export default async function createPlugin({ useHotCleanup(module, () => clearInterval(timerId)); return await createRouter({ + engine: indexBuilder.getSearchEngine(), logger, }); } diff --git a/packages/search-common/src/types.ts b/packages/search-common/src/types.ts index ad784c3b36..daa5823424 100644 --- a/packages/search-common/src/types.ts +++ b/packages/search-common/src/types.ts @@ -18,6 +18,7 @@ import { JsonObject } from '@backstage/config'; export interface SearchQuery { term: string; filters?: JsonObject; + types?: string[]; pageCursor: string; } diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index ab260ada5b..2d1803f4c4 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -20,7 +20,9 @@ }, "dependencies": { "@backstage/search-common": "^0.1.1", - "winston": "^3.2.1" + "winston": "^3.2.1", + "lunr": "^2.3.9", + "@types/lunr": "^2.3.3" }, "devDependencies": { "@backstage/backend-common": "^0.6.0", diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 43bf84a59d..0f5a287037 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -16,9 +16,11 @@ import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; import { Logger } from 'winston'; + import { RegisterCollatorParameters, RegisterDecoratorParameters, + SearchEngine, } from './types'; interface CollatorEnvelope { @@ -27,18 +29,25 @@ interface CollatorEnvelope { } type IndexBuilderOptions = { + searchEngine: SearchEngine; logger: Logger; }; export class IndexBuilder { private collators: Record; private decorators: Record; + private searchEngine: SearchEngine; private logger: Logger; - constructor({ logger }: IndexBuilderOptions) { + constructor({ logger, searchEngine }: IndexBuilderOptions) { this.collators = {}; this.decorators = {}; this.logger = logger; + this.searchEngine = searchEngine; + } + + getSearchEngine(): SearchEngine { + return this.searchEngine; } /** @@ -106,7 +115,13 @@ export class IndexBuilder { documents = await decorators[i].execute(documents); } - // TODO: push documents to a configured search engine. + if (!documents || documents.length === 0) { + this.logger.info(`No documents for type "${type}" to index`); + return; + } + + // pushing documents to index to a configured search engine. + this.searchEngine.index(type, documents); }), ); } diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts new file mode 100644 index 0000000000..6c348235e0 --- /dev/null +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts @@ -0,0 +1,325 @@ +/* + * 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 { LunrSearchEngine } from './LunrSearchEngine'; +import { SearchEngine } from '../types'; + +describe('LunrSearchEngine', () => { + let testLunrSearchEngine: SearchEngine; + + beforeEach(() => { + testLunrSearchEngine = new LunrSearchEngine({ logger: getVoidLogger() }); + }); + + describe('translator', () => { + it('query translator invoked', async () => { + const translatorSpy = jest.spyOn(testLunrSearchEngine, 'translator'); + + // Translate query and ensure the translator was invoked. + await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(translatorSpy).toHaveBeenCalled(); + expect(translatorSpy).toHaveBeenCalledWith({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + }); + + it('should return translated query', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm', + }); + }); + + it('should return translated query with 1 filter', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: { kind: 'testKind' }, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm +kind:testKind', + }); + }); + + it('should return translated query with multiple filters', async () => { + const mockedTranslatedQuery = await testLunrSearchEngine.translator({ + term: 'testTerm', + filters: { kind: 'testKind', namespace: 'testNameSpace' }, + pageCursor: '', + }); + + expect(mockedTranslatedQuery).toMatchObject({ + documentTypes: ['*'], + lunrQueryString: 'testTerm +kind:testKind +namespace:testNameSpace', + }); + }); + }); + + describe('query', () => { + it('should perform search query', async () => { + const querySpy = jest.spyOn(testLunrSearchEngine, 'query'); + + // Perform search query and ensure the query func was invoked. + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + expect(querySpy).toHaveBeenCalled(); + expect(querySpy).toHaveBeenCalledWith({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + // Should return 0 results as nothing is indexed here + expect(mockedSearchResult).toMatchObject({ results: [] }); + }); + + it('should perform search query and return 0 results on no match', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + // Mock indexing of 1 document + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTerm', + filters: {}, + pageCursor: '', + }); + + // Should return 0 results as we are mocking the indexing of 1 document but with no match on the fields + expect(mockedSearchResult).toMatchObject({ results: [] }); + }); + + it('should perform search query and return search results on match', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + // Mock indexing of 1 document + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + filters: {}, + pageCursor: '', + }); + + // Should return 1 result as we are mocking the indexing of 1 document with match on the title field + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match with filters', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + ]; + + // Mock indexing of 2 documents + testLunrSearchEngine.index('test-index', mockDocuments); + + // Perform search query + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + filters: { location: 'test/location2' }, + pageCursor: '', + }); + + // Should return 1 of 2 results as we are + // 1. Mocking the indexing of 2 documents + // 2. Matching on the location field with the filter { location: 'test/location2' } + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match with filter and not fail on missing field', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + ]; + + const mockDocuments2 = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + extraField: 'testExtraField', + }, + ]; + + // Mock 2 indices with 1 document each + testLunrSearchEngine.index('test-index', mockDocuments); + testLunrSearchEngine.index('test-index-2', mockDocuments2); + // Perform search query scoped to "test-index-2" with a filter on the field "extraField" + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + pageCursor: '', + filters: { extraField: 'testExtraField' }, + }); + + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + extraField: 'testExtraField', + }, + }, + ], + }); + }); + + it('should perform search query and return search results on match, scoped to specific index', async () => { + const mockDocuments = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location2', + }, + ]; + + const mockDocuments2 = [ + { + title: 'testTitle', + text: 'testText', + location: 'test/location3', + }, + { + title: 'testTitle', + text: 'testText', + location: 'test/location4', + }, + ]; + + // Mock 2 indices with 2 documents each + testLunrSearchEngine.index('test-index', mockDocuments); + testLunrSearchEngine.index('test-index-2', mockDocuments2); + + // Perform search query scoped to "test-index-2" + const mockedSearchResult = await testLunrSearchEngine.query({ + term: 'testTitle', + types: ['test-index-2'], + pageCursor: '', + }); + + expect(mockedSearchResult).toMatchObject({ + results: [ + { + document: { + location: 'test/location3', + text: 'testText', + title: 'testTitle', + }, + }, + { + document: { + location: 'test/location4', + text: 'testText', + title: 'testTitle', + }, + }, + ], + }); + }); + }); + + describe('index', () => { + it('should index document', async () => { + const indexSpy = jest.spyOn(testLunrSearchEngine, 'index'); + const mockDocuments = [ + { + title: 'testTerm', + text: 'testText', + location: 'test/location', + }, + ]; + + // call index func and ensure the index func was invoked. + testLunrSearchEngine.index('test-index', mockDocuments); + expect(indexSpy).toHaveBeenCalled(); + expect(indexSpy).toHaveBeenCalledWith('test-index', [ + { title: 'testTerm', text: 'testText', location: 'test/location' }, + ]); + }); + }); +}); diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts new file mode 100644 index 0000000000..cc8c5bb982 --- /dev/null +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts @@ -0,0 +1,134 @@ +/* + * 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 { + SearchQuery, + IndexableDocument, + SearchResultSet, +} from '@backstage/search-common'; +import lunr from 'lunr'; +import { Logger } from 'winston'; +import { SearchEngine, QueryTranslator } from '../types'; + +type ConcreteLunrQuery = { + lunrQueryString: string; + documentTypes: string[]; +}; + +export class LunrSearchEngine implements SearchEngine { + protected lunrIndices: Record = {}; + protected docStore: Record; + protected logger: Logger; + + constructor({ logger }: { logger: Logger }) { + this.logger = logger; + this.docStore = {}; + } + + translator: QueryTranslator = ({ + term, + filters, + types, + }: SearchQuery): ConcreteLunrQuery => { + let lunrQueryFilters; + if (filters) { + lunrQueryFilters = Object.entries(filters) + .map(([key, value]) => ` +${key}:${value}`) + .join(''); + } + + return { + lunrQueryString: `${term}${lunrQueryFilters || ''}`, + documentTypes: types || ['*'], + }; + }; + + index(type: string, documents: IndexableDocument[]): void { + const lunrBuilder = new lunr.Builder(); + // Make this lunr index aware of all relevant fields. + Object.keys(documents[0]).forEach(field => { + lunrBuilder.field(field); + }); + + // Set "location" field as reference field + lunrBuilder.ref('location'); + + documents.forEach((document: IndexableDocument) => { + // Add document to Lunar index + lunrBuilder.add(document); + // Store documents in memory to be able to look up document using the ref during query time + // This is not how you should implement your SearchEngine implementation! Do not copy! + this.docStore[document.location] = document; + }); + + // "Rotate" the index by simply overwriting any existing index of the same name. + this.lunrIndices[type] = lunrBuilder.build(); + } + + query(query: SearchQuery): Promise { + const { lunrQueryString, documentTypes } = this.translator( + query, + ) as ConcreteLunrQuery; + + const results: lunr.Index.Result[] = []; + + if (documentTypes.length === 1 && documentTypes[0] === '*') { + // Iterate over all this.lunrIndex values. + Object.values(this.lunrIndices).forEach(i => { + try { + results.push(...i.search(lunrQueryString)); + } catch (err) { + // if a field does not exist on a index, we can see that as a no-match + if ( + err instanceof lunr.QueryParseError && + err.message.startsWith('unrecognised field') + ) + return; + } + }); + } else { + // Iterate over the filtered list of this.lunrIndex keys. + Object.keys(this.lunrIndices) + .filter(d => documentTypes.includes(d)) + .forEach(d => { + try { + results.push(...this.lunrIndices[d].search(lunrQueryString)); + } catch (err) { + // if a field does not exist on a index, we can see that as a no-match + if ( + err instanceof lunr.QueryParseError && + err.message.startsWith('unrecognised field') + ) + return; + } + }); + } + + // Sort results. + results.sort((doc1, doc2) => { + return doc2.score - doc1.score; + }); + + // Translate results into SearchResultSet + const resultSet: SearchResultSet = { + results: results.map(d => { + return { document: this.docStore[d.ref] }; + }), + }; + + return Promise.resolve(resultSet); + } +} diff --git a/plugins/search-backend-node/src/engines/index.ts b/plugins/search-backend-node/src/engines/index.ts new file mode 100644 index 0000000000..ed7079ef62 --- /dev/null +++ b/plugins/search-backend-node/src/engines/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { LunrSearchEngine } from './LunrSearchEngine'; diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/index.test.ts index 0941f23540..0c66db372a 100644 --- a/plugins/search-backend-node/src/index.test.ts +++ b/plugins/search-backend-node/src/index.test.ts @@ -21,6 +21,7 @@ import { IndexableDocument, } from '@backstage/search-common'; import { IndexBuilder } from './IndexBuilder'; +import { LunrSearchEngine, SearchEngine } from './index'; class TestDocumentCollator implements DocumentCollator { async execute() { @@ -35,12 +36,18 @@ class TestDocumentDecorator implements DocumentDecorator { } describe('IndexBuilder', () => { + let testSearchEngine: SearchEngine; let testIndexBuilder: IndexBuilder; let testCollator: DocumentCollator; let testDecorator: DocumentDecorator; beforeEach(() => { - testIndexBuilder = new IndexBuilder({ logger: getVoidLogger() }); + const logger = getVoidLogger(); + testSearchEngine = new LunrSearchEngine({ logger }); + testIndexBuilder = new IndexBuilder({ + logger, + searchEngine: testSearchEngine, + }); testCollator = new TestDocumentCollator(); testDecorator = new TestDocumentDecorator(); }); diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index 924dfbc9dc..a3a0eb855e 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -15,3 +15,5 @@ */ export { IndexBuilder } from './IndexBuilder'; +export { LunrSearchEngine } from './engines'; +export type { SearchEngine } from './types'; diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts index e55faccfb4..4abfa77fb1 100644 --- a/plugins/search-backend-node/src/types.ts +++ b/plugins/search-backend-node/src/types.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; +import { + DocumentCollator, + DocumentDecorator, + IndexableDocument, + SearchQuery, + SearchResultSet, +} from '@backstage/search-common'; /** * Parameters required to register a collator. @@ -51,3 +57,20 @@ export interface RegisterDecoratorParameters { */ types?: string[]; } + +/** + * A type of function responsible for translating an abstract search query into + * a concrete query relevant to a particular search engine. + */ +export type QueryTranslator = (query: SearchQuery) => unknown; + +/** + * Interface that must be implemented by specific search engines, responsible + * for performing indexing and querying and translating abstract queries into + * concrete, search engine-specific queries. + */ +export interface SearchEngine { + translator: QueryTranslator; + index(type: string, documents: IndexableDocument[]): void; + query(query: SearchQuery): Promise; +} diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index a7d588b4b1..82b72e9506 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -21,6 +21,7 @@ "dependencies": { "@backstage/backend-common": "^0.6.0", "@backstage/search-common": "^0.1.1", + "@backstage/plugin-search-backend-node": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index ba7032eb93..9b7cf83ba5 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -15,6 +15,10 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { + IndexBuilder, + LunrSearchEngine, +} from '@backstage/plugin-search-backend-node'; import express from 'express'; import request from 'supertest'; @@ -24,8 +28,12 @@ describe('createRouter', () => { let app: express.Express; beforeAll(async () => { + const logger = getVoidLogger(); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); const router = await createRouter({ - logger: getVoidLogger(), + engine: indexBuilder.getSearchEngine(), + logger, }); app = express().use(router); }); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 1a0c67bdb8..6b757fee16 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -18,23 +18,24 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { SearchQuery, SearchResultSet } from '@backstage/search-common'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; type RouterOptions = { + engine: SearchEngine; logger: Logger; }; export async function createRouter({ + engine, logger, }: RouterOptions): Promise { const router = Router(); - router.get( '/query', async ( req: express.Request, res: express.Response, ) => { - // TODO: Actually transform req.params into search engine specific query. const { term, filters = {}, pageCursor = '' } = req.query; logger.info( `Search request received: ${term}, ${JSON.stringify( @@ -43,13 +44,12 @@ export async function createRouter({ ); try { - // TODO: Actually query search engine. - // TODO: And actually transform results into frontend-readable result - res.send({ - results: [], - }); + const results = await engine?.query(req.query); + res.send(results); } catch (err) { - throw new Error(`There was a problem performing the search query.`); + throw new Error( + `There was a problem performing the search query. ${err}`, + ); } }, ); diff --git a/plugins/search-backend/src/service/standaloneServer.ts b/plugins/search-backend/src/service/standaloneServer.ts index b0c53a6adc..df42e3dda2 100644 --- a/plugins/search-backend/src/service/standaloneServer.ts +++ b/plugins/search-backend/src/service/standaloneServer.ts @@ -18,6 +18,10 @@ import { createServiceBuilder } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; +import { + LunrSearchEngine, + IndexBuilder, +} from '@backstage/plugin-search-backend-node'; export interface ServerOptions { port: number; @@ -29,8 +33,14 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'search-backend' }); + const searchEngine = new LunrSearchEngine({ logger }); + const indexBuilder = new IndexBuilder({ logger, searchEngine }); logger.debug('Starting application server...'); + + // TODO: stub out some documents/indices? + const router = await createRouter({ + engine: indexBuilder.getSearchEngine(), logger, }); diff --git a/yarn.lock b/yarn.lock index dfa07a5397..df07058f48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6210,6 +6210,11 @@ resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== +"@types/lunr@^2.3.3": + version "2.3.3" + resolved "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.3.tgz#ec985618fd2712c010f8edab4f1ae7784ad7c583" + integrity sha512-09sXZZVsB3Ib41U0fC+O1O+4UOZT1bl/e+/QubPxpqDWHNEchvx/DEb1KJMOwq6K3MTNzZFoNSzVdR++o1DVnw== + "@types/luxon@^1.25.0": version "1.26.0" resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36" @@ -17927,6 +17932,11 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" +lunr@^2.3.9: + version "2.3.9" + resolved "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" + integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== + luxon@1.25.0, luxon@^1.25.0: version "1.25.0" resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" From 034998d53756690a2ada42a1a78d79174e52480c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Apr 2021 13:19:35 +0000 Subject: [PATCH 025/176] Version Packages --- .changeset/chatty-parents-search.md | 5 --- .changeset/dry-carrots-impress.md | 6 --- .changeset/dry-elephants-doubt.md | 5 --- .changeset/eight-plums-hide.md | 5 --- .changeset/eighty-crews-rhyme.md | 5 --- .changeset/fair-carrots-tell.md | 5 --- .changeset/flat-paws-rule.md | 6 --- .changeset/fluffy-suns-repair.md | 5 --- .changeset/fresh-cheetahs-rush.md | 5 --- .changeset/giant-onions-obey.md | 5 --- .changeset/good-glasses-build.md | 6 --- .changeset/gorgeous-pumas-cover.md | 5 --- .changeset/great-years-battle.md | 14 ------ .changeset/grumpy-games-shop.md | 5 --- .changeset/healthy-stingrays-shout.md | 5 --- .changeset/light-fireants-flow.md | 5 --- .changeset/light-horses-hammer.md | 5 --- .changeset/moody-pigs-repeat.md | 5 --- .changeset/new-shirts-move.md | 5 --- .changeset/quiet-badgers-cheer.md | 11 ----- .changeset/real-apples-visit.md | 5 --- .changeset/rotten-cups-bow.md | 6 --- .changeset/rude-items-bow.md | 5 --- .changeset/six-turtles-sip.md | 6 --- .changeset/sour-plums-enjoy.md | 7 --- .changeset/stale-carpets-poke.md | 30 ------------- .changeset/stale-chefs-retire.md | 5 --- .changeset/strong-houses-lick.md | 6 --- .changeset/techdocs-eight-camels-hear.md | 7 --- .changeset/techdocs-swift-mugs-invent.md | 5 --- .changeset/tiny-ears-love.md | 5 --- .changeset/unlucky-starfishes-vanish.md | 6 --- packages/app/CHANGELOG.md | 31 +++++++++++++ packages/app/package.json | 18 ++++---- packages/backend-common/CHANGELOG.md | 8 ++++ packages/backend-common/package.json | 4 +- packages/backend/CHANGELOG.md | 24 ++++++++++ packages/backend/package.json | 22 ++++----- packages/cli/CHANGELOG.md | 8 ++++ packages/cli/package.json | 8 ++-- packages/core-api/CHANGELOG.md | 9 ++++ packages/core-api/package.json | 6 +-- packages/core/CHANGELOG.md | 17 +++++++ packages/core/package.json | 8 ++-- packages/create-app/CHANGELOG.md | 52 ++++++++++++++++++++++ packages/create-app/package.json | 2 +- packages/theme/CHANGELOG.md | 6 +++ packages/theme/package.json | 4 +- plugins/api-docs/package.json | 6 +-- plugins/badges/package.json | 6 +-- plugins/bitrise/package.json | 6 +-- plugins/catalog-backend/CHANGELOG.md | 50 +++++++++++++++++++++ plugins/catalog-backend/package.json | 10 ++--- plugins/catalog-import/CHANGELOG.md | 16 +++++++ plugins/catalog-import/package.json | 8 ++-- plugins/catalog/CHANGELOG.md | 17 +++++++ plugins/catalog/package.json | 8 ++-- plugins/circleci/package.json | 6 +-- plugins/cloudbuild/package.json | 6 +-- plugins/code-coverage-backend/CHANGELOG.md | 10 +++++ plugins/code-coverage-backend/package.json | 6 +-- plugins/code-coverage/CHANGELOG.md | 17 +++++++ plugins/code-coverage/package.json | 8 ++-- plugins/config-schema/package.json | 6 +-- plugins/cost-insights/package.json | 6 +-- plugins/explore/package.json | 6 +-- plugins/fossa/CHANGELOG.md | 17 +++++++ plugins/fossa/package.json | 8 ++-- plugins/gcp-projects/package.json | 6 +-- plugins/github-actions/package.json | 6 +-- plugins/github-deployments/CHANGELOG.md | 16 +++++++ plugins/github-deployments/package.json | 8 ++-- plugins/gitops-profiles/package.json | 6 +-- plugins/graphiql/package.json | 6 +-- plugins/jenkins/package.json | 6 +-- plugins/kafka/package.json | 6 +-- plugins/kubernetes-backend/CHANGELOG.md | 9 ++++ plugins/kubernetes-backend/package.json | 6 +-- plugins/kubernetes/package.json | 6 +-- plugins/lighthouse/package.json | 6 +-- plugins/newrelic/package.json | 6 +-- plugins/org/package.json | 6 +-- plugins/pagerduty/package.json | 6 +-- plugins/register-component/package.json | 6 +-- plugins/rollbar-backend/CHANGELOG.md | 18 ++++++++ plugins/rollbar-backend/package.json | 6 +-- plugins/rollbar/package.json | 6 +-- plugins/scaffolder-backend/CHANGELOG.md | 21 +++++++++ plugins/scaffolder-backend/package.json | 6 +-- plugins/scaffolder/CHANGELOG.md | 17 +++++++ plugins/scaffolder/package.json | 8 ++-- plugins/search-backend-node/CHANGELOG.md | 6 +++ plugins/search-backend-node/package.json | 6 +-- plugins/search-backend/CHANGELOG.md | 11 +++++ plugins/search-backend/package.json | 8 ++-- plugins/search/package.json | 6 +-- plugins/sentry/package.json | 6 +-- plugins/sonarqube/package.json | 6 +-- plugins/splunk-on-call/package.json | 6 +-- plugins/tech-radar/package.json | 6 +-- plugins/techdocs/CHANGELOG.md | 22 +++++++++ plugins/techdocs/package.json | 8 ++-- plugins/todo/package.json | 6 +-- plugins/user-settings/package.json | 6 +-- plugins/welcome/CHANGELOG.md | 16 +++++++ plugins/welcome/package.json | 8 ++-- 106 files changed, 594 insertions(+), 387 deletions(-) delete mode 100644 .changeset/chatty-parents-search.md delete mode 100644 .changeset/dry-carrots-impress.md delete mode 100644 .changeset/dry-elephants-doubt.md delete mode 100644 .changeset/eight-plums-hide.md delete mode 100644 .changeset/eighty-crews-rhyme.md delete mode 100644 .changeset/fair-carrots-tell.md delete mode 100644 .changeset/flat-paws-rule.md delete mode 100644 .changeset/fluffy-suns-repair.md delete mode 100644 .changeset/fresh-cheetahs-rush.md delete mode 100644 .changeset/giant-onions-obey.md delete mode 100644 .changeset/good-glasses-build.md delete mode 100644 .changeset/gorgeous-pumas-cover.md delete mode 100644 .changeset/great-years-battle.md delete mode 100644 .changeset/grumpy-games-shop.md delete mode 100644 .changeset/healthy-stingrays-shout.md delete mode 100644 .changeset/light-fireants-flow.md delete mode 100644 .changeset/light-horses-hammer.md delete mode 100644 .changeset/moody-pigs-repeat.md delete mode 100644 .changeset/new-shirts-move.md delete mode 100644 .changeset/quiet-badgers-cheer.md delete mode 100644 .changeset/real-apples-visit.md delete mode 100644 .changeset/rotten-cups-bow.md delete mode 100644 .changeset/rude-items-bow.md delete mode 100644 .changeset/six-turtles-sip.md delete mode 100644 .changeset/sour-plums-enjoy.md delete mode 100644 .changeset/stale-carpets-poke.md delete mode 100644 .changeset/stale-chefs-retire.md delete mode 100644 .changeset/strong-houses-lick.md delete mode 100644 .changeset/techdocs-eight-camels-hear.md delete mode 100644 .changeset/techdocs-swift-mugs-invent.md delete mode 100644 .changeset/tiny-ears-love.md delete mode 100644 .changeset/unlucky-starfishes-vanish.md create mode 100644 plugins/code-coverage-backend/CHANGELOG.md create mode 100644 plugins/code-coverage/CHANGELOG.md diff --git a/.changeset/chatty-parents-search.md b/.changeset/chatty-parents-search.md deleted file mode 100644 index d9b1222d8f..0000000000 --- a/.changeset/chatty-parents-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Sort the table filter options by name. diff --git a/.changeset/dry-carrots-impress.md b/.changeset/dry-carrots-impress.md deleted file mode 100644 index aa057beb58..0000000000 --- a/.changeset/dry-carrots-impress.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-search-backend': patch -'@backstage/plugin-search-backend-node': patch ---- - -Lunr Search Engine support diff --git a/.changeset/dry-elephants-doubt.md b/.changeset/dry-elephants-doubt.md deleted file mode 100644 index 7a9ccc9892..0000000000 --- a/.changeset/dry-elephants-doubt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-deployments': patch ---- - -Adds extraColumns field to GitHub Deployments card diff --git a/.changeset/eight-plums-hide.md b/.changeset/eight-plums-hide.md deleted file mode 100644 index d1f8bcecfe..0000000000 --- a/.changeset/eight-plums-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Forward user token to scaffolder task for subsequent api requests diff --git a/.changeset/eighty-crews-rhyme.md b/.changeset/eighty-crews-rhyme.md deleted file mode 100644 index 5a8379acb6..0000000000 --- a/.changeset/eighty-crews-rhyme.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Exposing Material UI extension point for tabs to be able to add additional information to them diff --git a/.changeset/fair-carrots-tell.md b/.changeset/fair-carrots-tell.md deleted file mode 100644 index 62dce5c3ce..0000000000 --- a/.changeset/fair-carrots-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add `config:docs` command that opens up reference documentation for the local configuration schema in a browser. diff --git a/.changeset/flat-paws-rule.md b/.changeset/flat-paws-rule.md deleted file mode 100644 index af7725793e..0000000000 --- a/.changeset/flat-paws-rule.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/plugin-scaffolder': patch ---- - -Adding Headings for Accessibility on the Scaffolder Plugin diff --git a/.changeset/fluffy-suns-repair.md b/.changeset/fluffy-suns-repair.md deleted file mode 100644 index 6f45905219..0000000000 --- a/.changeset/fluffy-suns-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Respect top-level UI schema keys in scaffolder forms. Allows more advanced RJSF features such as explicit field ordering. diff --git a/.changeset/fresh-cheetahs-rush.md b/.changeset/fresh-cheetahs-rush.md deleted file mode 100644 index fe8a9caafb..0000000000 --- a/.changeset/fresh-cheetahs-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -No longer add newly created plugins to `plugins.ts` in the app, as it is no longer needed. diff --git a/.changeset/giant-onions-obey.md b/.changeset/giant-onions-obey.md deleted file mode 100644 index fbf9faa0a3..0000000000 --- a/.changeset/giant-onions-obey.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-import': patch ---- - -allow import from HTTP repositories diff --git a/.changeset/good-glasses-build.md b/.changeset/good-glasses-build.md deleted file mode 100644 index 3d506d3e43..0000000000 --- a/.changeset/good-glasses-build.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-api': patch -'@backstage/core': patch ---- - -Add support for discovering plugins through the app element tree, removing the need to register them explicitly. diff --git a/.changeset/gorgeous-pumas-cover.md b/.changeset/gorgeous-pumas-cover.md deleted file mode 100644 index c4c9facac4..0000000000 --- a/.changeset/gorgeous-pumas-cover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-welcome': patch ---- - -Australian Greeting diff --git a/.changeset/great-years-battle.md b/.changeset/great-years-battle.md deleted file mode 100644 index a2d61be11a..0000000000 --- a/.changeset/great-years-battle.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@backstage/plugin-rollbar-backend': patch -'@backstage/plugin-scaffolder-backend': minor ---- - -Fix some `spleling`. - -The `scaffolder-backend` has a configuration schema change that may be breaking -in rare circumstances. Due to a typo in the schema, the -`scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and -`scaffolder.bitbucket.visibility` did not get proper validation that the value -is one of the supported strings (`public`, `internal` (not available for -Bitbucket), and `private`). If you had a value that was not one of these three, -you may have to adjust your config. diff --git a/.changeset/grumpy-games-shop.md b/.changeset/grumpy-games-shop.md deleted file mode 100644 index 7426786f89..0000000000 --- a/.changeset/grumpy-games-shop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -remove use of deprecated type HelmetOptions diff --git a/.changeset/healthy-stingrays-shout.md b/.changeset/healthy-stingrays-shout.md deleted file mode 100644 index c3ce59ce1f..0000000000 --- a/.changeset/healthy-stingrays-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -SystemDiagramCard UI improvements diff --git a/.changeset/light-fireants-flow.md b/.changeset/light-fireants-flow.md deleted file mode 100644 index a8f7b09278..0000000000 --- a/.changeset/light-fireants-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Fix the schema / code mismatch in LDAP `set` config diff --git a/.changeset/light-horses-hammer.md b/.changeset/light-horses-hammer.md deleted file mode 100644 index 0d421c7056..0000000000 --- a/.changeset/light-horses-hammer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/theme': patch ---- - -Replace the link color in dark theme diff --git a/.changeset/moody-pigs-repeat.md b/.changeset/moody-pigs-repeat.md deleted file mode 100644 index 396fa80281..0000000000 --- a/.changeset/moody-pigs-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Exported SignInProviderConfig to strongly type SignInPage providers diff --git a/.changeset/new-shirts-move.md b/.changeset/new-shirts-move.md deleted file mode 100644 index 3896c85bbb..0000000000 --- a/.changeset/new-shirts-move.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Add low german greeting diff --git a/.changeset/quiet-badgers-cheer.md b/.changeset/quiet-badgers-cheer.md deleted file mode 100644 index e22bf9492c..0000000000 --- a/.changeset/quiet-badgers-cheer.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Removed `plugins.ts` from the app, as plugins are now discovered through the react tree. - -To apply this change to an existing app, simply delete `packages/app/src/plugins.ts` along with the import and usage in `packages/app/src/App.tsx`. - -Note that there are a few plugins that require explicit registration, in which case you would need to keep them in `plugins.ts`. The set of plugins that need explicit registration is any plugin that doesn't have a component extension that gets rendered as part of the app element tree. An example of such a plugin in the main Backstage repo is `@backstage/plugin-badges`. In the case of the badges plugin this is because there is not yet a component-based API for adding context menu items to the entity layout. - -If you have plugins that still rely on route registration through the `register` method of `createPlugin`, these need to be kept in `plugins.ts` as well. However, it is recommended to migrate these to export an extensions component instead. diff --git a/.changeset/real-apples-visit.md b/.changeset/real-apples-visit.md deleted file mode 100644 index b5165f633b..0000000000 --- a/.changeset/real-apples-visit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -GithubDiscoveryProcessor now excludes archived repositories so they won't be added to Backstage. diff --git a/.changeset/rotten-cups-bow.md b/.changeset/rotten-cups-bow.md deleted file mode 100644 index 967681171d..0000000000 --- a/.changeset/rotten-cups-bow.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-code-coverage': patch -'@backstage/plugin-code-coverage-backend': patch ---- - -Update tests to function in windows diff --git a/.changeset/rude-items-bow.md b/.changeset/rude-items-bow.md deleted file mode 100644 index 69b76d821e..0000000000 --- a/.changeset/rude-items-bow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes-backend': patch ---- - -Kubernetes client TLS verification is now configurable and defaults to true diff --git a/.changeset/six-turtles-sip.md b/.changeset/six-turtles-sip.md deleted file mode 100644 index e71b04e64d..0000000000 --- a/.changeset/six-turtles-sip.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Support configuration of file storage for SQLite databases. Every plugin has its -own database file at the specified path. diff --git a/.changeset/sour-plums-enjoy.md b/.changeset/sour-plums-enjoy.md deleted file mode 100644 index ef68166255..0000000000 --- a/.changeset/sour-plums-enjoy.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Fix system diagram card to be on the system page - -To apply the same fix to an existing application, in `EntityPage.tsx` simply move the `` for the `/diagram` path from the `groupPage` down into the `systemPage` element. diff --git a/.changeset/stale-carpets-poke.md b/.changeset/stale-carpets-poke.md deleted file mode 100644 index 4f39e57b71..0000000000 --- a/.changeset/stale-carpets-poke.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Externalize repository processing for BitbucketDiscoveryProcessor. - -Add an extension point where you can customize how a matched Bitbucket repository should -be processed. This can for example be used if you want to generate the catalog-info.yaml -automatically based on other files in a repository, while taking advantage of the -build-in repository crawling functionality. - -`BitbucketDiscoveryProcessor.fromConfig` now takes an optional parameter `options.parser` where -you can customize the logic for each repository found. The default parser has the same -behaviour as before, where it emits an optional location for the matched repository -and lets the other processors take care of further processing. - -```typescript -const customRepositoryParser: BitbucketRepositoryParser = async function* customRepositoryParser({ - client, - repository, -}) { - // Custom logic for interpret the matching repository. - // See defaultRepositoryParser for an example -}; - -const processor = BitbucketDiscoveryProcessor.fromConfig(env.config, { - parser: customRepositoryParser, - logger: env.logger, -}); -``` diff --git a/.changeset/stale-chefs-retire.md b/.changeset/stale-chefs-retire.md deleted file mode 100644 index e43c20062a..0000000000 --- a/.changeset/stale-chefs-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Adding close button on support menu diff --git a/.changeset/strong-houses-lick.md b/.changeset/strong-houses-lick.md deleted file mode 100644 index 9739bdfda1..0000000000 --- a/.changeset/strong-houses-lick.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-api': patch -'@backstage/core': patch ---- - -Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. diff --git a/.changeset/techdocs-eight-camels-hear.md b/.changeset/techdocs-eight-camels-hear.md deleted file mode 100644 index 8f2479f6bd..0000000000 --- a/.changeset/techdocs-eight-camels-hear.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-techdocs': minor ---- - -Add feedback link icon in Techdocs Reader that directs to GitLab or GitHub repo issue page with pre-filled title and source link. -For link to appear, requires `repo_url` and `edit_uri` to be filled in mkdocs.yml, as per https://www.mkdocs.org/user-guide/configuration. An `edit_uri` will need to be specified for self-hosted GitLab/GitHub instances with a different host name. -To identify issue URL format as GitHub or GitLab, the host name of source in `repo_url` is checked if it contains `gitlab` or `github`. Alternately this is determined by matching to `host` values from `integrations` in app-config.yaml. diff --git a/.changeset/techdocs-swift-mugs-invent.md b/.changeset/techdocs-swift-mugs-invent.md deleted file mode 100644 index 912089fb3c..0000000000 --- a/.changeset/techdocs-swift-mugs-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Add a test id to the shadow root element of the Reader to access it easily in e2e tests diff --git a/.changeset/tiny-ears-love.md b/.changeset/tiny-ears-love.md deleted file mode 100644 index 9c3d1613a5..0000000000 --- a/.changeset/tiny-ears-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Added support for Datadog rum events diff --git a/.changeset/unlucky-starfishes-vanish.md b/.changeset/unlucky-starfishes-vanish.md deleted file mode 100644 index 3bb341bee9..0000000000 --- a/.changeset/unlucky-starfishes-vanish.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-fossa': patch ---- - -Add a `FossaPage` that shows the license compliance status of all components in the catalog. -See the projects `Readme` on how to use it. diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 81a7f66f4c..7262b2ae29 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,36 @@ # example-app +## 0.2.25 + +### Patch Changes + +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [4e5c94249] +- Updated dependencies [99fbef232] +- Updated dependencies [cb0206b2b] +- Updated dependencies [1373f4f12] +- Updated dependencies [29a7e4be8] +- Updated dependencies [ab07d77f6] +- Updated dependencies [96728a2af] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [87c4f59de] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] +- Updated dependencies [ac6025f63] +- Updated dependencies [e292e393f] +- Updated dependencies [479b29124] + - @backstage/core@0.7.6 + - @backstage/cli@0.6.9 + - @backstage/plugin-scaffolder@0.9.1 + - @backstage/plugin-catalog-import@0.5.3 + - @backstage/plugin-catalog@0.5.5 + - @backstage/theme@0.2.6 + - @backstage/plugin-code-coverage@0.1.2 + - @backstage/plugin-techdocs@0.8.0 + ## 0.2.24 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 95e14766c3..ba9af03310 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,21 +1,21 @@ { "name": "example-app", - "version": "0.2.24", + "version": "0.2.25", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.7", - "@backstage/cli": "^0.6.8", - "@backstage/core": "^0.7.5", + "@backstage/cli": "^0.6.9", + "@backstage/core": "^0.7.6", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-api-docs": "^0.4.11", "@backstage/plugin-badges": "^0.2.0", - "@backstage/plugin-catalog": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.5.2", + "@backstage/plugin-catalog": "^0.5.5", + "@backstage/plugin-catalog-import": "^0.5.3", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/plugin-circleci": "^0.2.12", "@backstage/plugin-cloudbuild": "^0.2.13", - "@backstage/plugin-code-coverage": "^0.1.0", + "@backstage/plugin-code-coverage": "^0.1.2", "@backstage/plugin-cost-insights": "^0.8.4", "@backstage/plugin-explore": "^0.3.2", "@backstage/plugin-gcp-projects": "^0.2.5", @@ -29,14 +29,14 @@ "@backstage/plugin-org": "^0.3.12", "@backstage/plugin-pagerduty": "0.3.2", "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.9.0", + "@backstage/plugin-scaffolder": "^0.9.1", "@backstage/plugin-search": "^0.3.4", "@backstage/plugin-sentry": "^0.3.8", "@backstage/plugin-tech-radar": "^0.3.9", - "@backstage/plugin-techdocs": "^0.7.2", + "@backstage/plugin-techdocs": "^0.8.0", "@backstage/plugin-todo": "^0.1.0", "@backstage/plugin-user-settings": "^0.2.8", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.12", diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index c194f7e666..68fab7d861 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/backend-common +## 0.6.3 + +### Patch Changes + +- d367f63b5: remove use of deprecated type HelmetOptions +- b42531cfe: Support configuration of file storage for SQLite databases. Every plugin has its + own database file at the specified path. + ## 0.6.2 ### Patch Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 41fa657818..998ac475d9 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.6.2", + "version": "0.6.3", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -73,7 +73,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 6585ea2723..ee04e2bb6b 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,29 @@ # example-backend +## 0.2.25 + +### Patch Changes + +- Updated dependencies [b9b2b4b76] +- Updated dependencies [84c54474d] +- Updated dependencies [49574a8a3] +- Updated dependencies [d367f63b5] +- Updated dependencies [5fe62f124] +- Updated dependencies [09b5fcf2e] +- Updated dependencies [55b2fc0c0] +- Updated dependencies [c42cd1daa] +- Updated dependencies [b42531cfe] +- Updated dependencies [c2306f898] + - @backstage/plugin-search-backend@0.1.3 + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/plugin-scaffolder-backend@0.10.0 + - @backstage/plugin-rollbar-backend@0.1.9 + - @backstage/backend-common@0.6.3 + - @backstage/plugin-catalog-backend@0.8.0 + - @backstage/plugin-code-coverage-backend@0.1.2 + - @backstage/plugin-kubernetes-backend@0.3.5 + - example-app@0.2.25 + ## 0.2.22 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 984b8b60a5..440ceca8e5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.22", + "version": "0.2.25", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,30 +27,30 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-client": "^0.3.9", "@backstage/catalog-model": "^0.7.5", "@backstage/config": "^0.1.4", "@backstage/plugin-app-backend": "^0.3.10", "@backstage/plugin-auth-backend": "^0.3.7", "@backstage/plugin-badges-backend": "^0.1.1", - "@backstage/plugin-catalog-backend": "^0.7.0", - "@backstage/plugin-code-coverage-backend": "^0.1.0", + "@backstage/plugin-catalog-backend": "^0.8.0", + "@backstage/plugin-code-coverage-backend": "^0.1.2", "@backstage/plugin-graphql-backend": "^0.1.6", - "@backstage/plugin-kubernetes-backend": "^0.3.3", + "@backstage/plugin-kubernetes-backend": "^0.3.5", "@backstage/plugin-kafka-backend": "^0.2.3", "@backstage/plugin-proxy-backend": "^0.2.6", - "@backstage/plugin-rollbar-backend": "^0.1.8", - "@backstage/plugin-scaffolder-backend": "^0.9.4", - "@backstage/plugin-search-backend": "^0.1.2", - "@backstage/plugin-search-backend-node": "^0.1.2", + "@backstage/plugin-rollbar-backend": "^0.1.9", + "@backstage/plugin-scaffolder-backend": "^0.10.0", + "@backstage/plugin-search-backend": "^0.1.3", + "@backstage/plugin-search-backend-node": "^0.1.3", "@backstage/plugin-techdocs-backend": "^0.7.0", "@backstage/plugin-todo-backend": "^0.1.3", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.21", + "example-app": "^0.2.25", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^0.95.1", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.9", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a1381495ff..af452b3c9d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.6.9 + +### Patch Changes + +- 4e5c94249: Add `config:docs` command that opens up reference documentation for the local configuration schema in a browser. +- 1373f4f12: No longer add newly created plugins to `plugins.ts` in the app, as it is no longer needed. +- 479b29124: Added support for Datadog rum events + ## 0.6.8 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 251b2ab076..cd61193efe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.6.8", + "version": "0.6.9", "private": false, "publishConfig": { "access": "public" @@ -116,12 +116,12 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.6.2", + "@backstage/backend-common": "^0.6.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 609940b8db..fab82a77e5 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/core-api +## 0.2.17 + +### Patch Changes + +- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. +- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. +- Updated dependencies [931b21a12] + - @backstage/theme@0.2.6 + ## 0.2.16 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 33e5ac146d..a55df67343 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.16", + "version": "0.2.17", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -42,7 +42,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7f691a2787..1b556ac1c3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/core +## 0.7.6 + +### Patch Changes + +- 94da20976: Sort the table filter options by name. +- d8cc7e67a: Exposing Material UI extension point for tabs to be able to add additional information to them +- 99fbef232: Adding Headings for Accessibility on the Scaffolder Plugin +- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly. +- 937ed39ce: Exported SignInProviderConfig to strongly type SignInPage providers +- 9a9e7a42f: Adding close button on support menu +- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different. +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [50ce875a0] + - @backstage/core-api@0.2.17 + - @backstage/theme@0.2.6 + ## 0.7.5 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 7b1898a19d..344ea10398 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.5", + "version": "0.7.6", "private": false, "publishConfig": { "access": "public", @@ -30,9 +30,9 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core-api": "^0.2.16", + "@backstage/core-api": "^0.2.17", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -69,7 +69,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 5975b78e06..bd35768e68 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,57 @@ # @backstage/create-app +## 1.0.0 + +### Patch Changes + +- ee22773e9: Removed `plugins.ts` from the app, as plugins are now discovered through the react tree. + + To apply this change to an existing app, simply delete `packages/app/src/plugins.ts` along with the import and usage in `packages/app/src/App.tsx`. + + Note that there are a few plugins that require explicit registration, in which case you would need to keep them in `plugins.ts`. The set of plugins that need explicit registration is any plugin that doesn't have a component extension that gets rendered as part of the app element tree. An example of such a plugin in the main Backstage repo is `@backstage/plugin-badges`. In the case of the badges plugin this is because there is not yet a component-based API for adding context menu items to the entity layout. + + If you have plugins that still rely on route registration through the `register` method of `createPlugin`, these need to be kept in `plugins.ts` as well. However, it is recommended to migrate these to export an extensions component instead. + +- 670acd88e: Fix system diagram card to be on the system page + + To apply the same fix to an existing application, in `EntityPage.tsx` simply move the `` for the `/diagram` path from the `groupPage` down into the `systemPage` element. + +- Updated dependencies [94da20976] +- Updated dependencies [84c54474d] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [4e5c94249] +- Updated dependencies [99fbef232] +- Updated dependencies [cb0206b2b] +- Updated dependencies [1373f4f12] +- Updated dependencies [29a7e4be8] +- Updated dependencies [ab07d77f6] +- Updated dependencies [49574a8a3] +- Updated dependencies [d367f63b5] +- Updated dependencies [96728a2af] +- Updated dependencies [5fe62f124] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [87c4f59de] +- Updated dependencies [09b5fcf2e] +- Updated dependencies [b42531cfe] +- Updated dependencies [c2306f898] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] +- Updated dependencies [ac6025f63] +- Updated dependencies [e292e393f] +- Updated dependencies [479b29124] + - @backstage/core@0.7.6 + - @backstage/plugin-scaffolder-backend@0.10.0 + - @backstage/cli@0.6.9 + - @backstage/plugin-scaffolder@0.9.1 + - @backstage/plugin-catalog-import@0.5.3 + - @backstage/plugin-rollbar-backend@0.1.9 + - @backstage/backend-common@0.6.3 + - @backstage/plugin-catalog@0.5.5 + - @backstage/plugin-catalog-backend@0.8.0 + - @backstage/theme@0.2.6 + - @backstage/plugin-techdocs@0.8.0 + ## 0.3.18 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index ea3d3548b8..2b30eafa2b 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.18", + "version": "1.0.0", "private": false, "publishConfig": { "access": "public" diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 9ce3c75802..22964d6b25 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/theme +## 0.2.6 + +### Patch Changes + +- 931b21a12: Replace the link color in dark theme + ## 0.2.5 ### Patch Changes diff --git a/packages/theme/package.json b/packages/theme/package.json index b10e01ca1b..87821b6751 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.5", + "version": "0.2.6", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6" + "@backstage/cli": "^0.6.9" }, "files": [ "dist" diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 13f27c321b..51df09ba80 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -31,9 +31,9 @@ "dependencies": { "@asyncapi/react-component": "^0.22.3", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +49,7 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0d59e271b0..9f0c49af7e 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,10 +21,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 16a02ae42a..83911cdfc4 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,7 +37,7 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 94e9960e35..28efc1c5d3 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/plugin-catalog-backend +## 0.8.0 + +### Minor Changes + +- 5fe62f124: Fix the schema / code mismatch in LDAP `set` config + +### Patch Changes + +- 09b5fcf2e: GithubDiscoveryProcessor now excludes archived repositories so they won't be added to Backstage. +- c2306f898: Externalize repository processing for BitbucketDiscoveryProcessor. + + Add an extension point where you can customize how a matched Bitbucket repository should + be processed. This can for example be used if you want to generate the catalog-info.yaml + automatically based on other files in a repository, while taking advantage of the + build-in repository crawling functionality. + + `BitbucketDiscoveryProcessor.fromConfig` now takes an optional parameter `options.parser` where + you can customize the logic for each repository found. The default parser has the same + behaviour as before, where it emits an optional location for the matched repository + and lets the other processors take care of further processing. + + ```typescript + const customRepositoryParser: BitbucketRepositoryParser = async function* customRepositoryParser({ + client, + repository, + }) { + // Custom logic for interpret the matching repository. + // See defaultRepositoryParser for an example + }; + + const processor = BitbucketDiscoveryProcessor.fromConfig(env.config, { + parser: customRepositoryParser, + logger: env.logger, + }); + ``` + +- Updated dependencies [94da20976] +- Updated dependencies [b9b2b4b76] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [d367f63b5] +- Updated dependencies [937ed39ce] +- Updated dependencies [b42531cfe] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/backend-common@0.6.3 + ## 0.7.1 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 5125e9703e..9afb537637 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.7.1", + "version": "0.8.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,13 +30,13 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", - "@backstage/plugin-search-backend-node": "^0.1.2", + "@backstage/plugin-search-backend-node": "^0.1.3", "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", @@ -65,7 +65,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.9", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 5fd93562d6..8c189a5f9b 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-catalog-import +## 0.5.3 + +### Patch Changes + +- 29a7e4be8: allow import from HTTP repositories +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.5.2 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 6af4b02bd0..9ba32d830c 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.2", + "version": "0.5.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "dependencies": { "@backstage/catalog-model": "^0.7.6", "@backstage/catalog-client": "^0.3.9", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/integration": "^0.5.0", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -53,7 +53,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index eccfa953a4..35e000f130 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-catalog +## 0.5.5 + +### Patch Changes + +- 96728a2af: SystemDiagramCard UI improvements +- 87c4f59de: Add low german greeting +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 8561ae3188..b6b306c260 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.4", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ "dependencies": { "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -53,7 +53,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 5eb91d08bb..5c05c0ee6f 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 75e877ccb6..6ad0b8bfa9 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,8 +32,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md new file mode 100644 index 0000000000..cfc08db5c4 --- /dev/null +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -0,0 +1,10 @@ +# @backstage/plugin-code-coverage-backend + +## 0.1.2 + +### Patch Changes + +- 55b2fc0c0: Update tests to function in windows +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 1e7903b016..3fc238ce61 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.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,7 +19,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.2", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@types/express-xml-bodyparser": "^0.3.2", "@types/supertest": "^2.0.8", "msw": "^0.21.2", diff --git a/plugins/code-coverage/CHANGELOG.md b/plugins/code-coverage/CHANGELOG.md new file mode 100644 index 0000000000..4a55b7f0f5 --- /dev/null +++ b/plugins/code-coverage/CHANGELOG.md @@ -0,0 +1,17 @@ +# @backstage/plugin-code-coverage + +## 0.1.2 + +### Patch Changes + +- 55b2fc0c0: Update tests to function in windows +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index 1d02da3dea..1adc94fff7 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-code-coverage", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/styles": "^4.11.0", @@ -39,7 +39,7 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 2db05d549a..55f785a7a4 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index bec5db9fe0..c68fdde509 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -55,7 +55,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 7765760906..7c2eb5c90d 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -31,10 +31,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/plugin-explore-react": "^0.0.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index 001b926c6d..fe916804d2 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-fossa +## 0.2.5 + +### Patch Changes + +- 40d1e11cf: Add a `FossaPage` that shows the license compliance status of all components in the catalog. + See the projects `Readme` on how to use it. +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.2.4 ### Patch Changes diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 2f656c476a..23f9cfde74 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-fossa", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,10 +32,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index fecf9bdf22..509576cf2b 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 3dba840b8b..6a95803d6f 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -34,9 +34,9 @@ "dependencies": { "@backstage/catalog-model": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/integration": "^0.5.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 9369ef87f9..e7f849ef2e 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-github-deployments +## 0.1.3 + +### Patch Changes + +- 60d0a1a2e: Adds extraColumns field to GitHub Deployments card +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.1.2 ### Patch Changes diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index c5571b6e1f..12a36c160e 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.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index d3a0d0df66..ae614fe504 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 4ebedffd74..4aa26587ad 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c736eb0b5b..edee4bc2ee 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 644c074b81..a7295469d6 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 379f0dd872..8bcac8a60b 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes-backend +## 0.3.5 + +### Patch Changes + +- c42cd1daa: Kubernetes client TLS verification is now configurable and defaults to true +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.3.4 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 79864b7b48..7a8b16bc8a 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@google-cloud/container": "^2.2.0", @@ -52,7 +52,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@types/aws4": "^1.5.1", "supertest": "^6.1.3" }, diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index df00c0801f..03013e1480 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -33,10 +33,10 @@ "dependencies": { "@backstage/catalog-model": "^0.7.4", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/plugin-kubernetes-backend": "^0.3.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@kubernetes/client-node": "^0.14.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index eb7d69d77d..45980df00c 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -33,9 +33,9 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 82952c7452..0951d972c2 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 0a9917cb7d..5bed02e9c8 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -21,10 +21,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/core-api": "^0.2.16", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,7 +35,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 44e8cb5b7f..c0e3afec34 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index a8cfcc894a..2870ef79f1 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index a64fa25a81..8e20e93555 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-rollbar-backend +## 0.1.9 + +### Patch Changes + +- 49574a8a3: Fix some `spleling`. + + The `scaffolder-backend` has a configuration schema change that may be breaking + in rare circumstances. Due to a typo in the schema, the + `scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and + `scaffolder.bitbucket.visibility` did not get proper validation that the value + is one of the supported strings (`public`, `internal` (not available for + Bitbucket), and `private`). If you had a value that was not one of these three, + you may have to adjust your config. + +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.1.8 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 489c12139d..05b02b2c39 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.3", "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.9", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 6d0e49b1ae..6d654866af 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 2c45bdc611..cb1855e041 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-scaffolder-backend +## 0.10.0 + +### Minor Changes + +- 49574a8a3: Fix some `spleling`. + + The `scaffolder-backend` has a configuration schema change that may be breaking + in rare circumstances. Due to a typo in the schema, the + `scaffolder.github.visibility`, `scaffolder.gitlab.visibility`, and + `scaffolder.bitbucket.visibility` did not get proper validation that the value + is one of the supported strings (`public`, `internal` (not available for + Bitbucket), and `private`). If you had a value that was not one of these three, + you may have to adjust your config. + +### Patch Changes + +- 84c54474d: Forward user token to scaffolder task for subsequent api requests +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/backend-common@0.6.3 + ## 0.9.6 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 2c65d91604..729ef58d99 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.9.6", + "version": "0.10.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.2", + "@backstage/backend-common": "^0.6.3", "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", @@ -67,7 +67,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/test-utils": "^0.1.10", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index b7a8cbb89c..fee76f8775 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-scaffolder +## 0.9.1 + +### Patch Changes + +- 99fbef232: Adding Headings for Accessibility on the Scaffolder Plugin +- cb0206b2b: Respect top-level UI schema keys in scaffolder forms. Allows more advanced RJSF features such as explicit field ordering. +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.9.0 ### Minor Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 10785a4db7..4d4563df2f 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.0", + "version": "0.9.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,11 +33,11 @@ "@backstage/catalog-client": "^0.3.10", "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -60,7 +60,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search-backend-node/CHANGELOG.md b/plugins/search-backend-node/CHANGELOG.md index 8718488b21..e7a9d9aa0a 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.3 + +### Patch Changes + +- b9b2b4b76: Lunr Search Engine support + ## 0.1.2 ### Patch Changes diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index 2d1803f4c4..b5d13af01c 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.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -25,8 +25,8 @@ "@types/lunr": "^2.3.3" }, "devDependencies": { - "@backstage/backend-common": "^0.6.0", - "@backstage/cli": "^0.6.6" + "@backstage/backend-common": "^0.6.3", + "@backstage/cli": "^0.6.9" }, "files": [ "dist" diff --git a/plugins/search-backend/CHANGELOG.md b/plugins/search-backend/CHANGELOG.md index b6a8d9fd18..9313e2e83a 100644 --- a/plugins/search-backend/CHANGELOG.md +++ b/plugins/search-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-search-backend +## 0.1.3 + +### Patch Changes + +- b9b2b4b76: Lunr Search Engine support +- Updated dependencies [b9b2b4b76] +- Updated dependencies [d367f63b5] +- Updated dependencies [b42531cfe] + - @backstage/plugin-search-backend-node@0.1.3 + - @backstage/backend-common@0.6.3 + ## 0.1.2 ### Patch Changes diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 82b72e9506..a94fed35b9 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,9 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.3", "@backstage/search-common": "^0.1.1", - "@backstage/plugin-search-backend-node": "^0.1.2", + "@backstage/plugin-search-backend-node": "^0.1.3", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -29,7 +29,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.9", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/search/package.json b/plugins/search/package.json index 13019c7f32..c7ab201efe 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/search-common": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -44,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index dee05a8c2d..9908af2e24 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +46,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index d48f59febe..97243bb930 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -34,8 +34,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 990d0acb27..ecaf21f8eb 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,9 +31,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index e927ed938a..5a1c2e2095 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 3924a56df3..4f89cd9844 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-techdocs +## 0.8.0 + +### Minor Changes + +- ac6025f63: Add feedback link icon in Techdocs Reader that directs to GitLab or GitHub repo issue page with pre-filled title and source link. + For link to appear, requires `repo_url` and `edit_uri` to be filled in mkdocs.yml, as per https://www.mkdocs.org/user-guide/configuration. An `edit_uri` will need to be specified for self-hosted GitLab/GitHub instances with a different host name. + To identify issue URL format as GitHub or GitLab, the host name of source in `repo_url` is checked if it contains `gitlab` or `github`. Alternately this is determined by matching to `host` values from `integrations` in app-config.yaml. + +### Patch Changes + +- e292e393f: Add a test id to the shadow root element of the Reader to access it easily in e2e tests +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.7.2 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 88b3b6a1df..025a419f59 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.7.2", + "version": "0.8.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,10 +33,10 @@ "dependencies": { "@backstage/config": "^0.1.4", "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/test-utils": "^0.1.9", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@backstage/errors": "^0.1.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +49,7 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 5a80f068cc..7339e568c3 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -27,10 +27,10 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.5", + "@backstage/core": "^0.7.6", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.5", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -39,7 +39,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 6055f31b8a..d410dc103e 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/CHANGELOG.md b/plugins/welcome/CHANGELOG.md index 68b8c358c2..cf907b5b4f 100644 --- a/plugins/welcome/CHANGELOG.md +++ b/plugins/welcome/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-welcome +## 0.2.7 + +### Patch Changes + +- f85851837: Australian Greeting +- Updated dependencies [94da20976] +- Updated dependencies [d8cc7e67a] +- Updated dependencies [99fbef232] +- Updated dependencies [ab07d77f6] +- Updated dependencies [931b21a12] +- Updated dependencies [937ed39ce] +- Updated dependencies [9a9e7a42f] +- Updated dependencies [50ce875a0] + - @backstage/core@0.7.6 + - @backstage/theme@0.2.6 + ## 0.2.6 ### Patch Changes diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index aa7a746016..b276b86835 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.5", - "@backstage/theme": "^0.2.5", + "@backstage/core": "^0.7.6", + "@backstage/theme": "^0.2.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.9", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", From f218637d0859edd6d1bbce4f8050b5c73c430723 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Apr 2021 13:43:17 +0200 Subject: [PATCH 026/176] create-app: release version 0.3.19 Signed-off-by: Patrik Oldsberg --- packages/create-app/CHANGELOG.md | 2 +- packages/create-app/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index bd35768e68..c821a3ab81 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,6 +1,6 @@ # @backstage/create-app -## 1.0.0 +## 0.3.19 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 2b30eafa2b..5cf6cdd78e 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": "1.0.0", + "version": "0.3.19", "private": false, "publishConfig": { "access": "public" From 6fbd7beca5bd67cc7721c942e7180aecb60ba273 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 15:50:24 +0200 Subject: [PATCH 027/176] Rename changeset Signed-off-by: Oliver Sand --- ...lovely-wombats-relate.md => techdocs-lovely-wombats-relate.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{lovely-wombats-relate.md => techdocs-lovely-wombats-relate.md} (100%) diff --git a/.changeset/lovely-wombats-relate.md b/.changeset/techdocs-lovely-wombats-relate.md similarity index 100% rename from .changeset/lovely-wombats-relate.md rename to .changeset/techdocs-lovely-wombats-relate.md From dcd54c7cd12ec1c48ce44e8a2f7c9e4c0d300d07 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 16:07:13 +0200 Subject: [PATCH 028/176] Use `RouteRef` to generate path to search page Signed-off-by: Oliver Sand --- .changeset/sixty-dragons-retire.md | 5 +++++ .../src/components/SidebarSearch/SidebarSearch.tsx | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/sixty-dragons-retire.md diff --git a/.changeset/sixty-dragons-retire.md b/.changeset/sixty-dragons-retire.md new file mode 100644 index 0000000000..62965c6e88 --- /dev/null +++ b/.changeset/sixty-dragons-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Use `RouteRef` to generate path to search page. diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 7e72e83b02..b422c33e02 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -13,22 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback } from 'react'; +import { SidebarSearchField, useRouteRef } from '@backstage/core'; import qs from 'qs'; +import React, { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { SidebarSearchField } from '@backstage/core'; +import { rootRouteRef } from '../../plugin'; export const SidebarSearch = () => { + const searchRoute = useRouteRef(rootRouteRef); const navigate = useNavigate(); const handleSearch = useCallback( (query: string): void => { const queryString = qs.stringify({ query }, { addQueryPrefix: true }); - // TODO: Here the url to the search plugin is hardcoded. We need a way to query the route in the future. - // Maybe an API that I can just call from other places? - navigate(`/search${queryString}`); + navigate(`${searchRoute()}${queryString}`); }, - [navigate], + [navigate, searchRoute], ); return ; From 546172674279917bfa49ce621207925e760a6da5 Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Thu, 22 Apr 2021 09:08:46 -0600 Subject: [PATCH 029/176] [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 030/176] 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 c6a5acfc2a84ed0ef3269cc4958276604286caf7 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Thu, 22 Apr 2021 17:46:21 +0200 Subject: [PATCH 031/176] Fix typo - Service should be only be enabled when backend is enabled Signed-off-by: Brian Fox --- .../backstage/templates/service.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml index 77a113ae5c..e74bfe0b8d 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/templates/service.yaml @@ -16,7 +16,7 @@ spec: app.kubernetes.io/name: {{ include "backstage.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} -{{- if .Values.app.enabled }} +{{- if .Values.backend.enabled }} --- apiVersion: v1 kind: Service From 610619dba09e0c82be7abde8c16ae54456cc77d2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Apr 2021 19:45:40 +0200 Subject: [PATCH 032/176] docs: update install step in plugin architecture docs Signed-off-by: Patrik Oldsberg --- docs/overview/architecture-overview.md | 48 +++++++++++++++----------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index f2f2bc72c8..fab5698014 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -67,33 +67,41 @@ is available at ### Installing plugins -Plugins are typically loaded by the UI in your Backstage applications -`plugins.ts` file. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) -is that file in the Backstage sample app. +Plugins are typically installed as React components in your Backstage +application. For example, +[here](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) +is a file that imports many full-page plugins in the Backstage sample app. -Plugins can be enabled, and passed configuration in `apis.ts`. For example, -[here](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) -is that file in the Backstage sample app. - -This is how the Lighthouse plugin would be enabled in a typical Backstage -application: +An example of one of these plugin components is the `CatalogIndexPage`, which is +a full-page view that allows you to browse entities in the Backstage catalog. It +is installed in the app by importing it and adding it as an element like this: ```tsx -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; +import { CatalogIndexPage } from '@backstage/plugin-catalog'; -const builder = ApiRegistry.builder(); +... -export const lighthouseApi = new LighthouseRestApi(/* URL of the lighthouse microservice! */); -builder.add(lighthouseApiRef, lighthouseApi); - -export default builder.build() as ApiHolder; +const routes = ( + + ... + } /> + ... + +); ``` +Note that we use `"/catalog"` as our path to this plugin page, but we can choose +any route we want for the page, as long as it doesn't collide with the routes +that we choose for the other plugins in the app. + +These components that are exported from plugins are referred to as "Plugin +Extension Components", or "Extensions Components". They are regular React +components, but in addition to being able to be rendered by React, they also +contain various pieces of metadata that is used to write together the entire +app. Extensions components are created using `create*Extension` methods, which +you can read more about in the +[composability documentation](../plugins/composability.md). + As of this moment, there is no config based install procedure for plugins. Some code changes are required. From c614ede9a5b56232d2b8cb655bcc9a903d4ac9a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Apr 2021 20:12:33 +0200 Subject: [PATCH 033/176] docs: update plugin installation docs Signed-off-by: Patrik Oldsberg --- .changeset/gentle-days-return.md | 21 ++++++ docs/features/kubernetes/installation.md | 12 +--- .../features/software-catalog/installation.md | 10 +-- .../software-templates/installation.md | 10 +-- docs/features/techdocs/getting-started.md | 10 +-- .../configure-app-with-plugins.md | 9 +-- docs/plugins/structure-of-a-plugin.md | 3 +- plugins/api-docs/README.md | 14 +--- plugins/bitrise/README.md | 6 -- plugins/catalog-import/README.md | 10 +-- plugins/circleci/README.md | 30 ++++----- plugins/cost-insights/README.md | 14 ++-- plugins/explore/README.md | 10 +-- plugins/fossa/README.md | 16 ++--- plugins/github-actions/README.md | 24 +++++-- plugins/github-deployments/README.md | 12 +--- plugins/graphiql/README.md | 13 ++-- plugins/jenkins/README.md | 19 ++++-- plugins/lighthouse/README.md | 19 ++---- .../lighthouse/src/components/Intro/index.tsx | 11 +-- plugins/pagerduty/README.md | 6 -- plugins/register-component/README.md | 67 +++---------------- plugins/rollbar/README.md | 32 ++++----- plugins/sentry/README.md | 40 +++++------ plugins/sonarqube/README.md | 17 ++--- 25 files changed, 152 insertions(+), 283 deletions(-) create mode 100644 .changeset/gentle-days-return.md diff --git a/.changeset/gentle-days-return.md b/.changeset/gentle-days-return.md new file mode 100644 index 0000000000..96552173bd --- /dev/null +++ b/.changeset/gentle-days-return.md @@ -0,0 +1,21 @@ +--- +'@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/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index fed24c58d6..10968b427c 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -23,16 +23,8 @@ cd packages/app yarn add @backstage/plugin-kubernetes ``` -Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -`plugins.ts`: - -```typescript -export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; -``` - -Now, add the "Kubernetes" tab to the catalog entity page. In +Once the package has been installed, you need to import the plugin in your app +by adding the "Kubernetes" tab to the catalog entity page. In `packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get to the tab, and add the tab itself. diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index ff8dfbda68..df473f886f 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -24,14 +24,8 @@ yarn add @backstage/plugin-catalog ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { catalogPlugin } from '@backstage/plugin-catalog'; -``` - -Next we need to install the two pages that the catalog plugin provides. You can -choose any name for these routes, but we recommend the following: +Add the two pages that the catalog plugin provides to your app. You can choose +any name for these routes, but we recommend the following: ```tsx // packages/app/src/App.tsx diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 105b7e5f96..0abaab0aed 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -26,14 +26,8 @@ yarn add @backstage/plugin-scaffolder ### Adding the Plugin to your `packages/app` -Add the following entry to the head of your `packages/app/src/plugins.ts`: - -```ts -export { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -``` - -Next we need to install the root page that the Scaffolder plugin provides. You -can choose any path for the route, but we recommend the following: +Add the root page that the Scaffolder plugin provides to your app. You can +choose any path for the route, but we recommend the following: ```tsx import { ScaffolderPage } from '@backstage/plugin-scaffolder'; diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index a96e881849..934e91bd26 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -29,14 +29,8 @@ yarn add @backstage/plugin-techdocs Once the package has been installed, you need to import the plugin in your app. -Add the following to `packages/app/src/plugins.ts`: - -```typescript -export { plugin as TechDocs } from '@backstage/plugin-techdocs'; -``` - -Now we can add a route for the TechDocs page. In `packages/app/src/App.tsx`, -import TechDocsPage and add the following to `FlatRoutes`: +In `packages/app/src/App.tsx`, import TechDocsPage and add the following to +`FlatRoutes`: ```tsx import { TechDocsPage } from '@backstage/plugin-techdocs'; diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index b2f3a37d90..746b4bd940 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -27,14 +27,7 @@ package.json. Backstage Apps are set up as monorepos with [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). Since CircleCI is a frontend UI plugin, it goes in `app` rather than `backend`. -2. Add the plugin itself to the App: - -```js -// packages/app/src/plugins.ts -export { plugin as CircleCi } from '@backstage/plugin-circleci'; -``` - -3. Register the plugin in the entity pages: +2. Add the `EntityCircleCIContent` extension to the entity pages in the app: ```diff // packages/app/src/components/catalog/EntityPage.tsx diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 2aacd02cf2..f81e3794e5 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -100,8 +100,7 @@ There are three things needed for a Backstage app to start making use of a plugin. 1. Add plugin as dependency in `app/package.json` -2. `import` plugin in `app/src/plugins.ts` -3. Import and use one or more plugin extensions, for example in +2. Import and use one or more plugin extensions, for example in `app/src/App.tsx`. Luckily these three steps happen automatically when you create a plugin with the diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 4e71e87275..3d44eaaa29 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -33,17 +33,7 @@ To link that a component provides or consumes an API, see the [`providesApis`](h yarn add @backstage/plugin-api-docs ``` -2. Add the plugin to the app: - -```ts -// packages/app/src/plugins.ts - -export { apiDocsPlugin } from '@backstage/plugin-api-docs'; -``` - -} /> - -3. Register the `ApiExplorerPage` at the `/api-docs` path: +2. Add the `ApiExplorerPage` extensions to the app: ```tsx // packages/app/src/App.tsx @@ -53,7 +43,7 @@ import { ApiExplorerPage } from '@backstage/plugin-api-docs'; } />; ``` -4. Add one of the provided widgets to the EntityPage: +3. Add one of the provided widgets to the EntityPage: ```tsx // packages/app/src/components/catalog/EntityPage.tsx diff --git a/plugins/bitrise/README.md b/plugins/bitrise/README.md index 59d85b0a98..ae3917d78a 100644 --- a/plugins/bitrise/README.md +++ b/plugins/bitrise/README.md @@ -11,12 +11,6 @@ Welcome to the Bitrise plugin! $ yarn add @backstage/plugin-bitrise ``` -Then make sure to export the plugin in your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: - -```js -export { bitrisePlugin } from '@backstage/plugin-bitrise'; -``` - Bitrise Plugin exposes an "Entity Tab Content" component `EntityBitriseContent`. You can include it in the [`EntityPage.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/catalog/EntityPage.tsx)`: ```tsx diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index 97371ce0e7..90190e4356 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -23,15 +23,7 @@ Some features are not yet available for all supported Git providers. yarn add @backstage/plugin-catalog-import ``` -2. Add the plugin to the app: - -```ts -// packages/app/src/plugins.ts - -export { catalogImportPlugin } from '@backstage/plugin-catalog-import'; -``` - -3. Register the `CatalogImportPage` at the `/catalog-import` path: +2. Add the `CatalogImportPage` extensions to the app: ```tsx // packages/app/src/App.tsx diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index d30e950aa9..868ae6e26a 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -13,26 +13,22 @@ Website: [https://circleci.com/](https://circleci.com/) yarn add @backstage/plugin-circleci ``` -2. Add plugin itself: +2. Add the `EntityCircleCIContent` extension to the entity page in the app: -```js -// packages/app/src/plugins.ts -export { plugin as Circleci } from '@backstage/plugin-circleci'; -``` - -3. Register the plugin router: - -```jsx +```tsx // packages/app/src/components/catalog/EntityPage.tsx +import { EntityCircleCIContent } from '@backstage/plugin-circleci'; -import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; - -// Then somewhere inside -} -/>; +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 4. Add proxy config: diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index bbd047be14..f737c022f3 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -51,11 +51,17 @@ export const apis = [ ]; ``` -4. Add cost-insights to your Backstage plugins. +4. Add the `CostInsightsPage` extension to your `App.tsx`: -```ts -// packages/app/src/plugins.ts -export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +```tsx +// packages/app/src/App.tsx +import { CostInsightsPage } from '@backstage/plugin-cost-insights'; + + + ... + } /> + ... +; ``` 5. Add Cost Insights to your app Sidebar. diff --git a/plugins/explore/README.md b/plugins/explore/README.md index b236af14dc..fea1333e34 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -5,13 +5,7 @@ This plugin helps to visualize the domains and tools in your ecosystem. ## Getting started -To install the plugin, include the following import your `plugins.ts`: - -```typescript -export { explorePlugin } from '@backstage/plugin-explore'; -``` - -Register and bind the route in `App.tsx`: +To install the plugin, add and bind the route in `App.tsx`: ```typescript import { ExplorePage, explorePlugin } from '@backstage/plugin-explore'; @@ -30,7 +24,7 @@ bindRoutes({ bind }) { } /> ``` -Add a link to the sidebar in `Root.tsx`: +And add a link to the sidebar in `Root.tsx`: ```typescript import LayersIcon from '@material-ui/icons/Layers'; diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 049a1c25cb..11f3e90e1c 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -14,15 +14,7 @@ The FOSSA Plugin displays code statistics from [FOSSA](https://fossa.com/). yarn add @backstage/plugin-fossa ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts - -export { fossaPlugin } from '@backstage/plugin-fossa'; -``` - -3. Add the `EntityFossaCard` to the EntityPage: +2. Add the `EntityFossaCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx @@ -40,7 +32,7 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( ); ``` -4. Add the proxy config: +3. Add the proxy config: ```yaml # app-config.yaml @@ -57,9 +49,9 @@ fossa: organizationId: ``` -5. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) +4. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens) -6. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: +5. Add the `fossa.io/project-name` annotation to your catalog-info.yaml file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index d47144351e..328ef6054d 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -35,17 +35,29 @@ TBD ### Standalone app requirements -If you didn't clone this repo you have to do some extra work. - -1. Add plugin API to your Backstage instance: +1. Install the plugin dependency in your Backstage app package: ```bash +cd packages/app yarn add @backstage/plugin-github-actions ``` -```js -// packages/app/src/plugins.ts -export { plugin as GithubActions } from '@backstage/plugin-github-actions'; +2. Add to the app `EntityPage` component: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityGithubActionsContent } from '@backstage/plugin-github-actions'; + +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 2. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`. diff --git a/plugins/github-deployments/README.md b/plugins/github-deployments/README.md index 174d1bc8c7..1bb8846105 100644 --- a/plugins/github-deployments/README.md +++ b/plugins/github-deployments/README.md @@ -18,15 +18,7 @@ The GitHub Deployments Plugin displays recent deployments from GitHub. yarn add @backstage/plugin-github-deployments ``` -2. Add the plugin to the app - -```typescript -// packages/app/src/plugins.ts - -export { githubDeploymentsPlugin as GithubDeploymentsPlugin } from '@backstage/plugin-github-deployments'; -``` - -3. Add the `EntityGithubDeploymentsCard` to the EntityPage: +2. Add the `EntityGithubDeploymentsCard` to the EntityPage: ```typescript // packages/app/src/components/catalog/EntityPage.tsx @@ -44,7 +36,7 @@ const OverviewContent = () => ( ); ``` -4. Add the `github.com/project-slug` annotation to your `catalog-info.yaml` file: +3. Add the `github.com/project-slug` annotation to your `catalog-info.yaml` file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/graphiql/README.md b/plugins/graphiql/README.md index c75d8b71e3..39372890d1 100644 --- a/plugins/graphiql/README.md +++ b/plugins/graphiql/README.md @@ -16,18 +16,13 @@ cd packages/app yarn add @backstage/plugin-graphiql ``` -```diff -# in packages/app/src/plugins.ts -+export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; -``` - ```diff # in packages/app/src/App.tsx -+import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; ++import { GraphiQLPage } from '@backstage/plugin-graphiql'; - const AppRoutes = () => ( - -+ } /> + const routes = ( + ++ } /> ``` ### Adding GraphQL endpoints diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index b3bc21b7d5..f406393190 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -14,11 +14,22 @@ Website: [https://jenkins.io/](https://jenkins.io/) yarn add @backstage/plugin-jenkins ``` -2. Add plugin: +2. Add the `EntityJenkinsContent` extension to the entity page in the app: -```js -// packages/app/src/plugins.ts -export { plugin as Jenkins } from '@backstage/plugin-jenkins'; +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityJenkinsContent } from '@backstage/plugin-circleci'; + +// ... +const serviceEntityPage = ( + + ... + + + + ... + +); ``` 3. Add proxy configuration to `app-config.yaml` diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index 149172b7e8..028547ea3e 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -31,26 +31,17 @@ When you have an instance running that Backstage can hook into, first install th $ yarn add @backstage/plugin-lighthouse ``` -Then make sure to export the plugin in -your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) -to enable the plugin: - -```js -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -``` - -Modify your app routes in `App.tsx` to include the Router component exported from the plugin, for example: +Modify your app routes in `App.tsx` to include the `LighthousePage` component exported from the plugin, for example: ```tsx // At the top imports -import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; +import { LighthousePage } from '@backstage/plugin-lighthouse'; -// Inside App component - + // ... - } /> + } /> // ... -; +; ``` Then configure the `lighthouse-audit-service` URL in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). diff --git a/plugins/lighthouse/src/components/Intro/index.tsx b/plugins/lighthouse/src/components/Intro/index.tsx index 25ffccabbd..a89518a5ed 100644 --- a/plugins/lighthouse/src/components/Intro/index.tsx +++ b/plugins/lighthouse/src/components/Intro/index.tsx @@ -49,19 +49,12 @@ When you have an instance running that Backstage can hook into, first install th $ yarn add @backstage/plugin-lighthouse \`\`\` -Then make sure to export the plugin in your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: +Modify your app routes in \`App.tsx\` to include the \`LighthousePage\` component exported from the plugin, for example: -\`\`\`js -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; -\`\`\` - -Modify your app routes in \`App.tsx\` or \`App.jsx\` to include the Router component exported from the plugin, for example: - -\`\`\`js +\`\`\`tsx // At the top imports import { LighthousePage } from '@backstage/plugin-lighthouse'; -// Inside App component // ... } /> diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 9d8023b9fe..36f380fdd5 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -22,12 +22,6 @@ Install the plugin: yarn add @backstage/plugin-pagerduty ``` -Add it to the app in `plugins.ts`: - -```ts -export { plugin as Pagerduty } from '@backstage/plugin-pagerduty'; -``` - Add it to the `EntityPage.tsx`: ```ts diff --git a/plugins/register-component/README.md b/plugins/register-component/README.md index 0abf27590f..9c5304385e 100644 --- a/plugins/register-component/README.md +++ b/plugins/register-component/README.md @@ -16,69 +16,24 @@ When installed it is accessible on [localhost:3000/register-component](localhost ## Standalone setup -0. Install plugin and its dependency `plugin-catalog` +1. Install plugin and its dependency `plugin-catalog` ```bash -yarn add @backstage/plugin-register-component -W -yarn add @backstage/plugin-catalog -W +cd packages/app +yarn add @backstage/plugin-register-component ``` -1. Add dependencies to the active plugins list +2. Add the `RegisterComponentPage` extension to your `App.tsx`: -```typescript -// packages/app/src/plugins.ts -export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; -export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; -``` - -2. Create `packages/app/src/apis.ts` and register all the needed plugins - -```typescript -import { - alertApiRef, - AlertApiForwarder, - ApiRegistry, - ConfigApi, - errorApiRef, - ErrorApiForwarder, - ErrorAlerter, -} from '@backstage/core'; - -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; - -export const apis = (config: ConfigApi) => { - const backendUrl = config.getString('backend.baseUrl'); - - const builder = ApiRegistry.builder(); - - const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - const errorApi = builder.add( - errorApiRef, - new ErrorAlerter(alertApi, new ErrorApiForwarder()), - ); - - builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: backendUrl, - basePath: '/catalog', - }), - ); - - return builder.build(); -}; -``` - -3. Pass `apis` to createApp - -```typescript +```tsx // packages/app/src/App.tsx -import { apis } from './apis'; +import { RegisterComponentPage } from '@backstage/plugin-cost-insights'; -const app = createApp({ - apis, - plugins: Object.values(plugins), -}); + + ... + } /> + ... +; ``` ## Running diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index 993b93d3a0..584bd7d6bc 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -12,33 +12,25 @@ Website: [https://rollbar.com/](https://rollbar.com/) yarn add @backstage/plugin-rollbar ``` -3. Add plugin to the list of plugins: +3. Add to the app `EntityPage` component: -```ts -// packages/app/src/plugins.ts -export { plugin as Rollbar } from '@backstage/plugin-rollbar'; -``` - -4. Add to the app `EntityPage` component: - -```ts +```tsx // packages/app/src/components/catalog/EntityPage.tsx -import { Router as RollbarRouter } from '@backstage/plugin-rollbar'; +import { EntityRollbarContent } from '@backstage/plugin-rollbar'; // ... -const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( +const serviceEntityPage = ( - // ... - } - /> + ... + + + + ... ); ``` -5. Setup the `app.config.yaml` and account token environment variable +4. Setup the `app.config.yaml` and account token environment variable ```yaml # app.config.yaml @@ -48,7 +40,7 @@ rollbar: accountToken: ${ROLLBAR_ACCOUNT_TOKEN} ``` -6. Annotate entities with the rollbar project slug +5. Annotate entities with the rollbar project slug ```yaml # pump-station-catalog-component.yaml @@ -60,7 +52,7 @@ metadata: rollbar.com/project-slug: project-name ``` -7. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity +6. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity ## Features diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 52cef9cbe6..9bdb2dc16d 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -14,53 +14,43 @@ The Sentry Plugin displays issues from [Sentry](https://sentry.io). yarn add @backstage/plugin-sentry ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts - -export { plugin as Sentry } from '@backstage/plugin-sentry'; -``` - -3. Add the `SentryIssuesWidget` to the EntityPage: +2. Add the `EntitySentryCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx -import { SentryIssuesWidget } from '@backstage/plugin-sentry'; +import { EntitySentryCard } from '@backstage/plugin-sentry'; -const OverviewContent = ({ entity }: { entity: Entity }) => ( +const overviewContent = ( // ... - + // ... ); ``` -> You can also import a `Router` if you want to have a dedicated sentry page: +> You can also import the full-page `EntitySentryContent` extension if you want to have a dedicated sentry page: > > ```tsx > // packages/app/src/components/catalog/EntityPage.tsx > -> import { Router as SentryRouter } from '@backstage/plugin-sentry'; +> import { EntitySentryContent } from '@backstage/plugin-sentry'; > -> const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( -> +> const serviceEntityPage = ( +> > // ... -> path="/sentry" -> title="Sentry" -> element={} -> /> +> +> +> > // ... -> +> > ); > ``` -4. Add the proxy config: +3. Add the proxy config: ```yaml # app-config.yaml @@ -76,9 +66,9 @@ sentry: organization: ``` -5. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. +4. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. -6. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: +5. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index 7c745edf90..5c12832e57 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -13,14 +13,7 @@ The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarclo yarn add @backstage/plugin-sonarqube ``` -2. Add plugin to the app: - -```js -// packages/app/src/plugins.ts -export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; -``` - -3. Add the `EntitySonarQubeCard` to the EntityPage: +2. Add the `EntitySonarQubeCard` to the EntityPage: ```diff // packages/app/src/components/catalog/EntityPage.tsx @@ -40,7 +33,7 @@ export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; ); ``` -4. Add the proxy config: +3. Add the proxy config: Provide a method for your Backstage backend to get to your SonarQube API end point. Add configuration to your `app-config.yaml` file depending on the product you use. @@ -77,16 +70,16 @@ sonarQube: baseUrl: https://your.sonarqube.instance.com ``` -5. Get and provide `SONARQUBE_AUTH` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) +4. Get and provide `SONARQUBE_AUTH` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) -6. Run the following commands in the root folder of the project to install and compile the changes. +5. Run the following commands in the root folder of the project to install and compile the changes. ```yaml yarn install yarn tsc ``` -7. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. +6. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. ```yaml apiVersion: backstage.io/v1alpha1 From 8e067f00bec02b3666b063cf2f0b8a96ce08f9f6 Mon Sep 17 00:00:00 2001 From: azhurbilo Date: Thu, 22 Apr 2021 20:22:45 +0300 Subject: [PATCH 034/176] 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 035/176] 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 39bdaa0046f41c395d0e43f7b00d6d4239471b30 Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Tue, 6 Apr 2021 17:18:38 +0800 Subject: [PATCH 036/176] Add TechDocs landing page customization and exported components Signed-off-by: Chongyang Adrian, Ke --- .changeset/techdocs-quiet-pens-prove.md | 5 + docs/features/techdocs/how-to-guides.md | 77 ++++++++ ...Content.test.tsx => DocsCardGrid.test.tsx} | 17 +- .../src/home/components/DocsCardGrid.tsx | 58 ++++++ ...nedContent.test.tsx => DocsTable.test.tsx} | 44 +---- .../{OwnedContent.tsx => DocsTable.tsx} | 68 +++---- .../src/home/components/OverviewContent.tsx | 73 -------- .../components/TechDocsCustomHome.test.tsx | 83 +++++++++ .../home/components/TechDocsCustomHome.tsx | 169 ++++++++++++++++++ .../src/home/components/TechDocsHome.test.tsx | 26 ++- .../src/home/components/TechDocsHome.tsx | 118 ++++-------- plugins/techdocs/src/index.ts | 5 + plugins/techdocs/src/plugin.ts | 39 ++++ 13 files changed, 538 insertions(+), 244 deletions(-) create mode 100644 .changeset/techdocs-quiet-pens-prove.md rename plugins/techdocs/src/home/components/{OverviewContent.test.tsx => DocsCardGrid.test.tsx} (77%) create mode 100644 plugins/techdocs/src/home/components/DocsCardGrid.tsx rename plugins/techdocs/src/home/components/{OwnedContent.test.tsx => DocsTable.test.tsx} (68%) rename plugins/techdocs/src/home/components/{OwnedContent.tsx => DocsTable.tsx} (67%) delete mode 100644 plugins/techdocs/src/home/components/OverviewContent.tsx create mode 100644 plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx create mode 100644 plugins/techdocs/src/home/components/TechDocsCustomHome.tsx diff --git a/.changeset/techdocs-quiet-pens-prove.md b/.changeset/techdocs-quiet-pens-prove.md new file mode 100644 index 0000000000..379c20e72a --- /dev/null +++ b/.changeset/techdocs-quiet-pens-prove.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Add customization and exportable components for TechDocs landing page diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 2cb769b0e3..a76905d6c5 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -82,3 +82,80 @@ Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 minutes which means they will not be re-built if new changes are made within 30 minutes. This cache invalidation will be replaced by commit timestamp based implementation very soon. + +## How to use a custom TechDocs home page? + +### 1st way: TechDocsCustomHome with a custom configuration + +In your main App.tsx: + +``` +import { + TechDocsCustomHome, + WidgetType, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; +import { Entity } from '@backstage/catalog-model'; + +const tabsConfig = [ + { + label: 'Custom Tab', + widgets: [ + { + title: 'Custom Documents Cards 1', + description: + 'Explore your internal technical ecosystem through documentation.', + // sets maximum height of widget, as CSS maxHeight attribute + widgetMaxHeight: '400px' + widgetType: 'DocsCardGrid' as WidgetType, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; + }, + { + title: 'Custom Documents Cards 2', + description: + 'Explore your internal technical ecosystem through documentation.', + widgetMaxHeight: '400px' + widgetType: 'DocsCardGrid' as WidgetType, + filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; + }, + ], + }, + { + label: 'Overview', + widgets: [ + { + title: 'Overview', + description: + 'Explore your internal technical ecosystem through documentation.', + widgetType: 'DocsTable' as WidgetType, + filterPredicate: () => true, + }, + ], + }, +]; + +const routes = ( + + } + /> + } + /> + +``` + +An example of tabsConfig that corresponds to the default documentation home page +can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. + +### 2nd way: custom home page plugin + +A custom home page plugin can be built that uses the components extensions +DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a +array of documentation entities ( have 'backstage.io/techdocs-ref' annotation ) +as an 'entities' attribute. + +For an reference to the React structure of the default home page, please refer +to `plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. diff --git a/plugins/techdocs/src/home/components/OverviewContent.test.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx similarity index 77% rename from plugins/techdocs/src/home/components/OverviewContent.test.tsx rename to plugins/techdocs/src/home/components/DocsCardGrid.test.tsx index ec67c346d7..1ddaa4c373 100644 --- a/plugins/techdocs/src/home/components/OverviewContent.test.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -17,13 +17,13 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; -import { OverviewContent } from './OverviewContent'; +import { DocsCardGrid } from './DocsCardGrid'; -describe('TechDocs Overview Content', () => { - it('should render all TechDocs Documents', async () => { +describe('Entity Docs Card Grid', () => { + it('should render all entities passed ot it', async () => { const { findByText } = render( wrapInTestApp( - { />, ), ); - - expect(await findByText('Overview')).toBeInTheDocument(); - expect( - await findByText( - /Explore your internal technical ecosystem through documentation./i, - ), - ).toBeInTheDocument(); expect(await findByText('testName')).toBeInTheDocument(); expect(await findByText('testName2')).toBeInTheDocument(); }); diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx new file mode 100644 index 0000000000..47f747442c --- /dev/null +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import { generatePath } from 'react-router-dom'; + +import { Entity } from '@backstage/catalog-model'; +import { Button, ItemCardGrid, ItemCardHeader } from '@backstage/core'; +import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; + +import { rootDocsRouteRef } from '../../plugin'; + +export const DocsCardGrid = ({ + entities, +}: { + entities: Entity[] | undefined; +}) => { + if (!entities) return null; + return ( + + {!entities?.length + ? null + : entities.map((entity, index: number) => ( + + + + + {entity.metadata.description} + + + + + ))} + + ); +}; diff --git a/plugins/techdocs/src/home/components/OwnedContent.test.tsx b/plugins/techdocs/src/home/components/DocsTable.test.tsx similarity index 68% rename from plugins/techdocs/src/home/components/OwnedContent.test.tsx rename to plugins/techdocs/src/home/components/DocsTable.test.tsx index edccb0d3c6..8a7d8d9af1 100644 --- a/plugins/techdocs/src/home/components/OwnedContent.test.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -16,37 +16,13 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { OwnedContent } from './OwnedContent'; +import { DocsTable } from './DocsTable'; -jest.mock('../hooks', () => ({ - useOwnUser: () => { - return { - value: { - apiVersion: 'version', - kind: 'User', - metadata: { - name: 'owned', - namespace: 'default', - }, - relations: [ - { - target: { - kind: 'TestKind', - name: 'testName', - }, - type: 'ownerOf', - }, - ], - }, - }; - }, -})); - -describe('TechDocs Owned Content', () => { - it('should render TechDocs Owned Documents', async () => { - const { findByText, queryByText } = render( +describe('DocsTable test', () => { + it('should render documents passed', async () => { + const { findByText } = render( wrapInTestApp( - { ), ); - expect(await findByText('Owned documents')).toBeInTheDocument(); - expect(await findByText(/Access your documentation./i)).toBeInTheDocument(); expect(await findByText('testName')).toBeInTheDocument(); - expect(await queryByText('testName2')).not.toBeInTheDocument(); + expect(await findByText('testName2')).toBeInTheDocument(); }); it('should render empty state if no owned documents exist', async () => { - const { findByText } = render( - wrapInTestApp(), - ); + const { findByText } = render(wrapInTestApp()); expect(await findByText('No documents to show')).toBeInTheDocument(); }); diff --git a/plugins/techdocs/src/home/components/OwnedContent.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx similarity index 67% rename from plugins/techdocs/src/home/components/OwnedContent.tsx rename to plugins/techdocs/src/home/components/DocsTable.tsx index f962147345..341ff852be 100644 --- a/plugins/techdocs/src/home/components/OwnedContent.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -20,46 +20,34 @@ import { generatePath } from 'react-router-dom'; import { IconButton, Tooltip } from '@material-ui/core'; import ShareIcon from '@material-ui/icons/Share'; -import { - Content, - ContentHeader, - SupportButton, - Table, - EmptyState, - Button, - SubvalueCell, - Link, -} from '@backstage/core'; +import { Table, EmptyState, Button, SubvalueCell, Link } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { isOwnerOf } from '@backstage/plugin-catalog-react'; import { rootDocsRouteRef } from '../../plugin'; -import { useOwnUser } from '../hooks'; -export const OwnedContent = ({ +export const DocsTable = ({ entities, + title, }: { entities: Entity[] | undefined; + title?: string | undefined; }) => { const [, copyToClipboard] = useCopyToClipboard(); - const { value: user } = useOwnUser(); - if (!entities || !user) return null; + if (!entities) return null; - const ownedDocuments = entities - .filter((entity: Entity) => isOwnerOf(user, entity)) - .map(entity => { - return { + const documents = entities.map(entity => { + return { + name: entity.metadata.name, + description: entity.metadata.description, + owner: entity?.spec?.owner, + type: entity?.spec?.type, + docsUrl: generatePath(rootDocsRouteRef.path, { + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, name: entity.metadata.name, - description: entity.metadata.description, - owner: entity?.spec?.owner, - type: entity?.spec?.type, - docsUrl: generatePath(rootDocsRouteRef.path, { - namespace: entity.metadata.namespace ?? 'default', - kind: entity.kind, - name: entity.metadata.name, - }), - }; - }); + }), + }; + }); const columns = [ { @@ -99,19 +87,17 @@ export const OwnedContent = ({ ]; return ( - - - Discover documentation you own. - - {ownedDocuments && ownedDocuments.length > 0 ? ( + <> + {documents && documents.length > 0 ? ( ) : ( )} - + ); }; diff --git a/plugins/techdocs/src/home/components/OverviewContent.tsx b/plugins/techdocs/src/home/components/OverviewContent.tsx deleted file mode 100644 index e0030691f5..0000000000 --- a/plugins/techdocs/src/home/components/OverviewContent.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 from 'react'; -import { generatePath } from 'react-router-dom'; - -import { Entity } from '@backstage/catalog-model'; -import { - Button, - Content, - ContentHeader, - SupportButton, - ItemCardGrid, - ItemCardHeader, -} from '@backstage/core'; -import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; - -import { rootDocsRouteRef } from '../../plugin'; - -export const OverviewContent = ({ - entities, -}: { - entities: Entity[] | undefined; -}) => { - if (!entities) return null; - return ( - - - Discover documentation in your ecosystem. - - - {!entities?.length - ? null - : entities.map((entity, index: number) => ( - - - - - {entity.metadata.description} - - - - - ))} - - - ); -}; diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx new file mode 100644 index 0000000000..a7f4d90240 --- /dev/null +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -0,0 +1,83 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { TechDocsCustomHome, WidgetType } from './TechDocsCustomHome'; + +describe('TechDocsCustomHome', () => { + const catalogApi: Partial = { + getEntities: async () => ({ items: [] }), + }; + + const apiRegistry = ApiRegistry.with(catalogApiRef, catalogApi); + + it('should render a TechDocs home page', async () => { + const tabsConfig = [ + { + label: 'First Tab', + widgets: [ + { + title: 'First Tab', + description: 'First Tab Description', + widgetType: 'DocsCardGrid' as WidgetType, + filterPredicate: () => true, + }, + ], + }, + { + label: 'Second Tab ', + widgets: [ + { + title: 'Second Tab', + description: 'Second Tab Description', + widgetType: 'DocsTable' as WidgetType, + filterPredicate: () => true, + }, + ], + }, + ]; + + await renderInTestApp( + + + , + ); + + // Header + expect(await screen.findByText('Documentation')).toBeInTheDocument(); + expect( + await screen.findByText(/Documentation available in Backstage/i), + ).toBeInTheDocument(); + + // Explore Content + expect(await screen.findByTestId('docs-explore')).toBeDefined(); + + // Tabs + expect(await screen.findAllByText('First Tab')).toHaveLength(2); + expect(await screen.findByText('Second Tab')).toBeInTheDocument(); + expect( + await screen.findByText('First Tab Description'), + ).toBeInTheDocument(); + (await screen.findByText('Second Tab')).click(); + expect( + await screen.findByText('Second Tab Description'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx new file mode 100644 index 0000000000..37a9dadddb --- /dev/null +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -0,0 +1,169 @@ +/* + * 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 React, { useState } from 'react'; +import { useAsync } from 'react-use'; +import { makeStyles } from '@material-ui/core'; + +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { + CodeSnippet, + Content, + ConfigApi, + configApiRef, + Header, + HeaderTabs, + Page, + Progress, + useApi, + WarningPanel, + SupportButton, + ContentHeader, +} from '@backstage/core'; +import { DocsTable } from './DocsTable'; +import { DocsCardGrid } from './DocsCardGrid'; + +const widgets = { + DocsTable: DocsTable, + DocsCardGrid: DocsCardGrid, +}; + +export type WidgetType = 'DocsCardGrid' | 'DocsTable'; + +export interface WidgetConfig { + title: string; + description: string; + widgetType: WidgetType; + widgetMaxHeight?: string; + filterPredicate: (entity: Entity) => boolean; +} + +export interface TabConfig { + label: string; + widgets: WidgetConfig[]; +} + +export type TabsConfig = TabConfig[]; + +const CustomWidget = ({ + config, + entities, + index, +}: { + config: WidgetConfig; + entities: Entity[]; + index: number; +}) => { + const useStyles = makeStyles({ + widgetContainer: { + maxHeight: config.widgetMaxHeight || 'inherit', + marginBottom: '2rem', + overflow: 'auto', + }, + }); + const classes = useStyles(); + const Widget = widgets[config.widgetType]; + const shownEntities = entities.filter(config.filterPredicate); + return ( + <> + + {index === 0 ? ( + + Discover documentation in your ecosystem. + + ) : null} + +
+ +
+ + ); +}; + +export const TechDocsCustomHome = ({ + tabsConfig, +}: { + tabsConfig: TabsConfig; +}) => { + const [selectedTab, setSelectedTab] = useState(0); + const catalogApi: CatalogApi = useApi(catalogApiRef); + const configApi: ConfigApi = useApi(configApiRef); + + const { value: entities, loading, error } = useAsync(async () => { + const response = await catalogApi.getEntities(); + return response.items.filter((entity: Entity) => { + return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; + }); + }); + + const generatedSubtitle = `Documentation available in ${ + configApi.getOptionalString('organization.name') ?? 'Backstage' + }`; + + const currentTabConfig = tabsConfig[selectedTab]; + + if (loading) { + return ( + +
+ + + + + ); + } + + if (error) { + return ( + +
+ + + + + + + ); + } + + return ( + +
+ setSelectedTab(index)} + tabs={tabsConfig.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + {currentTabConfig.widgets.map((config, index) => ( + + ))} + + + ); +}; diff --git a/plugins/techdocs/src/home/components/TechDocsHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsHome.test.tsx index 8067767d39..566ed3bd0b 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -27,6 +27,30 @@ import { screen } from '@testing-library/react'; import React from 'react'; import { TechDocsHome } from './TechDocsHome'; +jest.mock('../hooks', () => ({ + useOwnUser: () => { + return { + value: { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', + }, + relations: [ + { + target: { + kind: 'TestKind', + name: 'testName', + }, + type: 'ownerOf', + }, + ], + }, + }; + }, +})); + describe('TechDocs Home', () => { const catalogApi: Partial = { getEntities: async () => ({ items: [] }), diff --git a/plugins/techdocs/src/home/components/TechDocsHome.tsx b/plugins/techdocs/src/home/components/TechDocsHome.tsx index 5e9e25e7a0..904c759b11 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -14,88 +14,44 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { useAsync } from 'react-use'; - -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { - CodeSnippet, - ConfigApi, - configApiRef, - Content, - Header, - HeaderTabs, - Page, - Progress, - useApi, - WarningPanel, -} from '@backstage/core'; - -import { OverviewContent } from './OverviewContent'; -import { OwnedContent } from './OwnedContent'; +import { useOwnUser } from '../hooks'; +import { isOwnerOf } from '@backstage/plugin-catalog-react'; +import { WidgetType, TechDocsCustomHome } from './TechDocsCustomHome'; export const TechDocsHome = () => { - const [selectedTab, setSelectedTab] = useState(0); - const catalogApi: CatalogApi = useApi(catalogApiRef); - const configApi: ConfigApi = useApi(configApiRef); + const { value: user } = useOwnUser(); - const tabs = [{ label: 'Overview' }, { label: 'Owned Documents' }]; - - const { value, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities(); - return response.items.filter((entity: Entity) => { - return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; - }); - }); - - const generatedSubtitle = `Documentation available in ${ - configApi.getOptionalString('organization.name') ?? 'Backstage' - }`; - - if (loading) { - return ( - -
- - - - - ); - } - - if (error) { - return ( - -
- - - - - - - ); - } - - return ( - -
- setSelectedTab(index)} - tabs={tabs.map(({ label }, index) => ({ - id: index.toString(), - label, - }))} - /> - {selectedTab === 0 ? ( - - ) : ( - - )} - - ); + const tabsConfig = [ + { + label: 'Overview', + widgets: [ + { + title: 'Overview', + description: + 'Explore your internal technical ecosystem through documentation.', + widgetType: 'DocsCardGrid' as WidgetType, + filterPredicate: () => true, + }, + ], + }, + { + label: 'Owned', + widgets: [ + { + title: 'Owned documents', + description: 'Access your documentation.', + widgetType: 'DocsTable' as WidgetType, + filterPredicate: (entity: Entity) => { + if (!user) { + return false; + } + return isOwnerOf(user, entity); + }, + }, + ], + }, + ]; + return ; }; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 30c143e31f..f72938e385 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -19,7 +19,12 @@ export { techdocsPlugin as plugin, TechdocsPage, EntityTechdocsContent, + DocsCardGrid, + DocsTable, + TechDocsCustomHome, + TechDocsReaderPage, } from './plugin'; export { Router, EmbeddedDocsRouter } from './Router'; export * from './reader'; export * from './api'; +export type { WidgetType } from './home/components/TechDocsCustomHome'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index a945ca1494..d9b38a2c76 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -37,6 +37,7 @@ import { discoveryApiRef, identityApiRef, createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { techdocsStorageApiRef, @@ -111,3 +112,41 @@ export const EntityTechdocsContent = techdocsPlugin.provide( mountPoint: rootCatalogDocsRouteRef, }), ); + +// takes a list of entities and renders documentation cards +export const DocsCardGrid = techdocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./home/components/DocsCardGrid').then(m => m.DocsCardGrid), + }, + }), +); + +// takes a list of entities and renders table listing documentation +export const DocsTable = techdocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./home/components/DocsTable').then(m => m.DocsTable), + }, + }), +); + +// takes a custom tabs config object and renders a documentation landing page +export const TechDocsCustomHome = techdocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./home/components/TechDocsCustomHome').then( + m => m.TechDocsCustomHome, + ), + mountPoint: rootRouteRef, + }), +); + +export const TechDocsReaderPage = techdocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./reader/components/TechDocsPage').then(m => m.TechDocsPage), + mountPoint: rootDocsRouteRef, + }), +); From 10579c3d0daf824ae2aa41a63e280039e50e14b4 Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Mon, 19 Apr 2021 15:17:50 +0800 Subject: [PATCH 037/176] Update docs/features/techdocs/how-to-guides.md Co-authored-by: Himanshu Mishra Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index a76905d6c5..2b3fd08f76 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -89,7 +89,7 @@ implementation very soon. In your main App.tsx: -``` +```tsx import { TechDocsCustomHome, WidgetType, From 74c91998bfd295d2422a3b63327344bdf739ba8d Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Mon, 19 Apr 2021 15:17:55 +0800 Subject: [PATCH 038/176] Update docs/features/techdocs/how-to-guides.md Co-authored-by: Himanshu Mishra Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 2b3fd08f76..f387379e06 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -150,7 +150,7 @@ const routes = ( An example of tabsConfig that corresponds to the default documentation home page can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. -### 2nd way: custom home page plugin +### 2nd way: Custom home page plugin A custom home page plugin can be built that uses the components extensions DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a From 6db5564af60c4a704c476daad80e8aa8a13fdf62 Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Mon, 19 Apr 2021 15:18:01 +0800 Subject: [PATCH 039/176] Update docs/features/techdocs/how-to-guides.md Co-authored-by: Himanshu Mishra Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index f387379e06..e1dbe8b494 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -157,5 +157,5 @@ DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a array of documentation entities ( have 'backstage.io/techdocs-ref' annotation ) as an 'entities' attribute. -For an reference to the React structure of the default home page, please refer +For a reference to the React structure of the default home page, please refer to `plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. From 9f7d5662e3002356c06abe40dbfade36473a239c Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Mon, 19 Apr 2021 16:24:59 +0800 Subject: [PATCH 040/176] update how-to-guides.md Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index e1dbe8b494..1da925ae5a 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -154,8 +154,8 @@ can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. A custom home page plugin can be built that uses the components extensions DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a -array of documentation entities ( have 'backstage.io/techdocs-ref' annotation ) -as an 'entities' attribute. +array of documentation entities ( i.e.have a 'backstage.io/techdocs-ref' +annotation ) as an 'entities' attribute. -For a reference to the React structure of the default home page, please refer -to `plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. +For a reference to the React structure of the default home page, please refer to +`plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. From cb321bae90356b4eaa8609144e99ae082b2d939a Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Thu, 22 Apr 2021 11:53:30 +0800 Subject: [PATCH 041/176] change naming from widget to panel Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 18 ++++++----- .../src/home/components/DocsTable.tsx | 6 +++- .../components/TechDocsCustomHome.test.tsx | 10 +++---- .../home/components/TechDocsCustomHome.tsx | 30 +++++++++---------- .../src/home/components/TechDocsHome.tsx | 11 +++---- plugins/techdocs/src/index.ts | 2 +- 6 files changed, 43 insertions(+), 34 deletions(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 1da925ae5a..682051dd83 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -92,7 +92,7 @@ In your main App.tsx: ```tsx import { TechDocsCustomHome, - WidgetType, + PanelType, TechDocsReaderPage, } from '@backstage/plugin-techdocs'; import { Entity } from '@backstage/catalog-model'; @@ -105,17 +105,17 @@ const tabsConfig = [ title: 'Custom Documents Cards 1', description: 'Explore your internal technical ecosystem through documentation.', - // sets maximum height of widget, as CSS maxHeight attribute - widgetMaxHeight: '400px' - widgetType: 'DocsCardGrid' as WidgetType, + panelType: 'DocsCardGrid' as PanelType, + // optional, is applied to a container of the panel (excludes header of panel) + panelCSS: { maxHeight: '400px' }, filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; }, { title: 'Custom Documents Cards 2', description: 'Explore your internal technical ecosystem through documentation.', - widgetMaxHeight: '400px' - widgetType: 'DocsCardGrid' as WidgetType, + panelType: 'DocsCardGrid' as PanelType, + panelCSS: { maxHeight: '400px' }, filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; }, ], @@ -127,7 +127,7 @@ const tabsConfig = [ title: 'Overview', description: 'Explore your internal technical ecosystem through documentation.', - widgetType: 'DocsTable' as WidgetType, + panelType: 'DocsTable' as PanelType, filterPredicate: () => true, }, ], @@ -150,6 +150,10 @@ const routes = ( An example of tabsConfig that corresponds to the default documentation home page can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. +Currently `panelType` has DocsCardGrid and DocsTable available. We currently +recommend that DocsCardGrid can be optionally vertically stacked by setting a +maxHeight using `panelCSS`, and DocsTable to be in a tab by itself. + ### 2nd way: Custom home page plugin A custom home page plugin can be built that uses the components extensions diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index 341ff852be..cb0a3b4329 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -90,7 +90,11 @@ export const DocsTable = ({ <> {documents && documents.length > 0 ? (
{ const catalogApi: Partial = { @@ -32,22 +32,22 @@ describe('TechDocsCustomHome', () => { const tabsConfig = [ { label: 'First Tab', - widgets: [ + panels: [ { title: 'First Tab', description: 'First Tab Description', - widgetType: 'DocsCardGrid' as WidgetType, + panelType: 'DocsCardGrid' as PanelType, filterPredicate: () => true, }, ], }, { label: 'Second Tab ', - widgets: [ + panels: [ { title: 'Second Tab', description: 'Second Tab Description', - widgetType: 'DocsTable' as WidgetType, + panelType: 'DocsTable' as PanelType, filterPredicate: () => true, }, ], diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx index 37a9dadddb..ffcb02451b 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -37,46 +37,46 @@ import { import { DocsTable } from './DocsTable'; import { DocsCardGrid } from './DocsCardGrid'; -const widgets = { +const panels = { DocsTable: DocsTable, DocsCardGrid: DocsCardGrid, }; -export type WidgetType = 'DocsCardGrid' | 'DocsTable'; +export type PanelType = 'DocsCardGrid' | 'DocsTable'; -export interface WidgetConfig { +export interface PanelConfig { title: string; description: string; - widgetType: WidgetType; - widgetMaxHeight?: string; + panelType: PanelType; + panelCSS?: {}; filterPredicate: (entity: Entity) => boolean; } export interface TabConfig { label: string; - widgets: WidgetConfig[]; + panels: PanelConfig[]; } export type TabsConfig = TabConfig[]; -const CustomWidget = ({ +const CustomPanel = ({ config, entities, index, }: { - config: WidgetConfig; + config: PanelConfig; entities: Entity[]; index: number; }) => { const useStyles = makeStyles({ - widgetContainer: { - maxHeight: config.widgetMaxHeight || 'inherit', + panelContainer: { marginBottom: '2rem', overflow: 'auto', + ...(config.panelCSS ? config.panelCSS : {}), }, }); const classes = useStyles(); - const Widget = widgets[config.widgetType]; + const Panel = panels[config.panelType]; const shownEntities = entities.filter(config.filterPredicate); return ( <> @@ -87,8 +87,8 @@ const CustomWidget = ({ ) : null} -
- +
+
); @@ -155,8 +155,8 @@ export const TechDocsCustomHome = ({ }))} /> - {currentTabConfig.widgets.map((config, index) => ( - ( + { const { value: user } = useOwnUser(); @@ -26,23 +26,24 @@ export const TechDocsHome = () => { const tabsConfig = [ { label: 'Overview', - widgets: [ + panels: [ { title: 'Overview', description: 'Explore your internal technical ecosystem through documentation.', - widgetType: 'DocsCardGrid' as WidgetType, + panelType: 'DocsCardGrid' as PanelType, filterPredicate: () => true, }, ], }, { label: 'Owned', - widgets: [ + panels: [ { title: 'Owned documents', description: 'Access your documentation.', - widgetType: 'DocsTable' as WidgetType, + panelType: 'DocsTable' as PanelType, + paneCSS: { maxHeight: '200px' }, filterPredicate: (entity: Entity) => { if (!user) { return false; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index f72938e385..b33b91e2c5 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -27,4 +27,4 @@ export { export { Router, EmbeddedDocsRouter } from './Router'; export * from './reader'; export * from './api'; -export type { WidgetType } from './home/components/TechDocsCustomHome'; +export type { PanelType } from './home/components/TechDocsCustomHome'; From f5dab83b5972ae9c317a51a7b77ccf1c788b788e Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Fri, 23 Apr 2021 11:00:08 +0800 Subject: [PATCH 042/176] Update docs/features/techdocs/how-to-guides.md Signed-off-by: Eric Peterson Co-authored-by: Eric Peterson Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 682051dd83..1b19f12b8a 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -100,7 +100,7 @@ import { Entity } from '@backstage/catalog-model'; const tabsConfig = [ { label: 'Custom Tab', - widgets: [ + panels: [ { title: 'Custom Documents Cards 1', description: From 7a3b3b2be6bec8bbc733313e56cc0d396be2c16d Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Fri, 23 Apr 2021 11:00:17 +0800 Subject: [PATCH 043/176] Update docs/features/techdocs/how-to-guides.md Signed-off-by: Eric Peterson Co-authored-by: Eric Peterson Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 1b19f12b8a..384656ea38 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -122,7 +122,7 @@ const tabsConfig = [ }, { label: 'Overview', - widgets: [ + panels: [ { title: 'Overview', description: From 64ade20227f269f26ad3d4ffa1c20fcf6d223c77 Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Fri, 23 Apr 2021 11:00:30 +0800 Subject: [PATCH 044/176] Update plugins/techdocs/src/home/components/TechDocsHome.tsx Signed-off-by: Eric Peterson Co-authored-by: Eric Peterson Signed-off-by: Chongyang Adrian, Ke --- plugins/techdocs/src/home/components/TechDocsHome.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/techdocs/src/home/components/TechDocsHome.tsx b/plugins/techdocs/src/home/components/TechDocsHome.tsx index a9641391e2..ef2749015a 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.tsx @@ -43,7 +43,6 @@ export const TechDocsHome = () => { title: 'Owned documents', description: 'Access your documentation.', panelType: 'DocsTable' as PanelType, - paneCSS: { maxHeight: '200px' }, filterPredicate: (entity: Entity) => { if (!user) { return false; From 4666ddb275465dc499d9b9c30d2dbb3546233d4b Mon Sep 17 00:00:00 2001 From: Adrian Ke Chongyang <14866712+adrianke77@users.noreply.github.com> Date: Fri, 23 Apr 2021 11:00:37 +0800 Subject: [PATCH 045/176] Update plugins/techdocs/src/home/components/TechDocsHome.tsx Signed-off-by: Eric Peterson Co-authored-by: Eric Peterson Signed-off-by: Chongyang Adrian, Ke --- plugins/techdocs/src/home/components/TechDocsHome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/techdocs/src/home/components/TechDocsHome.tsx b/plugins/techdocs/src/home/components/TechDocsHome.tsx index ef2749015a..72f59d5446 100644 --- a/plugins/techdocs/src/home/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsHome.tsx @@ -37,7 +37,7 @@ export const TechDocsHome = () => { ], }, { - label: 'Owned', + label: 'Owned Documents', panels: [ { title: 'Owned documents', From 7dd94b075056ea9118047d4c66e0285f23e9139c Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Fri, 23 Apr 2021 11:20:46 +0800 Subject: [PATCH 046/176] add CSSProperties type for panelCSS Signed-off-by: Chongyang Adrian, Ke --- plugins/techdocs/package.json | 1 + plugins/techdocs/src/home/components/TechDocsCustomHome.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 88b3b6a1df..775e57d00f 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -41,6 +41,7 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.10.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx index ffcb02451b..7230adb29b 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -17,7 +17,7 @@ import React, { useState } from 'react'; import { useAsync } from 'react-use'; import { makeStyles } from '@material-ui/core'; - +import { CSSProperties } from '@material-ui/styles'; import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; import { @@ -48,7 +48,7 @@ export interface PanelConfig { title: string; description: string; panelType: PanelType; - panelCSS?: {}; + panelCSS?: CSSProperties; filterPredicate: (entity: Entity) => boolean; } From 813a4a04f55752bf273c2dac304f1d1dbd2152f8 Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Fri, 23 Apr 2021 11:38:09 +0800 Subject: [PATCH 047/176] fix DocsTable rendering of EmptyState Signed-off-by: Chongyang Adrian, Ke --- docs/features/techdocs/how-to-guides.md | 6 +++--- plugins/techdocs/src/home/components/TechDocsCustomHome.tsx | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 384656ea38..6c64e83a76 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -87,7 +87,7 @@ implementation very soon. ### 1st way: TechDocsCustomHome with a custom configuration -In your main App.tsx: +As an example, in your main App.tsx: ```tsx import { @@ -107,7 +107,7 @@ const tabsConfig = [ 'Explore your internal technical ecosystem through documentation.', panelType: 'DocsCardGrid' as PanelType, // optional, is applied to a container of the panel (excludes header of panel) - panelCSS: { maxHeight: '400px' }, + panelCSS: { maxHeight: '400px', overflow:'auto' }, filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; }, { @@ -115,7 +115,7 @@ const tabsConfig = [ description: 'Explore your internal technical ecosystem through documentation.', panelType: 'DocsCardGrid' as PanelType, - panelCSS: { maxHeight: '400px' }, + panelCSS: { maxHeight: '400px', overflow:'auto' }, filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; }, ], diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx index 7230adb29b..ff9f1bf101 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.tsx @@ -71,7 +71,6 @@ const CustomPanel = ({ const useStyles = makeStyles({ panelContainer: { marginBottom: '2rem', - overflow: 'auto', ...(config.panelCSS ? config.panelCSS : {}), }, }); From e363f6103ef99b73a5945dd95eee9bc9b344f11b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Apr 2021 04:14:11 +0000 Subject: [PATCH 048/176] chore(deps): bump serialize-error from 8.0.1 to 8.1.0 Bumps [serialize-error](https://github.com/sindresorhus/serialize-error) from 8.0.1 to 8.1.0. - [Release notes](https://github.com/sindresorhus/serialize-error/releases) - [Commits](https://github.com/sindresorhus/serialize-error/compare/v8.0.1...v8.1.0) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index df07058f48..ba1b3bfe73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23454,9 +23454,9 @@ serialize-error@^2.1.0: integrity sha1-ULZ51WNc34Rme9yOWa9OW4HV9go= serialize-error@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.0.1.tgz#7a67f8ecbbf28973b5a954a2852ff9f4eef52d99" - integrity sha512-r5o60rWFS+8/b49DNAbB+GXZA0SpDpuWE758JxDKgRTga05r3U5lwyksE91dYKDhXSmnu36RALj615E6Aj5pSg== + version "8.1.0" + resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz#3a069970c712f78634942ddd50fbbc0eaebe2f67" + integrity sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ== dependencies: type-fest "^0.20.2" From 0b95bc1c6bf96530ff716926bb5a1e5141363d32 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Fri, 23 Apr 2021 09:57:39 +0200 Subject: [PATCH 049/176] Align techdocs navigation with microsite Signed-off-by: Brian Fox --- mkdocs.yml | 153 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 96 insertions(+), 57 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 031fb56059..7826b3221d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,114 +7,153 @@ plugins: nav: - Overview: - What is Backstage?: 'overview/what-is-backstage.md' - - Backstage architecture: 'overview/architecture-overview.md' - - Roadmap: 'overview/roadmap.md' + - Architecture overview: 'overview/architecture-overview.md' + - Project Roadmap: 'overview/roadmap.md' - Vision: 'overview/vision.md' - - The Spotify story: 'overview/background.md' + - The Spotify Story: 'overview/background.md' - Strategies for adopting: 'overview/adopting.md' + - Stability Index: 'overview/stability-index.md' - Logo assets: 'overview/logos.md' - - Getting started: + - Getting Started: - Getting Started: 'getting-started/index.md' + - Create an App: 'getting-started/create-an-app.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' + - App configuration: + - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' + - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' + - Keeping Backstage Updated: 'getting-started/keeping-backstage-updated.md' + - Key Concepts: 'getting-started/concepts.md' - Contributors: 'getting-started/contributors.md' - - Demo deployment: https://backstage-demo.roadie.io - - Production deployments: - - Create an App: 'getting-started/create-an-app.md' - - App configuration: - - Configuring App with plugins: 'getting-started/configure-app-with-plugins.md' - - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - - Deployment scenarios: - - Docker: 'getting-started/deployment-docker.md' - - Kubernetes: 'getting-started/deployment-k8s.md' - - Kubernetes and Helm: 'getting-started/deployment-helm.md' - - Other: 'getting-started/deployment-other.md' - - Features: + - CLI: + - Overview: 'cli/index.md' + - Commands: 'cli/commands.md' + - Core Features: - Software Catalog: - Overview: 'features/software-catalog/index.md' - - Installation: 'features/software-catalog/installation.md' - - Configuration: 'features/software-catalog/configuration.md' - - System model: 'features/software-catalog/system-model.md' + - Installing in your Backstage App: 'features/software-catalog/installation.md' + - Catalog Configuration: 'features/software-catalog/configuration.md' + - System Model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' + - Well-known Relations: 'features/software-catalog/well-known-relations.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - API: 'features/software-catalog/api.md' - - Software creation templates: + - Kubernetes: + - Overview: 'features/kubernetes/index.md' + - Installation: 'features/kubernetes/installation.md' + - Configuration: 'features/kubernetes/configuration.md' + - Troubleshooting: 'features/kubernetes/troubleshooting.md' + - Software Templates: - Overview: 'features/software-templates/index.md' - - Installation: 'features/software-templates/installation.md' - - Adding templates: 'features/software-templates/adding-templates.md' - - Extending the Scaffolder: - - Overview: 'features/software-templates/extending/index.md' - - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Installing in your Backstage App: 'features/software-templates/installation.md' + - Adding your own Templates: 'features/software-templates/adding-templates.md' + - Writing Templates: 'features/software-templates/writing-templates.md' + - Builtin Actions: 'features/software-templates/builtin-actions.md' + - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' + - Writing Templates (Legacy): 'features/software-templates/legacy.md' - Backstage Search: - Overview: 'features/search/README.md' - - Architecture: 'features/search/architecture.md' + - Search Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - - Configuration: 'features/techdocs/configuration.md' + - TechDocs Configuration Options: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - Configuring CI/CD to generate and publish TechDocs sites: 'features/techdocs/configuring-ci-cd.md' - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' - - Kubernetes: - - Overview: 'features/kubernetes/index.md' - Integrations: + - Overview: 'integrations/index.md' + - Azure DevOps: + - Locations: 'integrations/azure/locations.md' + - Bitbucket: + - Locations: 'integrations/bitbucket/locations.md' + - Discovery: 'integrations/bitbucket/discovery.md' + - Datadog: + - Installation: 'integrations/datadog-rum/installation.md' - GitHub: + - Locations: 'integrations/github/locations.md' + - Discovery: 'integrations/github/discovery.md' - Org Data: 'integrations/github/org.md' - - LDAP: - - Org Data: 'integrations/ldap/org.md' + - GitLab: + - Locations: 'integrations/gitlab/locations.md' - Google Analytics: - Installation: 'integrations/google-analytics/installation.md' + - Google GCS: + - Locations: 'integrations/google-cloud-storage/locations.md' + - LDAP: + - Org Data: 'integrations/ldap/org.md' - Plugins: - - Overview: 'plugins/index.md' + - Intro to plugins: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - - Creating a new plugin: 'plugins/create-a-plugin.md' - - Developing a plugin: 'plugins/plugin-development.md' + - Create a Backstage Plugin: 'plugins/create-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Plugin Development: 'plugins/plugin-development.md' + - Integrate into the Service Catalog: 'plugins/integrating-plugin-into-service-catalog.md' - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - - Backstage backend plugin: 'plugins/backend-plugin.md' + - Backend plugin: 'plugins/backend-plugin.md' - Call existing API: 'plugins/call-existing-api.md' + - GitHub Apps for Backend Authentication: 'plugins/github-apps.md' - Testing: - - Overview: 'plugins/testing.md' + - Testing with Jest: 'plugins/testing.md' - Publishing: - - Open source and npm: 'plugins/publishing.md' - - Private/internal (non-open source): 'plugins/publish-private.md' + - Publishing: 'plugins/publishing.md' + - Publish private: 'plugins/publish-private.md' + - Add to Marketplace: 'plugins/add-to-marketplace.md' - Observability: 'plugins/observability.md' - Configuration: - - Overview: 'conf/index.md' - - Reading Configuration: 'conf/reading.md' - - Writing Configuration: 'conf/writing.md' - - Defining Configuration: 'conf/defining.md' + - Static Configuration in Backstage: 'conf/index.md' + - Reading Backstage Configuration: 'conf/reading.md' + - Writing Backstage Configuration: 'conf/writing.md' + - Defining Configuration for your Plugin: 'conf/defining.md' - Authentication and identity: - - Overview: 'auth/index.md' - - Add auth provider: 'auth/add-auth-provider.md' + - Adding Authentication: 'auth/index.md' + - Included providers: + - Auth0: 'auth/auth0/provider.md' + - Azure: 'auth/microsoft/provider.md' + - GitHub: 'auth/github/provider.md' + - GitLab: 'auth/gitlab/provider.md' + - Google: 'auth/google/provider.md' + - Okta: 'auth/okta/provider.md' + - OneLogin: 'auth/onelogin/provider.md' + - Adding authentication providers: 'auth/add-auth-provider.md' + - Using authentication and identity: 'auth/using-auth.md' - Auth backend: 'auth/auth-backend.md' - - Auth backend class structure: 'auth/auth-backend-classes.md' - - OAuth: 'auth/oauth.md' + - OAuth and OpenID Connect: 'auth/oauth.md' + - Auth backend classes: 'auth/auth-backend-classes.md' - Glossary: 'auth/glossary.md' + - Deployment: + - Deploying Backstage: 'deployment/index.md' + - Docker: 'deployment/docker.md' + - Kubernetes: 'deployment/k8s.md' + - Helm: 'deployment/helm.md' + - Heroku: 'deployment/heroku.md' - Designing for Backstage: - - Backstage Design Language System (DLS): 'dls/design.md' - - Storybook -- reusable UI components: 'http://backstage.io/storybook' + - Design: 'dls/design.md' - Contributing to Storybook: 'dls/contributing-to-storybook.md' - - Figma resources: 'dls/figma.md' + - Figma: 'dls/figma.md' - API references: - - TypeScript APIs: - - Utilities: 'api/utility-apis.md' + - TypeScript API: + - Utility APIs: 'api/utility-apis.md' + - reference/utility-apis/README: 'reference/utility-apis/README.md' - createPlugin: 'reference/createPlugin.md' - - createPlugin-feature-flags: 'reference/createPlugin-feature-flags.md' + - createPlugin -feature flags: 'reference/createPlugin-feature-flags.md' - Backend APIs: - Backend: 'api/backend.md' - Tutorials: - - Overview: 'tutorials/index.md' + - Future developer journey: 'tutorials/journey.md' + - Monorepo App Setup With Authentication: 'tutorials/quickstart-app-auth.md' + - Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md' + - Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md' - Architecture Decision Records (ADRs): - Overview: 'architecture-decisions/index.md' - ADR001 - Architecture Decision Record (ADR) log: 'architecture-decisions/adr001-add-adr-log.md' @@ -129,7 +168,7 @@ nav: - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md' - Support: - - 'support/support.md' - - 'support/project-structure.md' + - Support and community: 'support/support.md' + - Backstage Project Structure: 'support/project-structure.md' - Glossary: glossary.md - FAQ: FAQ.md From a3a171ccd2a1db14fcd22dae6daeca5de53ea193 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Fri, 23 Apr 2021 09:31:00 +0200 Subject: [PATCH 050/176] Microsite nav - Alphabetic order please Signed-off-by: Brian Fox --- microsite/sidebars.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/microsite/sidebars.json b/microsite/sidebars.json index e2a57bd426..c7b049a53e 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -135,13 +135,13 @@ }, { "type": "subcategory", - "label": "LDAP", - "ids": ["integrations/ldap/org"] + "label": "Google GCS", + "ids": ["integrations/google-cloud-storage/locations"] }, { "type": "subcategory", - "label": "Google GCS", - "ids": ["integrations/google-cloud-storage/locations"] + "label": "LDAP", + "ids": ["integrations/ldap/org"] } ], "Plugins": [ From 5652f4eee5b94c5522cb69cf4d643bfd57ffa441 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Fri, 23 Apr 2021 09:31:35 +0200 Subject: [PATCH 051/176] Fix broken link in Software Templates -> Installation Signed-off-by: Brian Fox --- docs/features/software-templates/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 105b7e5f96..7d42c71af9 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -265,7 +265,7 @@ the templates at [localhost:3000/create](http://localhost:3000/create) now! Software Templates use [Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating library. By default it will use the -[spotify/backstage-cookiecutter](<[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)>) +[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile) docker image. If you are running backstage from a Docker container and you want to avoid From f09f6a166bd1acd9b2026af18ef827d51a3ca893 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 12:41:53 +0200 Subject: [PATCH 052/176] Use interface and not implementation in ApiRef Closes #4404 Signed-off-by: Oliver Sand --- .changeset/swift-waves-sleep.md | 12 + plugins/techdocs/dev/api.ts | 8 +- plugins/techdocs/src/api.ts | 236 +---------------- .../src/{api.test.ts => client.test.ts} | 8 +- plugins/techdocs/src/client.ts | 244 ++++++++++++++++++ plugins/techdocs/src/index.ts | 17 +- plugins/techdocs/src/plugin.ts | 22 +- plugins/techdocs/src/types.ts | 4 + 8 files changed, 296 insertions(+), 255 deletions(-) create mode 100644 .changeset/swift-waves-sleep.md rename plugins/techdocs/src/{api.test.ts => client.test.ts} (87%) create mode 100644 plugins/techdocs/src/client.ts diff --git a/.changeset/swift-waves-sleep.md b/.changeset/swift-waves-sleep.md new file mode 100644 index 0000000000..831ecc0001 --- /dev/null +++ b/.changeset/swift-waves-sleep.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +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/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 7acf3c7032..e764bf82bb 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DiscoveryApi, IdentityApi } from '@backstage/core'; -import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; -import { TechDocsStorage } from '../src/api'; +import { Config } from '@backstage/config'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { NotFoundError } from '@backstage/errors'; +import { TechDocsStorageApi } from '../src/api'; -export class TechDocsDevStorageApi implements TechDocsStorage { +export class TechDocsDevStorageApi implements TechDocsStorageApi { public configApi: Config; public discoveryApi: DiscoveryApi; public identityApi: IdentityApi; diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 0be530cc08..204cc1b5a8 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core'; -import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; -import { TechDocsMetadata } from './types'; -import { NotFoundError } from '@backstage/errors'; +import { createApiRef } from '@backstage/core'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -30,7 +28,10 @@ export const techdocsApiRef = createApiRef({ description: 'Used to make requests towards techdocs API', }); -export interface TechDocsStorage { +export interface TechDocsStorageApi { + getApiOrigin(): Promise; + getStorageUrl(): Promise; + getBuilder(): Promise; getEntityDocs(entityId: EntityName, path: string): Promise; syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( @@ -40,227 +41,8 @@ export interface TechDocsStorage { ): Promise; } -export interface TechDocs { +export interface TechDocsApi { + getApiOrigin(): Promise; getTechDocsMetadata(entityId: EntityName): Promise; - getEntityMetadata(entityId: EntityName): Promise; -} - -/** - * API to talk to techdocs-backend. - * - * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API - */ -export class TechDocsApi implements TechDocs { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - /** - * Retrieve TechDocs metadata. - * - * When docs are built, we generate a techdocs_metadata.json and store it along with the generated - * static files. It includes necessary data about the docs site. This method requests techdocs-backend - * which retrieves the TechDocs metadata. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - */ - async getTechDocsMetadata(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - const res = await request.json(); - - return res; - } - - /** - * Retrieve metadata about an entity. - * - * This method requests techdocs-backend which uses the catalog APIs to respond with filtered - * information required here. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - */ - async getEntityMetadata(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - const res = await request.json(); - - return res; - } -} - -/** - * API which talks to TechDocs storage to fetch files to render. - * - * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API - */ -export class TechDocsStorageApi implements TechDocsStorage { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - async getStorageUrl() { - return ( - this.configApi.getOptionalString('techdocs.storageUrl') ?? - `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` - ); - } - - async getBuilder() { - return this.configApi.getString('techdocs.builder'); - } - - /** - * Fetch HTML content as text for an individual docs page in an entity's docs site. - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new - * @returns {string} HTML content of the docs page as string - * @throws {Error} Throws error when the page is not found. - */ - async getEntityDocs(entityId: EntityName, path: string) { - const { kind, namespace, name } = entityId; - - const storageUrl = await this.getStorageUrl(); - const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, - ); - - let errorMessage = ''; - switch (request.status) { - case 404: - errorMessage = 'Page not found. '; - // path is empty for the home page of an entity's docs site - if (!path) { - errorMessage += - 'This could be because there is no index.md file in the root of the docs directory of this repository.'; - } - throw new NotFoundError(errorMessage); - case 500: - errorMessage = - 'Could not generate documentation or an error in the TechDocs backend. '; - throw new Error(errorMessage); - default: - // Do nothing - break; - } - - return request.text(); - } - - /** - * Check if docs are on the latest version and trigger rebuild if not - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version - * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend - */ - async syncEntityDocs(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; - const token = await this.identityApi.getIdToken(); - let request; - let attempts: number = 0; - // retry if request times out, up to 5 times - // can happen due to docs taking too long to generate - while (!request || (request.status === 408 && attempts < 5)) { - attempts++; - request = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - } - - switch (request.status) { - case 404: - throw new NotFoundError((await request.json()).error); - case 200: - case 201: - case 304: - return true; - // for timeout and misc errors, handle without error to allow viewing older docs - // if older docs not available, - // Reader will show 404 error coming from getEntityDocs - case 408: - default: - return false; - } - } - - async getBaseUrl( - oldBaseUrl: string, - entityId: EntityName, - path: string, - ): Promise { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - return new URL( - oldBaseUrl, - `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, - ).toString(); - } + getEntityMetadata(entityId: EntityName): Promise; } diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/client.test.ts similarity index 87% rename from plugins/techdocs/src/api.test.ts rename to plugins/techdocs/src/client.test.ts index 2260cc7d39..e8fc8f042f 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/client.test.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; import { UrlPatternDiscovery } from '@backstage/core'; -import { TechDocsStorageApi } from './api'; +import { TechDocsStorageClient } from './client'; const mockEntity = { kind: 'Component', @@ -23,7 +23,7 @@ const mockEntity = { name: 'test-component', }; -describe('TechDocsStorageApi', () => { +describe('TechDocsStorageClient', () => { const mockBaseUrl = 'http://backstage:9191/api/techdocs'; const configApi = { getOptionalString: () => 'http://backstage:9191/api/techdocs', @@ -32,7 +32,7 @@ describe('TechDocsStorageApi', () => { it('should return correct base url based on defined storage', async () => { // @ts-ignore Partial not assignable to Config. - const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + const storageApi = new TechDocsStorageClient({ configApi, discoveryApi }); await expect( storageApi.getBaseUrl('test.js', mockEntity, ''), @@ -43,7 +43,7 @@ describe('TechDocsStorageApi', () => { it('should return base url with correct entity structure', async () => { // @ts-ignore Partial not assignable to Config. - const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + const storageApi = new TechDocsStorageClient({ configApi, discoveryApi }); await expect( storageApi.getBaseUrl('test/', mockEntity, ''), diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts new file mode 100644 index 0000000000..245cfb0154 --- /dev/null +++ b/plugins/techdocs/src/client.ts @@ -0,0 +1,244 @@ +/* + * 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 { EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { NotFoundError } from '@backstage/errors'; +import { TechDocsApi, TechDocsStorageApi } from './api'; +import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; + +/** + * API to talk to techdocs-backend. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ +export class TechDocsClient implements TechDocsApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin(): Promise { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + /** + * Retrieve TechDocs metadata. + * + * When docs are built, we generate a techdocs_metadata.json and store it along with the generated + * static files. It includes necessary data about the docs site. This method requests techdocs-backend + * which retrieves the TechDocs metadata. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getTechDocsMetadata(entityId: EntityName): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const res = await request.json(); + + return res; + } + + /** + * Retrieve metadata about an entity. + * + * This method requests techdocs-backend which uses the catalog APIs to respond with filtered + * information required here. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + */ + async getEntityMetadata( + entityId: EntityName, + ): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const res = await request.json(); + + return res; + } +} + +/** + * API which talks to TechDocs storage to fetch files to render. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ +export class TechDocsStorageClient implements TechDocsStorageApi { + public configApi: Config; + public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; + + constructor({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + this.identityApi = identityApi; + } + + async getApiOrigin(): Promise { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl(): Promise { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder(): Promise { + return this.configApi.getString('techdocs.builder'); + } + + /** + * Fetch HTML content as text for an individual docs page in an entity's docs site. + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new + * @returns {string} HTML content of the docs page as string + * @throws {Error} Throws error when the page is not found. + */ + async getEntityDocs(entityId: EntityName, path: string): Promise { + const { kind, namespace, name } = entityId; + + const storageUrl = await this.getStorageUrl(); + const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; + const token = await this.identityApi.getIdToken(); + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ); + + let errorMessage = ''; + switch (request.status) { + case 404: + errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new NotFoundError(errorMessage); + case 500: + errorMessage = + 'Could not generate documentation or an error in the TechDocs backend. '; + throw new Error(errorMessage); + default: + // Do nothing + break; + } + + return request.text(); + } + + /** + * Check if docs are on the latest version and trigger rebuild if not + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @returns {boolean} Whether documents are currently synchronized to newest version + * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend + */ + async syncEntityDocs(entityId: EntityName): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + let request; + let attempts: number = 0; + // retry if request times out, up to 5 times + // can happen due to docs taking too long to generate + while (!request || (request.status === 408 && attempts < 5)) { + attempts++; + request = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + + switch (request.status) { + case 404: + throw new NotFoundError((await request.json()).error); + case 200: + case 201: + case 304: + return true; + // for timeout and misc errors, handle without error to allow viewing older docs + // if older docs not available, + // Reader will show 404 error coming from getEntityDocs + case 408: + default: + return false; + } + } + + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + return new URL( + oldBaseUrl, + `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, + ).toString(); + } +} diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index b33b91e2c5..3bdf8bfec1 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -14,17 +14,20 @@ * limitations under the License. */ +export * from './api'; +export { techdocsApiRef, techdocsStorageApiRef } from './api'; +export type { TechDocsApi, TechDocsStorageApi } from './api'; +export { TechDocsClient, TechDocsStorageClient } from './client'; +export type { PanelType } from './home/components/TechDocsCustomHome'; export { - techdocsPlugin, - techdocsPlugin as plugin, - TechdocsPage, - EntityTechdocsContent, DocsCardGrid, DocsTable, + EntityTechdocsContent, TechDocsCustomHome, + TechdocsPage, + techdocsPlugin as plugin, + techdocsPlugin, TechDocsReaderPage, } from './plugin'; -export { Router, EmbeddedDocsRouter } from './Router'; export * from './reader'; -export * from './api'; -export type { PanelType } from './home/components/TechDocsCustomHome'; +export { EmbeddedDocsRouter, Router } from './Router'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index d9b38a2c76..97dd6f2150 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -30,21 +30,17 @@ */ import { - createPlugin, - createRouteRef, - createApiFactory, configApiRef, + createApiFactory, + createComponentExtension, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, identityApiRef, - createRoutableExtension, - createComponentExtension, } from '@backstage/core'; -import { - techdocsStorageApiRef, - TechDocsStorageApi, - techdocsApiRef, - TechDocsApi, -} from './api'; +import { techdocsApiRef, techdocsStorageApiRef } from './api'; +import { TechDocsClient, TechDocsStorageClient } from './client'; export const rootRouteRef = createRouteRef({ path: '', @@ -72,7 +68,7 @@ export const techdocsPlugin = createPlugin({ identityApi: identityApiRef, }, factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsStorageApi({ + new TechDocsStorageClient({ configApi, discoveryApi, identityApi, @@ -86,7 +82,7 @@ export const techdocsPlugin = createPlugin({ identityApi: identityApiRef, }, factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsApi({ + new TechDocsClient({ configApi, discoveryApi, identityApi, diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index 36cc4e187b..fb068bee09 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ +import { Entity, Location } from '@backstage/catalog-model'; + export type TechDocsMetadata = { site_name: string; site_description: string; }; + +export type TechDocsEntityMetadata = Entity & { locationMetadata?: Location }; From bf905d9bdeba84c81d7e84e4b506867e662c10ce Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 12:43:28 +0200 Subject: [PATCH 053/176] Comment out datadog configuration Signed-off-by: Oliver Sand --- app-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index ac59874878..3c4f2d7ea5 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -2,9 +2,9 @@ app: title: Backstage Example App baseUrl: http://localhost:3000 googleAnalyticsTrackingId: # UA-000000-0 - datadogRum: - clientToken: '123456789' - applicationId: qwerty + #datadogRum: + # clientToken: '123456789' + # applicationId: qwerty # site: # datadoghq.eu default = datadoghq.com # env: # optional From a3048a3b7fcebea0394489c1c09ff2e814336bd4 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 13:08:20 +0200 Subject: [PATCH 054/176] Fix some remaining typing issues Signed-off-by: Oliver Sand --- .../src/reader/components/TechDocsPage.test.tsx | 15 +++++++++++++-- .../src/reader/components/TechDocsPage.tsx | 8 ++++---- .../src/reader/transformers/addBaseUrl.test.ts | 7 +++++-- .../src/reader/transformers/addBaseUrl.ts | 4 ++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 4b578bef38..52ddd3794e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -51,8 +51,19 @@ describe('', () => { }); const techdocsApi: Partial = { - getEntityMetadata: () => Promise.resolve([]), - getTechDocsMetadata: () => Promise.resolve([]), + getEntityMetadata: () => + Promise.resolve({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'backstage', + }, + }), + getTechDocsMetadata: () => + Promise.resolve({ + site_name: 'string', + site_description: 'string', + }), }; const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index f2635c0dfb..c15f3a6a44 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ +import { Content, Page, useApi } from '@backstage/core'; import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Content, Page, useApi } from '@backstage/core'; -import { Reader } from './Reader'; import { useAsync } from 'react-use'; -import { TechDocsPageHeader } from './TechDocsPageHeader'; import { techdocsApiRef } from '../../api'; +import { Reader } from './Reader'; +import { TechDocsPageHeader } from './TechDocsPageHeader'; export const TechDocsPage = () => { const [documentReady, setDocumentReady] = useState(false); @@ -33,7 +33,7 @@ export const TechDocsPage = () => { return techdocsApi.getTechDocsMetadata({ kind, namespace, name }); } - return Promise.resolve({ loading: true }); + return Promise.resolve(undefined); }, [kind, namespace, name, techdocsApi, documentReady]); const entityMetadataRequest = useAsync(() => { diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 5ba86e42ca..4311de5dae 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -16,14 +16,17 @@ import { createTestShadowDom } from '../../test-utils'; import { addBaseUrl } from '../transformers'; -import { TechDocsStorage } from '../../api'; +import { TechDocsStorageApi } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; -const techdocsStorageApi: TechDocsStorage = { +const techdocsStorageApi: TechDocsStorageApi = { getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), syncEntityDocs: () => new Promise(resolve => resolve(true)), + getApiOrigin: jest.fn(), + getBuilder: jest.fn(), + getStorageUrl: jest.fn(), }; const fixture = ` diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 2872ad36cc..17070dcfe0 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -15,10 +15,10 @@ */ import { EntityName } from '@backstage/catalog-model'; import type { Transformer } from './index'; -import { TechDocsStorage } from '../../api'; +import { TechDocsStorageApi } from '../../api'; type AddBaseUrlOptions = { - techdocsStorageApi: TechDocsStorage; + techdocsStorageApi: TechDocsStorageApi; entityId: EntityName; path: string; }; From 21fddf45244e9a335411627fdbc68cac9250a9b7 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 15:51:55 +0200 Subject: [PATCH 055/176] Rename changeset Signed-off-by: Oliver Sand --- .../{swift-waves-sleep.md => techdocs-swift-waves-sleep.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{swift-waves-sleep.md => techdocs-swift-waves-sleep.md} (100%) diff --git a/.changeset/swift-waves-sleep.md b/.changeset/techdocs-swift-waves-sleep.md similarity index 100% rename from .changeset/swift-waves-sleep.md rename to .changeset/techdocs-swift-waves-sleep.md From 92cc589201170b05689d0c310132e3448b8b9602 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 23 Apr 2021 13:16:10 +0200 Subject: [PATCH 056/176] Remove duplicate @backstage/test-utils dependency Signed-off-by: Oliver Sand --- plugins/techdocs/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index e526f028d5..7bde151663 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -35,7 +35,6 @@ "@backstage/catalog-model": "^0.7.7", "@backstage/core": "^0.7.6", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/test-utils": "^0.1.9", "@backstage/theme": "^0.2.6", "@backstage/errors": "^0.1.1", "@material-ui/core": "^4.11.0", From 38f076c0dffccfa0619de77451326fcc4f6fc4ff Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 23 Apr 2021 14:34:33 +0200 Subject: [PATCH 057/176] Tweak search code ownership. Signed-off-by: Eric Peterson --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 803ebd6445..b07519824b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,8 @@ /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core From cdda9c671d500c26a58759d276d3cd9b4591a6ac Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Fri, 23 Apr 2021 15:03:32 +0200 Subject: [PATCH 058/176] move refresh loop to builder and use configured refreshInterval Signed-off-by: Emma Indal --- packages/backend/src/plugins/search.ts | 7 +--- .../search-backend-node/src/IndexBuilder.ts | 37 ++++++++++--------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index a980661c85..da7ee193b4 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useHotCleanup } from '@backstage/backend-common'; import { createRouter } from '@backstage/plugin-search-backend'; import { IndexBuilder, @@ -35,11 +34,7 @@ export default async function createPlugin({ collator: new DefaultCatalogCollator(discovery), }); - // TODO: Move refresh loop logic into the builder. - const timerId = setInterval(() => { - indexBuilder.build(); - }, 60000); - useHotCleanup(module, () => clearInterval(timerId)); + indexBuilder.build(); return await createRouter({ engine: indexBuilder.getSearchEngine(), diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 0f5a287037..199cb7f923 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -100,28 +100,31 @@ export class IndexBuilder { async build() { return Promise.all( Object.keys(this.collators).map(async type => { - const decorators: DocumentDecorator[] = ( - this.decorators['*'] || [] - ).concat(this.decorators[type] || []); + setInterval(async () => { + const decorators: DocumentDecorator[] = ( + this.decorators['*'] || [] + ).concat(this.decorators[type] || []); - this.logger.info( - `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, - ); - let documents = await this.collators[type].collate.execute(); - for (let i = 0; i < decorators.length; i++) { this.logger.info( - `Decorating ${type} documents via ${decorators[i].constructor.name}`, + `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, ); - documents = await decorators[i].execute(documents); - } + let documents = await this.collators[type].collate.execute(); + for (let i = 0; i < decorators.length; i++) { + this.logger.info( + `Decorating ${type} documents via ${decorators[i].constructor.name}`, + ); + documents = await decorators[i].execute(documents); + } - if (!documents || documents.length === 0) { - this.logger.info(`No documents for type "${type}" to index`); - return; - } + if (!documents || documents.length === 0) { + this.logger.info(`No documents for type "${type}" to index`); + return; + } - // pushing documents to index to a configured search engine. - this.searchEngine.index(type, documents); + // pushing documents to index to a configured search engine. + this.searchEngine.index(type, documents); + // refreshInterval configured in seconds, setInterval want milliseconds + }, this.collators[type].refreshInterval * 1000); }), ); } From f5ba99d61c5acc77b2bc7b6cc0dad0d2a6796993 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Fri, 23 Apr 2021 15:07:12 +0200 Subject: [PATCH 059/176] add changeset Signed-off-by: Emma Indal --- .changeset/odd-ads-invite.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/odd-ads-invite.md diff --git a/.changeset/odd-ads-invite.md b/.changeset/odd-ads-invite.md new file mode 100644 index 0000000000..d487df47d3 --- /dev/null +++ b/.changeset/odd-ads-invite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Moved refresh loop to builder and use configured refreshInterval for each collator From 6f015b7689694bdbe2e90709b93b675cc66d9715 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 23 Apr 2021 19:38:51 +0200 Subject: [PATCH 060/176] 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 3b3bc4d120feb4d51656451b9f4830e8f10c2068 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 23 Apr 2021 12:13:13 -0600 Subject: [PATCH 061/176] Add healthcheck to k8s doc Signed-off-by: Tim Hansen --- docs/deployment/k8s.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 9dbf447740..81f7150a53 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -357,6 +357,14 @@ spec: name: postgres-secrets - secretRef: name: backstage-secrets + readinessProbe: + httpGet: + port: 7000 + path: /healthcheck + livenessProbe: + httpGet: + port: 7000 + path: /healthcheck ``` For production deployments, the `image` reference will usually be a full URL to From ba86f0290d7715e7c1189600fbab7d7f1b92bcc8 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 23 Apr 2021 12:34:46 -0600 Subject: [PATCH 062/176] Comment health checks with note Signed-off-by: Tim Hansen --- docs/deployment/k8s.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 81f7150a53..2c52e0e308 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -357,14 +357,16 @@ spec: name: postgres-secrets - secretRef: name: backstage-secrets - readinessProbe: - httpGet: - port: 7000 - path: /healthcheck - livenessProbe: - httpGet: - port: 7000 - path: /healthcheck +# Uncomment if health checks are enabled in your app: +# https://backstage.io/docs/plugins/observability#health-checks +# readinessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck +# livenessProbe: +# httpGet: +# port: 7000 +# path: /healthcheck ``` For production deployments, the `image` reference will usually be a full URL to From d47c2628b7c35fb35376727d1cafbf3ef2266aab Mon Sep 17 00:00:00 2001 From: Erik Larsson Date: Sat, 24 Apr 2021 06:51:24 +0200 Subject: [PATCH 063/176] fix: include migrations in code-coverage-backend plugin Signed-off-by: Erik Larsson --- .changeset/pink-islands-help.md | 5 +++++ plugins/code-coverage-backend/package.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/pink-islands-help.md diff --git a/.changeset/pink-islands-help.md b/.changeset/pink-islands-help.md new file mode 100644 index 0000000000..c7672f484f --- /dev/null +++ b/.changeset/pink-islands-help.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage-backend': patch +--- + +Include migrations diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 3fc238ce61..3ae76e3fe0 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -44,6 +44,7 @@ "xml2js": "^0.4.23" }, "files": [ - "dist" + "dist", + "migrations/**/*.{js,d.ts}" ] } From f940c3837802434cc7ae45f522a2782357eb5412 Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 24 Apr 2021 11:23:56 -0400 Subject: [PATCH 064/176] Prevent uncaught exception in download of Techdocs Azure Blob Storage publisher Signed-off-by: Taras --- .../techdocs-common/src/stages/publish/azureBlobStorage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 1a823c3d19..f6567abe49 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -221,7 +221,8 @@ export class AzureBlobStoragePublish implements PublisherBase { .on('end', () => { resolve(Buffer.concat(fileStreamChunks)); }); - }); + }) + .catch(reject); }); } From f4af06ebe339f2aeccce5a84b140b453f643033c Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 24 Apr 2021 11:34:41 -0400 Subject: [PATCH 065/176] Add changeset Signed-off-by: Taras --- .changeset/six-rocks-allow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/six-rocks-allow.md diff --git a/.changeset/six-rocks-allow.md b/.changeset/six-rocks-allow.md new file mode 100644 index 0000000000..f207654f45 --- /dev/null +++ b/.changeset/six-rocks-allow.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Gracefully handle HTTP request failures in download method of AzureBlobStorage publisher. From 15cbe6815611d15d9d993ff18a2c7491515f26a1 Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Mon, 26 Apr 2021 11:59:51 +0800 Subject: [PATCH 066/176] fix techdocs landing page table wrong copied link Signed-off-by: Chongyang Adrian, Ke --- .changeset/techdocs-mean-humans-hammer.md | 5 +++++ plugins/techdocs/src/home/components/DocsTable.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/techdocs-mean-humans-hammer.md diff --git a/.changeset/techdocs-mean-humans-hammer.md b/.changeset/techdocs-mean-humans-hammer.md new file mode 100644 index 0000000000..7697650250 --- /dev/null +++ b/.changeset/techdocs-mean-humans-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Fix TechDocs landing page table wrong copied link diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index cb0a3b4329..5b4a337dea 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -76,7 +76,7 @@ export const DocsTable = ({ - copyToClipboard(`${window.location.origin}/${row.docsUrl}`) + copyToClipboard(`${window.location.href}/${row.docsUrl}`) } > From 7e5b73d73341c872f4ec1182748a59a0a11b0b13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Apr 2021 04:34:56 +0000 Subject: [PATCH 067/176] chore(deps-dev): bump @types/supertest from 2.0.10 to 2.0.11 Bumps [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) from 2.0.10 to 2.0.11. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ba1b3bfe73..056f7257db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6727,9 +6727,9 @@ "@types/node" "*" "@types/supertest@^2.0.8": - version "2.0.10" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.10.tgz#630d79b4d82c73e043e43ff777a9ca98d457cab7" - integrity sha512-Xt8TbEyZTnD5Xulw95GLMOkmjGICrOQyJ2jqgkSjAUR3mm7pAIzSR0NFBaMcwlzVvlpCjNwbATcWWwjNiZiFrQ== + version "2.0.11" + resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz#2e70f69f220bc77b4f660d72c2e1a4231f44a77d" + integrity sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q== dependencies: "@types/superagent" "*" From 43db3d6892115eded93b3e5a655889658554c0a6 Mon Sep 17 00:00:00 2001 From: Martina Iglesias Fernandez Date: Fri, 23 Apr 2021 19:02:01 +0200 Subject: [PATCH 068/176] [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 8c0184a1c8858e1bc1673438ae44e0f4b94f019e Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 13:37:27 +0200 Subject: [PATCH 069/176] use timers in tests Signed-off-by: Emma Indal --- plugins/search-backend-node/src/index.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/index.test.ts index 0c66db372a..86233425e7 100644 --- a/plugins/search-backend-node/src/index.test.ts +++ b/plugins/search-backend-node/src/index.test.ts @@ -54,29 +54,32 @@ describe('IndexBuilder', () => { describe('addCollator', () => { it('adds a collator', async () => { + jest.useFakeTimers(); const collatorSpy = jest.spyOn(testCollator, 'execute'); // Add a collator. testIndexBuilder.addCollator({ type: 'anything', - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); // Build the index and ensure the collator was invoked. await testIndexBuilder.build(); + jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); }); }); describe('addDecorator', () => { it('adds a decorator', async () => { + jest.useFakeTimers(); const decoratorSpy = jest.spyOn(testDecorator, 'execute'); // Add a collator. testIndexBuilder.addCollator({ type: 'anything', - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -87,10 +90,14 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. await testIndexBuilder.build(); + jest.advanceTimersByTime(6000); + // wait for async decorator execution + await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); }); it('adds a type-specific decorator', async () => { + jest.useFakeTimers(); const expectedType = 'an-expected-type'; const docFixture = { title: 'Test', @@ -105,7 +112,7 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ type: expectedType, - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -117,6 +124,9 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. await testIndexBuilder.build(); + jest.advanceTimersByTime(6000); + // wait for async decorator execution + await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); expect(decoratorSpy).toHaveBeenCalledWith([docFixture]); }); @@ -136,7 +146,7 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ type: expectedType, - defaultRefreshIntervalSeconds: 600, + defaultRefreshIntervalSeconds: 6, collator: testCollator, }); @@ -148,6 +158,7 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was not invoked. await testIndexBuilder.build(); + jest.advanceTimersByTime(6000); expect(decoratorSpy).not.toHaveBeenCalled(); }); }); From 1c2d8436ab1af5c5424d78b8c5ac04647b3e24fe Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 13:38:15 +0200 Subject: [PATCH 070/176] change log severity from info to debug Signed-off-by: Emma Indal --- plugins/search-backend-node/src/IndexBuilder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 199cb7f923..0fc881c262 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -105,19 +105,19 @@ export class IndexBuilder { this.decorators['*'] || [] ).concat(this.decorators[type] || []); - this.logger.info( + this.logger.debug( `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, ); let documents = await this.collators[type].collate.execute(); for (let i = 0; i < decorators.length; i++) { - this.logger.info( + this.logger.debug( `Decorating ${type} documents via ${decorators[i].constructor.name}`, ); documents = await decorators[i].execute(documents); } if (!documents || documents.length === 0) { - this.logger.info(`No documents for type "${type}" to index`); + this.logger.debug(`No documents for type "${type}" to index`); return; } From d0b69236542f8cf31043c1f48facc74783c428d5 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 26 Apr 2021 14:05:04 +0200 Subject: [PATCH 071/176] Adjust changeset Signed-off-by: Oliver Sand --- .changeset/techdocs-swift-waves-sleep.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/techdocs-swift-waves-sleep.md b/.changeset/techdocs-swift-waves-sleep.md index 831ecc0001..79d10931cd 100644 --- a/.changeset/techdocs-swift-waves-sleep.md +++ b/.changeset/techdocs-swift-waves-sleep.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs': minor --- Make `techdocsStorageApiRef` and `techdocsApiRef` use interfaces instead of the From a3f090b2f391d47c9a36e8aeb53041798a9e02ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Apr 2021 19:58:02 +0200 Subject: [PATCH 072/176] 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 073/176] 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 074/176] 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 075/176] 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 076/176] 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 077/176] 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 078/176] 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 079/176] 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 080/176] 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 e0f43ae45391149d1d7bba5e91689d4690c0dde3 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Mon, 26 Apr 2021 09:21:09 -0400 Subject: [PATCH 081/176] Clarify token exposure Signed-off-by: Adam Harvey --- .github/workflows/chromatic-storybook-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index bb14e1c38b..89df848c76 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -48,5 +48,7 @@ jobs: - uses: chromaui/action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} + # projetToken intentionally shared to allow collaborators to run Chromatic on forks + # https://www.chromatic.com/docs/custom-ci-provider#run-chromatic-on-external-forks-of-open-source-projects projectToken: 9tzak77m9nj storybookBuildDir: 'packages/storybook/dist' From 656e1d82b281f4f3a9518e27d1bfe014393c2c91 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 15:38:31 +0200 Subject: [PATCH 082/176] 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 083/176] 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 19a4dd7103b806122fede7bdf2a8952204f81f84 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Apr 2021 16:00:09 +0200 Subject: [PATCH 084/176] plugins: removed unused swr dependency Signed-off-by: Patrik Oldsberg --- .changeset/rare-parrots-share.md | 6 ++++++ plugins/catalog/package.json | 3 +-- plugins/scaffolder/package.json | 1 - yarn.lock | 7 ------- 4 files changed, 7 insertions(+), 10 deletions(-) create mode 100644 .changeset/rare-parrots-share.md diff --git a/.changeset/rare-parrots-share.md b/.changeset/rare-parrots-share.md new file mode 100644 index 0000000000..917ec5a18e --- /dev/null +++ b/.changeset/rare-parrots-share.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-scaffolder': patch +--- + +Removed unused `swr` dependency. diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index b6b306c260..965a0b327b 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -49,8 +49,7 @@ "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", - "swr": "^0.3.0" + "react-use": "^15.3.3" }, "devDependencies": { "@backstage/cli": "^0.6.9", diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 4d4563df2f..2b4feecb7b 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -55,7 +55,6 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.3.0", "use-immer": "^0.5.1", "zen-observable": "^0.8.15" }, diff --git a/yarn.lock b/yarn.lock index ba1b3bfe73..f2c2bee93c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24792,13 +24792,6 @@ swap-case@^2.0.1: dependencies: tslib "^2.0.3" -swr@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/swr/-/swr-0.3.0.tgz#69602953dd50ab07fe60c920fd63199256645675" - integrity sha512-3p0p5TWH0qiaKAph5wBkMwqe2WjNseITfjmdVoNzjqRZGn/gnpRi6whMDjhMVb/vp/yyDtKWPlyjid8QZH+UhA== - dependencies: - fast-deep-equal "2.0.1" - symbol-observable@1.2.0, symbol-observable@^1.0.3, symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" From da489d2cd3a29f30749829e804ff8f9668153e0f Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 16:38:34 +0200 Subject: [PATCH 085/176] add new Scheduler Class Signed-off-by: Emma Indal --- plugins/search-backend-node/src/Scheduler.ts | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 plugins/search-backend-node/src/Scheduler.ts diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts new file mode 100644 index 0000000000..e54a837cee --- /dev/null +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -0,0 +1,75 @@ +/* + * 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 { Logger } from 'winston'; + +type TaskEnvelope = { + task: Function; + interval: number; +}; + +/** + * TODO: coordination, error handling + */ + +export class Scheduler { + private logger: Logger; + private schedule: TaskEnvelope[]; + private intervalTimeouts: NodeJS.Timeout[] = []; + + constructor({ logger }: { logger: Logger }) { + this.logger = logger; + this.schedule = []; + } + + /** + * Adds each task and interval to the schedule + * + */ + addToSchedule(task: Function, interval: number) { + if (this.intervalTimeouts.length) { + throw new Error( + 'Cannot add task to schedule that has already been started.', + ); + } + this.schedule.push({ task, interval }); + } + + /** + * Starts the scheduling process for each task + */ + start() { + this.logger.info('Starting all scheduled search tasks.'); + this.schedule.forEach(({ task, interval }) => { + this.intervalTimeouts.push( + setInterval(() => { + task(); + }, interval), + ); + }); + } + + /** + * Stop all scheduled tasks. + */ + stop() { + this.logger.info('Stopping all scheduled search tasks.'); + this.intervalTimeouts.forEach(timeout => { + clearInterval(timeout); + }); + this.intervalTimeouts = []; + } +} From 0b9eac3a819e3185c98a339124871f85fb2383e8 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 16:39:33 +0200 Subject: [PATCH 086/176] refactor build method in indexBuilder Signed-off-by: Emma Indal --- .../search-backend-node/src/IndexBuilder.ts | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 0fc881c262..768dee5b67 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -16,7 +16,7 @@ import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; import { Logger } from 'winston'; - +import { Scheduler } from './index'; import { RegisterCollatorParameters, RegisterDecoratorParameters, @@ -92,40 +92,42 @@ export class IndexBuilder { } /** - * Starts the process of executing collators and decorators and building the - * search index. - * - * TODO: But like with coordination, timing, error handling, and what have you. + * Compiles collators and decorators into tasks, which are added to a + * scheduler returned to the caller. */ - async build() { - return Promise.all( - Object.keys(this.collators).map(async type => { - setInterval(async () => { - const decorators: DocumentDecorator[] = ( - this.decorators['*'] || [] - ).concat(this.decorators[type] || []); + async build(): Promise<{ scheduler: Scheduler }> { + const scheduler = new Scheduler({ logger: this.logger }); + Object.keys(this.collators).map(type => { + scheduler.addToSchedule(async () => { + // Collate, Decorate, Index. + const decorators: DocumentDecorator[] = ( + this.decorators['*'] || [] + ).concat(this.decorators[type] || []); + + this.logger.debug( + `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, + ); + let documents = await this.collators[type].collate.execute(); + for (let i = 0; i < decorators.length; i++) { this.logger.debug( - `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, + `Decorating ${type} documents via ${decorators[i].constructor.name}`, ); - let documents = await this.collators[type].collate.execute(); - for (let i = 0; i < decorators.length; i++) { - this.logger.debug( - `Decorating ${type} documents via ${decorators[i].constructor.name}`, - ); - documents = await decorators[i].execute(documents); - } + documents = await decorators[i].execute(documents); + } - if (!documents || documents.length === 0) { - this.logger.debug(`No documents for type "${type}" to index`); - return; - } + if (!documents || documents.length === 0) { + this.logger.debug(`No documents for type "${type}" to index`); + return; + } - // pushing documents to index to a configured search engine. - this.searchEngine.index(type, documents); - // refreshInterval configured in seconds, setInterval want milliseconds - }, this.collators[type].refreshInterval * 1000); - }), - ); + // pushing documents to index to a configured search engine. + this.searchEngine.index(type, documents); + }, this.collators[type].refreshInterval * 1000); + }); + + return { + scheduler, + }; } } From 9dff1a9839704af0ae722372a728b504ebe27bec Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 16:43:58 +0200 Subject: [PATCH 087/176] use scheduler and fix tests Signed-off-by: Emma Indal --- packages/backend/src/plugins/search.ts | 5 ++++- plugins/search-backend-node/src/index.test.ts | 15 ++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index da7ee193b4..03c7d44a18 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -34,7 +34,10 @@ export default async function createPlugin({ collator: new DefaultCatalogCollator(discovery), }); - indexBuilder.build(); + const { scheduler } = await indexBuilder.build(); + + scheduler.start(); + useHotCleanup(module, () => scheduler.stop()); return await createRouter({ engine: indexBuilder.getSearchEngine(), diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/index.test.ts index 86233425e7..e1dc511b97 100644 --- a/plugins/search-backend-node/src/index.test.ts +++ b/plugins/search-backend-node/src/index.test.ts @@ -65,7 +65,8 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the collator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); }); @@ -89,7 +90,8 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); @@ -123,7 +125,8 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); @@ -138,7 +141,7 @@ describe('IndexBuilder', () => { text: 'Test text.', location: '/test/location', }; - jest + const collatorSpy = jest .spyOn(testCollator, 'execute') .mockImplementation(async () => [docFixture]); const decoratorSpy = jest.spyOn(testDecorator, 'execute'); @@ -157,8 +160,10 @@ describe('IndexBuilder', () => { }); // Build the index and ensure the decorator was not invoked. - await testIndexBuilder.build(); + const { scheduler } = await testIndexBuilder.build(); + scheduler.start(); jest.advanceTimersByTime(6000); + expect(collatorSpy).toHaveBeenCalled(); expect(decoratorSpy).not.toHaveBeenCalled(); }); }); From 50b15bfeb3495f623f0bce521ad9e5d5b383951d Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 16:44:32 +0200 Subject: [PATCH 088/176] imports and exports Signed-off-by: Emma Indal --- packages/backend/src/plugins/search.ts | 1 + plugins/search-backend-node/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 03c7d44a18..dcd48aaf40 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useHotCleanup } from '@backstage/backend-common'; import { createRouter } from '@backstage/plugin-search-backend'; import { IndexBuilder, diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index a3a0eb855e..7ee75e3d59 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -15,5 +15,6 @@ */ export { IndexBuilder } from './IndexBuilder'; +export { Scheduler } from './Scheduler'; export { LunrSearchEngine } from './engines'; export type { SearchEngine } from './types'; From fafd8043649dd7432bde654eaee11cf58539544f Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 16:49:32 +0200 Subject: [PATCH 089/176] update changeset Signed-off-by: Emma Indal --- .changeset/odd-ads-invite.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/odd-ads-invite.md b/.changeset/odd-ads-invite.md index d487df47d3..0dde3d0c1f 100644 --- a/.changeset/odd-ads-invite.md +++ b/.changeset/odd-ads-invite.md @@ -2,4 +2,4 @@ '@backstage/plugin-search-backend-node': patch --- -Moved refresh loop to builder and use configured refreshInterval for each collator +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. From e00ba124546a742639973fd26edb39635dff5351 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 26 Apr 2021 16:53:44 +0200 Subject: [PATCH 090/176] 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 17915e29b31acb6ebfbacee71db655888ae5602e Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 26 Apr 2021 16:53:28 +0200 Subject: [PATCH 091/176] Rework state management to avoid rendering multiple while navigating between pages Closes #5184 Signed-off-by: Oliver Sand --- .changeset/techdocs-young-walls-decide.md | 5 ++ .../techdocs/src/reader/components/Reader.tsx | 38 ++++++------- .../src/reader/components/TechDocsPage.tsx | 6 +-- .../src/reader/components/useRawPage.ts | 54 +++++++++++++++++++ 4 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 .changeset/techdocs-young-walls-decide.md create mode 100644 plugins/techdocs/src/reader/components/useRawPage.ts diff --git a/.changeset/techdocs-young-walls-decide.md b/.changeset/techdocs-young-walls-decide.md new file mode 100644 index 0000000000..8dc0373234 --- /dev/null +++ b/.changeset/techdocs-young-walls-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Rework state management to avoid rendering multiple while navigating between pages. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index ff4f47dca0..6e5e0b5920 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { EntityName } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core'; +import { configApiRef, useApi } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; import { useTheme } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; @@ -24,6 +24,7 @@ import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; import transformer, { addBaseUrl, + addGitFeedbackLink, addLinkClickListener, injectCss, onCssReady, @@ -31,10 +32,10 @@ import transformer, { rewriteDocLinks, sanitizeDOM, simplifyMkdocsFooter, - addGitFeedbackLink, } from '../transformers'; import { TechDocsNotFound } from './TechDocsNotFound'; import TechDocsProgressBar from './TechDocsProgressBar'; +import { useRawPage } from './useRawPage'; type Props = { entityId: EntityName; @@ -69,20 +70,20 @@ export const Reader = ({ entityId, onReady }: Props) => { }); } return techdocsStorageApi.syncEntityDocs({ kind, namespace, name }); - }); + }, [techdocsStorageApi, kind, namespace, name]); const { value: rawPage, loading: docLoading, error: docLoadError, - } = useAsync(async () => { - // do not automatically load same page again if URL has not changed, - // happens when generating new docs finishes - if (newerDocsExist && path === loadedPath) { - return null; + retry, + } = useRawPage(path, kind, namespace, name); + + useEffect(() => { + if (isSynced && newerDocsExist && path !== loadedPath) { + retry(); } - return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path); - }, [techdocsStorageApi, kind, namespace, name, path, isSynced]); + }); useEffect(() => { const updateSidebarPosition = () => { @@ -134,12 +135,12 @@ export const Reader = ({ entityId, onReady }: Props) => { onReady(); } // Pre-render - const transformedElement = transformer(rawPage as string, [ + const transformedElement = transformer(rawPage.content, [ sanitizeDOM(), addBaseUrl({ techdocsStorageApi, - entityId: entityId, - path, + entityId: rawPage.entityId, + path: rawPage.path, }), rewriteDocLinks(), removeMkdocsHeader(), @@ -304,16 +305,15 @@ export const Reader = ({ entityId, onReady }: Props) => { ]); }, [ rawPage, - entityId, navigate, onReady, shadowDomRef, - path, techdocsStorageApi, - theme, - kind, - namespace, - name, + theme.typography.fontFamily, + theme.palette.text.primary, + theme.palette.primary.main, + theme.palette.background.paper, + theme.palette.background.default, newerDocsExist, isSynced, configApi, diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index c15f3a6a44..338088be4d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -15,7 +15,7 @@ */ import { Content, Page, useApi } from '@backstage/core'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { techdocsApiRef } from '../../api'; @@ -40,9 +40,9 @@ export const TechDocsPage = () => { return techdocsApi.getEntityMetadata({ kind, namespace, name }); }, [kind, namespace, name, techdocsApi]); - const onReady = () => { + const onReady = useCallback(() => { setDocumentReady(true); - }; + }, [setDocumentReady]); return ( diff --git a/plugins/techdocs/src/reader/components/useRawPage.ts b/plugins/techdocs/src/reader/components/useRawPage.ts new file mode 100644 index 0000000000..1bc23d2a45 --- /dev/null +++ b/plugins/techdocs/src/reader/components/useRawPage.ts @@ -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. + */ +import { EntityName } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { useAsyncRetry } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { techdocsStorageApiRef } from '../../api'; + +export type RawPage = { + content: string; + path: string; + entityId: EntityName; +}; + +export function useRawPage( + path: string, + kind: string, + namespace: string, + name: string, +): AsyncState & { + retry(): void; +} { + const techdocsStorageApi = useApi(techdocsStorageApiRef); + + return useAsyncRetry(async () => { + const content = await techdocsStorageApi.getEntityDocs( + { kind, namespace, name }, + path, + ); + + return { + content, + path, + entityId: { + kind, + name, + namespace, + }, + }; + }, [techdocsStorageApi, kind, namespace, name, path]); +} From cb8c848a38d2e100288b46ba767258e83f5d9b15 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 26 Apr 2021 17:16:59 +0200 Subject: [PATCH 092/176] Disable color transitions on links to avoid issues in dark mode Signed-off-by: Oliver Sand --- .changeset/techdocs-red-donkeys-share.md | 5 +++++ plugins/techdocs/src/reader/components/Reader.tsx | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/techdocs-red-donkeys-share.md diff --git a/.changeset/techdocs-red-donkeys-share.md b/.changeset/techdocs-red-donkeys-share.md new file mode 100644 index 0000000000..29d932d0b8 --- /dev/null +++ b/.changeset/techdocs-red-donkeys-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Disable color transitions on links to avoid issues in dark mode. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index ff4f47dca0..14fcb43700 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -197,10 +197,21 @@ export const Reader = ({ entityId, onReady }: Props) => { } .md-nav--primary > .md-nav__title [for="none"] { padding-top: 0; - } + } } `, }), + injectCss({ + // Disable CSS animations on link colors as they lead to issues in dark + // mode. The dark mode color theme is applied later and theirfore there + // is always an animation from light to dark mode when navigation + // between pages. + css: ` + .md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink { + transition: none; + } + `, + }), injectCss({ // Admonitions and others are using SVG masks to define icons. These // masks are defined as CSS variables. From e1e757569fbb2f3b9b30ab48229ec2ef97cc0c7f Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Mon, 26 Apr 2021 17:32:54 +0200 Subject: [PATCH 093/176] prefix changeset with search Signed-off-by: Emma Indal --- .changeset/{odd-ads-invite.md => search-odd-ads-invite.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{odd-ads-invite.md => search-odd-ads-invite.md} (100%) diff --git a/.changeset/odd-ads-invite.md b/.changeset/search-odd-ads-invite.md similarity index 100% rename from .changeset/odd-ads-invite.md rename to .changeset/search-odd-ads-invite.md From 24c24f384a327632f92767406ac6b8eb7aae46d6 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Mon, 26 Apr 2021 14:14:22 -0400 Subject: [PATCH 094/176] Add stability index for cost insights Signed-off-by: Brenda Sukh --- docs/overview/stability-index.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 4172e4d1b7..6882f3d6c2 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -153,6 +153,13 @@ re-exported from @backstage/core, and this package should not be used directly. Stability: See @backstage/core +### `cost-insights` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) + +A frontend plugin that allows users to visualize, understand and optimize your +team's cloud costs. + +Stability: `1` + ### `create-app` [GitHub](https://github.com/backstage/backstage/tree/master/packages/create-app/) The CLI used to scaffold new Backstage projects. @@ -359,7 +366,7 @@ https://github.com/backstage/backstage/issues/2771. ### `tech-radar` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) -Visualize the your company's official guidelines of different areas of software +Visualize your company's official guidelines of different areas of software development. Stability: `0` From f639a09f835f8c1e0485a6a936a7115dadb6152f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 17:40:43 +0200 Subject: [PATCH 095/176] root: add api-extractor dependency and friends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gustaf Räntilä Signed-off-by: Patrik Oldsberg --- package.json | 6 ++ yarn.lock | 162 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index d87b98fbdb..b946019c0a 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,16 @@ "**/@roadiehq/**/@backstage/core": "*", "**/@roadiehq/**/@backstage/plugin-catalog": "*", "**/@roadiehq/**/@backstage/catalog-model": "*", + "**/@microsoft/api-extractor/typescript": "^4.0.3", "graphql-language-service-interface": "2.8.2", "graphql-language-service-parser": "1.9.0" }, "version": "1.0.0", + "dependencies": { + "@microsoft/api-extractor": "7.13.2-pr1916.0", + "@microsoft/api-documenter": "^7.12.16", + "@microsoft/api-extractor-model": "^7.12.5" + }, "devDependencies": { "@changesets/cli": "^2.14.0", "@octokit/openapi-types": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index f2c2bee93c..98c3af5d2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3820,11 +3820,54 @@ dependencies: "@types/whatwg-streams" "^0.0.7" +"@microsoft/api-documenter@^7.12.16": + version "7.12.21" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.12.21.tgz#cdafcd4575d42e55eb8ca338a3a5fb868f9d5872" + integrity sha512-XZQKnMppgkTHeGh92fQ7kidT2Bxr3o775Mo9gP21+kTQs3LnvukHmSnFwBTHTXpvOq1wnkbT430dRvxdayWECQ== + dependencies: + "@microsoft/api-extractor-model" "7.12.5" + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "3.36.2" + "@rushstack/ts-command-line" "4.7.10" + colors "~1.2.1" + js-yaml "~3.13.1" + resolve "~1.17.0" + +"@microsoft/api-extractor-model@7.12.5", "@microsoft/api-extractor-model@^7.12.5", "@microsoft/api-extractor-model@workspace:*": + version "7.12.5" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.12.5.tgz#28d2804865ceba9cd89ab4f05cff99d16fa6c9b8" + integrity sha512-oeHZW83JWjIVoCDvdwI5nsZGPxThbq4gZTLAYNeJGZE/mKEO0iayMPGmI3EllJBjwQsFvNVU+O/HGULhB2to/g== + dependencies: + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "3.36.2" + +"@microsoft/api-extractor@7.13.2-pr1916.0": + version "7.13.2-pr1916.0" + resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.13.2-pr1916.0.tgz#2e10cb928ea81b56cd5f63264da11f1f9b9310a5" + integrity sha512-0/HajL+NUixuNGMfFZbHmKJn5VEqiF45q2FXhu8UrggutdJ+9M6wZ++fejUHfxlC/WhQVrVVRtf4xvVM3oIW+A== + dependencies: + "@microsoft/api-extractor-model" "workspace:*" + "@microsoft/tsdoc" "0.12.24" + "@rushstack/node-core-library" "workspace:*" + "@rushstack/rig-package" "workspace:*" + "@rushstack/ts-command-line" "workspace:*" + colors "~1.2.1" + lodash "~4.17.15" + resolve "~1.17.0" + semver "~7.3.0" + source-map "~0.6.1" + typescript "~4.1.3" + "@microsoft/microsoft-graph-types@^1.25.0": version "1.25.0" resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-1.25.0.tgz#1f543ebc029a115dd1d48a1ae99d7ddd5ee9af57" integrity sha512-RsuA+ROaU3voWzG9TVBkRKxmLatteRGduFDi5p0k3FUHho49rm9SvrA7DUyYbSXLy2xXRx9AnjKM9klYBeKEiQ== +"@microsoft/tsdoc@0.12.24": + version "0.12.24" + resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.12.24.tgz#30728e34ebc90351dd3aff4e18d038eed2c3e098" + integrity sha512-Mfmij13RUTmHEMi9vRUhMXD7rnGR2VvxeNYtaGtaJ4redwwjT4UXYJ+nzmVJF7hhd4pn/Fx5sncDKxMVFJSWPg== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -4368,6 +4411,39 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@rushstack/node-core-library@3.36.2", "@rushstack/node-core-library@workspace:*": + version "3.36.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.36.2.tgz#ba00d313577f9b06d5aafaa29da0d94e594874c0" + integrity sha512-5J8xSY/PuCKR+yfxS497l0PP43kBUeD86S4eS3RzrmMle04J4522MWal8mk1T1EIDpYpgi8qScannU9oVxoStA== + dependencies: + "@types/node" "10.17.13" + colors "~1.2.1" + fs-extra "~7.0.1" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.17.0" + semver "~7.3.0" + timsort "~0.3.0" + z-schema "~3.18.3" + +"@rushstack/rig-package@workspace:*": + version "0.2.12" + resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.2.12.tgz#c434d62b28e0418a040938226f8913971d0424c7" + integrity sha512-nbePcvF8hQwv0ql9aeQxcaMPK/h1OLAC00W7fWCRWIvD2MchZOE8jumIIr66HGrfG2X1sw++m/ZYI4D+BM5ovQ== + dependencies: + resolve "~1.17.0" + strip-json-comments "~3.1.1" + +"@rushstack/ts-command-line@4.7.10", "@rushstack/ts-command-line@workspace:*": + version "4.7.10" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.7.10.tgz#a2ec6efb1945b79b496671ce90eb1be4f1397d31" + integrity sha512-8t042g8eerypNOEcdpxwRA3uCmz0duMo21rG4Z2mdz7JxJeylDmzjlU3wDdef2t3P1Z61JCdZB6fbm1Mh0zi7w== + dependencies: + "@types/argparse" "1.0.38" + argparse "~1.0.9" + colors "~1.2.1" + string-argv "~0.3.1" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -5578,6 +5654,11 @@ dependencies: "@types/glob" "*" +"@types/argparse@1.0.38": + version "1.0.38" + resolved "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" + integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== + "@types/aria-query@^4.2.0": version "4.2.0" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" @@ -6317,6 +6398,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-14.14.32.tgz#90c5c4a8d72bbbfe53033f122341343249183448" integrity sha512-/Ctrftx/zp4m8JOujM5ZhwzlWLx22nbQJiVqz8/zE15gOeEW+uly3FSX4fGFpcfEvFzXcMCJwq9lGVWgyARXhg== +"@types/node@10.17.13": + version "10.17.13" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c" + integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg== + "@types/node@^10.1.0", "@types/node@^10.12.0": version "10.17.35" resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56" @@ -7813,7 +7899,7 @@ arg@^4.1.0: resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -argparse@^1.0.10, argparse@^1.0.7: +argparse@^1.0.10, argparse@^1.0.7, argparse@~1.0.9: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== @@ -9903,6 +9989,11 @@ colors@^1.1.2, colors@^1.2.1: resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== +colors@~1.2.1: + version "1.2.5" + resolved "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc" + integrity sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg== + colorspace@1.1.x: version "1.1.2" resolved "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" @@ -9951,7 +10042,7 @@ commander@2.3.0: resolved "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873" integrity sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM= -commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: +commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.7.1, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -13552,7 +13643,7 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^7.0.1: +fs-extra@^7.0.1, fs-extra@~7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== @@ -15200,6 +15291,11 @@ import-lazy@^2.1.0: resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= +import-lazy@~4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + import-local@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" @@ -16602,6 +16698,11 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= + jmespath@0.15.0: version "0.15.0" resolved "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" @@ -16682,6 +16783,14 @@ js-yaml@^4.0.0: dependencies: argparse "^2.0.1" +js-yaml@~3.13.1: + version "3.13.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -17672,7 +17781,7 @@ lodash.flattendeep@^4.0.0: resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= -lodash.get@^4: +lodash.get@^4, lodash.get@^4.0.0: version "4.4.2" resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= @@ -17687,6 +17796,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= +lodash.isequal@^4.0.0: + version "4.5.0" + resolved "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" @@ -17772,7 +17886,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@4.x, lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.20, lodash@~4.17.4: +lodash@4.x, lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.15, lodash@~4.17.20, lodash@~4.17.4: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -22990,6 +23104,13 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17 is-core-module "^2.2.0" path-parse "^1.0.6" +resolve@~1.17.0: + version "1.17.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -23420,6 +23541,13 @@ 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" @@ -24300,7 +24428,7 @@ strict-uri-encode@^2.0.0: resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= -string-argv@0.3.1: +string-argv@0.3.1, string-argv@~0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== @@ -24526,7 +24654,7 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -25130,7 +25258,7 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" -timsort@^0.3.0: +timsort@^0.3.0, timsort@~0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= @@ -25632,7 +25760,7 @@ typescript-json-schema@^0.49.0: typescript "^4.1.3" yargs "^16.2.0" -typescript@^4.0.3, typescript@^4.1.3: +typescript@^4.0.3, typescript@^4.1.3, typescript@~4.1.3: version "4.2.3" resolved "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== @@ -26198,6 +26326,11 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= +validator@^8.0.0: + version "8.2.0" + resolved "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz#3c1237290e37092355344fef78c231249dab77b9" + integrity sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -27112,6 +27245,17 @@ yup@^0.29.3: synchronous-promise "^2.0.13" toposort "^2.0.2" +z-schema@~3.18.3: + version "3.18.4" + resolved "https://registry.npmjs.org/z-schema/-/z-schema-3.18.4.tgz#ea8132b279533ee60be2485a02f7e3e42541a9a2" + integrity sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw== + dependencies: + lodash.get "^4.0.0" + lodash.isequal "^4.0.0" + validator "^8.0.0" + optionalDependencies: + commander "^2.7.1" + zen-observable-ts@^0.8.21: version "0.8.21" resolved "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d" From 482256023b82bdb07afb48e900cf082c12d7a081 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 17:57:18 +0200 Subject: [PATCH 096/176] scripts: add api-extractor script that generates API reports and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gustaf Räntilä Signed-off-by: Patrik Oldsberg --- package.json | 2 + scripts/api-extractor.ts | 264 +++++++++++++++++++++++++++++++++++++++ scripts/tsconfig.json | 6 + 3 files changed, 272 insertions(+) create mode 100644 scripts/api-extractor.ts create mode 100644 scripts/tsconfig.json diff --git a/package.json b/package.json index b946019c0a..f1faad5de7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "yarn workspace example-app start", "start-backend": "yarn workspace example-backend start", "build": "lerna run build", + "build:api-reports": "tsc && ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", + "build:api-docs": "yarn build:api-reports --docs", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts new file mode 100644 index 0000000000..1cab49750f --- /dev/null +++ b/scripts/api-extractor.ts @@ -0,0 +1,264 @@ +/* + * 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +// eslint-disable-next-line no-restricted-imports +import { resolve as resolvePath, join as joinPath, dirname } from 'path'; +import fs from 'fs-extra'; +import { + Extractor, + ExtractorConfig, + CompilerState, + ExtractorLogLevel, +} from '@microsoft/api-extractor'; +import { ApiPackage, ApiModel } from '@microsoft/api-extractor-model'; +import { MarkdownDocumenter } from '@microsoft/api-documenter/lib/documenters/MarkdownDocumenter'; + +const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); + +/** + * Yup + */ +const { + PackageJsonLookup, +} = require('@rushstack/node-core-library/lib/PackageJsonLookup'); + +const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor; +PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = function tryGetPackageJsonFilePathForPatch( + path: string, +) { + if ( + path.includes('@material-ui') && + !dirname(path).endsWith('@material-ui') + ) { + return undefined; + } + return old.call(this, path); +}; + +const DOCUMENTED_PACKAGES = [ + 'packages/backend-common', + 'packages/catalog-client', + 'packages/catalog-model', + 'packages/cli-common', + 'packages/config', + 'packages/config-loader', + // 'packages/core', + // 'packages/core-api', + 'packages/dev-utils', + 'packages/errors', + 'packages/integration', + 'packages/integration-react', + 'packages/search-common', + 'packages/techdocs-common', + 'packages/test-utils', + 'packages/test-utils-core', + 'packages/theme', +]; + +interface ApiExtractionOptions { + packageDirs: string[]; + outputDir: string; + isLocalBuild: boolean; +} +async function runApiExtraction({ + packageDirs, + outputDir, + isLocalBuild, +}: ApiExtractionOptions) { + await fs.remove(outputDir); + + const entryPoints = packageDirs.map(packageDir => { + return resolvePath(__dirname, `../dist-types/${packageDir}/src/index.d.ts`); + }); + + let compilerState: CompilerState | undefined = undefined; + + for (const packageDir of packageDirs) { + console.log(`## Processing ${packageDir}`); + const projectFolder = resolvePath(__dirname, '..', packageDir); + const packagePath = resolvePath(__dirname, `../${packageDir}/package.json`); + + const extractorConfig = ExtractorConfig.prepare({ + configObject: { + mainEntryPointFilePath: resolvePath( + __dirname, + '../dist-types/packages//src/index.d.ts', + ), + bundledPackages: [], + + compiler: { + tsconfigFilePath: resolvePath(__dirname, '../tsconfig.json'), + }, + + apiReport: { + enabled: true, + reportFileName: 'api-report.md', + reportFolder: projectFolder, + reportTempFolder: resolvePath(outputDir, ''), + }, + + docModel: { + enabled: true, + apiJsonFilePath: resolvePath( + outputDir, + '.api.json', + ), + }, + + dtsRollup: { + enabled: false, + }, + + tsdocMetadata: { + enabled: false, + }, + + messages: { + compilerMessageReporting: { + default: { + logLevel: 'warning' as ExtractorLogLevel.Warning, + addToApiReportFile: true, + }, + }, + extractorMessageReporting: { + default: { + logLevel: 'warning' as ExtractorLogLevel.Warning, + addToApiReportFile: true, + }, + }, + tsdocMessageReporting: { + default: { + logLevel: 'warning' as ExtractorLogLevel.Warning, + addToApiReportFile: true, + }, + }, + }, + + newlineKind: 'lf', + + projectFolder, + }, + configObjectFullPath: projectFolder, + packageJsonFullPath: packagePath, + }); + + if (!compilerState) { + compilerState = CompilerState.create(extractorConfig, { + additionalEntryPoints: entryPoints, + }); + } + + // Message verbosity can't be configured, so just skip the check instead + (Extractor as any)._checkCompilerCompatibility = () => {}; + + // Invoke API Extractor + const extractorResult = Extractor.invoke(extractorConfig, { + localBuild: isLocalBuild, + showVerboseMessages: false, + showDiagnostics: false, + compilerState, + }); + + if (!extractorResult.succeeded) { + throw new Error( + `API Extractor completed with ${extractorResult.errorCount} errors` + + ` and ${extractorResult.warningCount} warnings`, + ); + } + } +} + +function isComponentMember(member: any) { + // React components are annotated with @components, and we want to skip those + return Boolean(member.docComment.match(/\n\s*\**\s*@component/m)); +} + +async function buildDocs({ + inputDir, + outputDir, +}: { + inputDir: string; + outputDir: string; +}) { + const parseFile = async (filename: string): Promise => { + console.log(`Reading ${filename}`); + return fs.readJson(resolvePath(inputDir, filename)); + }; + + const filenames = await fs.readdir(inputDir); + const serializedPackages = await Promise.all( + filenames + .filter(filename => filename.match(/\.api\.json$/i)) + .map(parseFile), + ); + + const newModel = new ApiModel(); + for (const serialized of serializedPackages) { + serialized.members[0].members = serialized.members[0].members.filter( + member => !isComponentMember(member), + ); + + const pkg = ApiPackage.deserialize( + serialized, + serialized.metadata, + ) as ApiPackage; + newModel.addMember(pkg); + } + + await fs.remove(outputDir); + await fs.ensureDir(outputDir); + + const documenter = new MarkdownDocumenter({ + apiModel: newModel, + documenterConfig: { + outputTarget: 'markdown', + newlineKind: '\n', + // De ba dålig kod + configFilePath: '', + configFile: {}, + } as any, + outputFolder: outputDir, + }); + + documenter.generateFiles(); +} + +async function main() { + const isCiBuild = process.argv.includes('--ci'); + const isDocsBuild = process.argv.includes('--docs'); + + console.log('# Generating package API reports'); + await runApiExtraction({ + packageDirs: DOCUMENTED_PACKAGES, + outputDir: tmpDir, + isLocalBuild: !isCiBuild, + }); + + if (isDocsBuild) { + console.log('# Generating package documentation'); + await buildDocs({ + inputDir: tmpDir, + outputDir: resolvePath(__dirname, '..', 'docs/reference'), + }); + } +} + +main().catch(error => { + console.error(error.stack || String(error)); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000000..c8d842173c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "CommonJS" + } +} From 26dee60729efac52a53500086f5c0c4649d747e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 18:13:00 +0200 Subject: [PATCH 097/176] scripts/api-extractor: better feedback for api-report mismatches Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 1cab49750f..e232170e9d 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -171,6 +171,28 @@ async function runApiExtraction({ localBuild: isLocalBuild, showVerboseMessages: false, showDiagnostics: false, + messageCallback(message) { + if ( + message.text.includes( + 'You have changed the public API signature for this project.', + ) + ) { + console.log(''); + console.log( + '*************************************************************************************', + ); + console.log( + '* You have uncommitted changes to the public API of a package. *', + ); + console.log( + '* To solve this, run `yarn build:api-reports` and commit all api-report.md changes. *', + ); + console.log( + '*************************************************************************************', + ); + console.log(''); + } + }, compilerState, }); From fecf70ae1aa2747e81a5c28ef3dcf8728505ee32 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 18:23:23 +0200 Subject: [PATCH 098/176] workflows: validate api reports in CI Signed-off-by: Patrik Oldsberg --- .github/workflows/ci.yml | 3 +++ package.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2337a70e2c..0a6d0f8589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: type checking and declarations run: yarn tsc:full + - name: check api reports + run: yarn build:api-reports:only --ci + - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn lerna -- run build --since origin/master --include-dependencies diff --git a/package.json b/package.json index f1faad5de7..b0f79031de 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "yarn workspace example-app start", "start-backend": "yarn workspace example-backend start", "build": "lerna run build", - "build:api-reports": "tsc && ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", + "build:api-reports": "tsc && yarn build:api-reports:only", + "build:api-reports:only": "ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", "build:api-docs": "yarn build:api-reports --docs", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck false --incremental false", From a904d8ba5e82d0d8e60eba9b438f7970596e673d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 18:39:21 +0200 Subject: [PATCH 099/176] packages: add api-reports for all included packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gustaf Räntilä Signed-off-by: Patrik Oldsberg --- .prettierignore | 1 + packages/backend-common/api-report.md | 469 +++++++++++++ packages/catalog-client/api-report.md | 105 +++ packages/catalog-model/api-report.md | 811 +++++++++++++++++++++++ packages/cli-common/api-report.md | 35 + packages/config-loader/api-report.md | 72 ++ packages/config/api-report.md | 112 ++++ packages/dev-utils/api-report.md | 39 ++ packages/errors/api-report.md | 125 ++++ packages/integration-react/api-report.md | 32 + packages/integration/api-report.md | 414 ++++++++++++ packages/search-common/api-report.md | 70 ++ packages/techdocs-common/api-report.md | 259 ++++++++ packages/test-utils-core/api-report.md | 113 ++++ packages/test-utils/api-report.md | 109 +++ packages/theme/api-report.md | 130 ++++ 16 files changed, 2896 insertions(+) create mode 100644 packages/backend-common/api-report.md create mode 100644 packages/catalog-client/api-report.md create mode 100644 packages/catalog-model/api-report.md create mode 100644 packages/cli-common/api-report.md create mode 100644 packages/config-loader/api-report.md create mode 100644 packages/config/api-report.md create mode 100644 packages/dev-utils/api-report.md create mode 100644 packages/errors/api-report.md create mode 100644 packages/integration-react/api-report.md create mode 100644 packages/integration/api-report.md create mode 100644 packages/search-common/api-report.md create mode 100644 packages/techdocs-common/api-report.md create mode 100644 packages/test-utils-core/api-report.md create mode 100644 packages/test-utils/api-report.md create mode 100644 packages/theme/api-report.md diff --git a/.prettierignore b/.prettierignore index c4e675a5d7..e60d54a466 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ microsite coverage *.hbs templates +api-report.md plugins/scaffolder-backend/sample-templates .vscode dist-types diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md new file mode 100644 index 0000000000..bdef061eda --- /dev/null +++ b/packages/backend-common/api-report.md @@ -0,0 +1,469 @@ +## API Report File for "@backstage/backend-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegration } from '@backstage/integration'; +import { BitbucketIntegration } from '@backstage/integration'; +import { Config } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; +import cors from 'cors'; +import Docker from 'dockerode'; +import { ErrorRequestHandler } from 'express'; +import express from 'express'; +import { Format } from 'logform'; +import { GithubCredentialsProvider } from '@backstage/integration'; +import { GitHubIntegration } from '@backstage/integration'; +import { GitLabIntegration } from '@backstage/integration'; +import * as http from 'http'; +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { MergeResult } from 'isomorphic-git'; +import { PushResult } from 'isomorphic-git'; +import { Readable } from 'stream'; +import { ReadCommitResult } from 'isomorphic-git'; +import { RequestHandler } from 'express'; +import { Router } from 'express'; +import { Server } from 'http'; +import * as winston from 'winston'; +import { Writable } from 'stream'; + +// Warning: (ae-missing-release-tag) "AzureUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class AzureUrlReader implements UrlReader { + constructor(integration: AzureIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // Warning: (ae-forgotten-export) The symbol "ReaderFactory" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // Warning: (ae-forgotten-export) The symbol "ReadTreeOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // Warning: (ae-forgotten-export) The symbol "SearchOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// Warning: (ae-missing-release-tag) "BitbucketUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class BitbucketUrlReader implements UrlReader { + constructor(integration: BitbucketIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// Warning: (ae-missing-release-tag) "coloredFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const coloredFormat: Format; + +// Warning: (ae-missing-release-tag) "createDatabase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public @deprecated +export const createDatabase: typeof createDatabaseClient; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "createDatabaseClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function createDatabaseClient(dbConfig: Config, overrides?: Partial): Knex; + +// Warning: (ae-missing-release-tag) "createRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createRootLogger(options?: winston.LoggerOptions, env?: NodeJS.ProcessEnv): winston.Logger; + +// Warning: (ae-forgotten-export) The symbol "ServiceBuilderImpl" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "createServiceBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl; + +// Warning: (ae-forgotten-export) The symbol "StatusCheckRouterOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "createStatusCheckRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise; + +// Warning: (ae-missing-release-tag) "ensureDatabaseExists" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function ensureDatabaseExists(dbConfig: Config, ...databases: Array): Promise; + +// Warning: (ae-missing-release-tag) "errorHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler; + +// Warning: (ae-missing-release-tag) "ErrorHandlerOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ErrorHandlerOptions = { + showStackTraces?: boolean; + logger?: Logger; + logClientErrors?: boolean; +}; + +// Warning: (ae-missing-release-tag) "getRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getRootLogger(): winston.Logger; + +// Warning: (ae-missing-release-tag) "getVoidLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getVoidLogger(): winston.Logger; + +// Warning: (ae-missing-release-tag) "Git" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class Git { + // (undocumented) + add({ dir, filepath, }: { + dir: string; + filepath: string; + }): Promise; + // (undocumented) + addRemote({ dir, url, remote, }: { + dir: string; + remote: string; + url: string; + }): Promise; + // (undocumented) + clone({ url, dir, ref, }: { + url: string; + dir: string; + ref?: string; + }): Promise; + // (undocumented) + commit({ dir, message, author, committer, }: { + dir: string; + message: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + currentBranch({ dir, fullName, }: { + dir: string; + fullName?: boolean; + }): Promise; + // (undocumented) + fetch({ dir, remote, }: { + dir: string; + remote?: string; + }): Promise; + // (undocumented) + static fromAuth: ({ username, password, logger, }: { + username?: string | undefined; + password?: string | undefined; + logger?: Logger | undefined; + }) => Git; + // (undocumented) + init({ dir }: { + dir: string; + }): Promise; + // (undocumented) + merge({ dir, theirs, ours, author, committer, }: { + dir: string; + theirs: string; + ours?: string; + author: { + name: string; + email: string; + }; + committer: { + name: string; + email: string; + }; + }): Promise; + // (undocumented) + push({ dir, remote }: { + dir: string; + remote: string; + }): Promise; + // (undocumented) + readCommit({ dir, sha, }: { + dir: string; + sha: string; + }): Promise; + // (undocumented) + resolveRef({ dir, ref, }: { + dir: string; + ref: string; + }): Promise; +} + +// Warning: (ae-missing-release-tag) "GithubUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class GithubUrlReader implements UrlReader { + constructor(integration: GitHubIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// Warning: (ae-missing-release-tag) "GitlabUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class GitlabUrlReader implements UrlReader { + constructor(integration: GitLabIntegration, deps: { + treeResponseFactory: ReadTreeResponseFactory; + }); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(url: string, options?: ReadTreeOptions): Promise; + // (undocumented) + search(url: string, options?: SearchOptions): Promise; + // (undocumented) + toString(): string; +} + +// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "loadBackendConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function loadBackendConfig(options: Options): Promise; + +// Warning: (ae-missing-release-tag) "notFoundHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function notFoundHandler(): RequestHandler; + +// Warning: (ae-missing-release-tag) "PluginDatabaseManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface PluginDatabaseManager { + getClient(): Promise; +} + +// Warning: (ae-missing-release-tag) "PluginEndpointDiscovery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type PluginEndpointDiscovery = { + getBaseUrl(pluginId: string): Promise; + getExternalBaseUrl(pluginId: string): Promise; +}; + +// Warning: (ae-missing-release-tag) "ReadTreeResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ReadTreeResponse = { + files(): Promise; + archive(): Promise; + dir(options?: ReadTreeResponseDirOptions): Promise; + etag: string; +}; + +// Warning: (ae-missing-release-tag) "ReadTreeResponseFile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "requestLoggingHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function requestLoggingHandler(logger?: Logger): RequestHandler; + +// Warning: (ae-missing-release-tag) "resolvePackagePath" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function resolvePackagePath(name: string, ...paths: string[]): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (ae-forgotten-export) The symbol "RunDockerContainerOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "runDockerContainer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const runDockerContainer: ({ imageName, args, logStream, dockerClient, mountDirs, workingDir, envVars, createOptions, }: RunDockerContainerOptions) => Promise<{ + error: any; + statusCode: any; +}>; + +// Warning: (ae-missing-release-tag) "SearchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type SearchResponse = { + files: SearchResponseFile[]; + etag: string; +}; + +// Warning: (ae-missing-release-tag) "SearchResponseFile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type SearchResponseFile = { + url: string; + content(): Promise; +}; + +// Warning: (ae-missing-release-tag) "ServiceBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ServiceBuilder = { + loadConfig(config: ConfigReader): ServiceBuilder; + setPort(port: number): ServiceBuilder; + setHost(host: string): ServiceBuilder; + setLogger(logger: Logger): ServiceBuilder; + enableCors(options: cors.CorsOptions): ServiceBuilder; + setHttpsSettings(settings: HttpsSettings): ServiceBuilder; + addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; + start(): Promise; +}; + +// Warning: (ae-missing-release-tag) "setRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function setRootLogger(newLogger: winston.Logger): void; + +// Warning: (ae-missing-release-tag) "SingleConnectionDatabaseManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class SingleConnectionDatabaseManager { + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + forPlugin(pluginId: string): PluginDatabaseManager; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + static fromConfig(config: Config): SingleConnectionDatabaseManager; + } + +// Warning: (ae-missing-release-tag) "SingleHostDiscovery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class SingleHostDiscovery implements PluginEndpointDiscovery { + static fromConfig(config: Config, options?: { + basePath?: string; + }): SingleHostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; + } + +// Warning: (ae-missing-release-tag) "StatusCheck" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type StatusCheck = () => Promise; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "statusCheckHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function statusCheckHandler(options?: StatusCheckHandlerOptions): Promise; + +// Warning: (ae-missing-release-tag) "StatusCheckHandlerOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface StatusCheckHandlerOptions { + statusCheck?: StatusCheck; +} + +// Warning: (ae-missing-release-tag) "UrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type UrlReader = { + read(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; + search(url: string, options?: SearchOptions): Promise; +}; + +// Warning: (ae-missing-release-tag) "UrlReaders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class UrlReaders { + // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts + static create({ logger, config, factories }: CreateOptions): UrlReader; + static default({ logger, config, factories }: CreateOptions): UrlReader; +} + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "useHotCleanup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function useHotCleanup(_module: NodeModule, cancelEffect: () => void): void; + +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@warning" is not defined in this configuration +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "useHotMemoize" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; + + +// Warnings were encountered during analysis: +// +// src/middleware/errorHandler.ts:47:24 - (tsdoc-malformed-html-name) Invalid HTML element: A space is not allowed here +// src/reading/AzureUrlReader.ts:53:30 - (ae-forgotten-export) The symbol "ReadTreeResponseFactory" needs to be exported by the entry point index.d.ts +// src/reading/types.ts:87:3 - (ae-forgotten-export) The symbol "ReadTreeResponseDirOptions" needs to be exported by the entry point index.d.ts +// src/service/types.ts:28:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:39:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:48:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:57:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:67:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:76:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:78:3 - (ae-forgotten-export) The symbol "HttpsSettings" needs to be exported by the entry point index.d.ts +// src/service/types.ts:83:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/service/types.ts:84:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md new file mode 100644 index 0000000000..fe12d3a62c --- /dev/null +++ b/packages/catalog-client/api-report.md @@ -0,0 +1,105 @@ +## API Report File for "@backstage/catalog-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { Location as Location_2 } from '@backstage/catalog-model'; + +// Warning: (ae-missing-release-tag) "AddLocationRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AddLocationRequest = { + type?: string; + target: string; + dryRun?: boolean; + presence?: 'optional' | 'required'; +}; + +// Warning: (ae-missing-release-tag) "AddLocationResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AddLocationResponse = { + location: Location_2; + entities: Entity[]; +}; + +// Warning: (ae-missing-release-tag) "CatalogApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface CatalogApi { + // (undocumented) + addLocation(location: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // Warning: (ae-forgotten-export) The symbol "CatalogRequestOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(name: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: String, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; +} + +// Warning: (ae-missing-release-tag) "CatalogClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class CatalogClient implements CatalogApi { + constructor(options: { + discoveryApi: DiscoveryApi; + }); + // (undocumented) + addLocation({ type, target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions): Promise; + // (undocumented) + getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; + // (undocumented) + getEntityByName(compoundName: EntityName, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + getLocationById(id: String, options?: CatalogRequestOptions): Promise; + // (undocumented) + getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeEntityByUid(uid: string, options?: CatalogRequestOptions): Promise; + // (undocumented) + removeLocationById(id: string, options?: CatalogRequestOptions): Promise; + } + +// Warning: (ae-missing-release-tag) "CatalogEntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CatalogEntitiesRequest = { + filter?: Record | undefined; + fields?: string[] | undefined; +}; + +// Warning: (ae-missing-release-tag) "CatalogListResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CatalogListResponse = { + items: T[]; +}; + + +// Warnings were encountered during analysis: +// +// src/CatalogClient.ts:40:26 - (ae-forgotten-export) The symbol "DiscoveryApi" needs to be exported by the entry point index.d.ts +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md new file mode 100644 index 0000000000..b3e3303589 --- /dev/null +++ b/packages/catalog-model/api-report.md @@ -0,0 +1,811 @@ +## API Report File for "@backstage/catalog-model" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; +import { JsonValue } from '@backstage/config'; +import * as yup from 'yup'; + +// Warning: (ae-missing-release-tag) "analyzeLocationSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const analyzeLocationSchema: yup.ObjectSchema<{ + location: LocationSpec; +}, object>; + +// Warning: (ae-missing-release-tag) "ApiEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface ApiEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + definition: string; + system?: string; + }; +} + +export { ApiEntityV1alpha1 as ApiEntity } + +export { ApiEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "apiEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const apiEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "CommonValidatorFunctions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class CommonValidatorFunctions { + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + static isJsonSafe(value: unknown): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool + static isValidDnsLabel(value: unknown): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool + static isValidDnsSubdomain(value: unknown): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + static isValidPrefixAndOrSuffix(value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + static isValidString(value: unknown): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + static isValidUrl(value: unknown): boolean; +} + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-forgotten-export) The symbol "EntityRefContext" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "compareEntityToRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName, context?: EntityRefContext): boolean; + +// Warning: (ae-missing-release-tag) "ComponentEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface ComponentEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_2[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_2; + // (undocumented) + spec: { + type: string; + lifecycle: string; + owner: string; + subcomponentOf?: string; + providesApis?: string[]; + consumesApis?: string[]; + system?: string; + }; +} + +export { ComponentEntityV1alpha1 as ComponentEntity } + +export { ComponentEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "componentEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const componentEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "DefaultNamespaceEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class DefaultNamespaceEntityPolicy implements EntityPolicy { + constructor(namespace?: string); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// Warning: (ae-missing-release-tag) "DomainEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface DomainEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_3[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_3; + // (undocumented) + spec: { + owner: string; + }; +} + +export { DomainEntityV1alpha1 as DomainEntity } + +export { DomainEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "domainEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const domainEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "EDIT_URL_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EDIT_URL_ANNOTATION = "backstage.io/edit-url"; + +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (ae-missing-release-tag) "Entity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type Entity = { + apiVersion: string; + kind: string; + metadata: EntityMeta; + spec?: JsonObject; + relations?: EntityRelation[]; +}; + +// Warning: (ae-missing-release-tag) "ENTITY_DEFAULT_NAMESPACE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const ENTITY_DEFAULT_NAMESPACE = "default"; + +// Warning: (ae-missing-release-tag) "ENTITY_META_GENERATED_FIELDS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"]; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "entityHasChanges" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function entityHasChanges(previous: Entity, next: Entity): boolean; + +// Warning: (ae-missing-release-tag) "EntityLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityLink = { + url: string; + title?: string; + icon?: string; +}; + +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (ae-missing-release-tag) "EntityMeta" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityMeta = JsonObject & { + uid?: string; + etag?: string; + generation?: number; + name: string; + namespace?: string; + description?: string; + labels?: Record; + annotations?: Record; + tags?: string[]; + links?: EntityLink[]; +}; + +// Warning: (ae-missing-release-tag) "EntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityName = { + kind: string; + namespace: string; + name: string; +}; + +// Warning: (ae-missing-release-tag) "EntityPolicies" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityPolicies: { + allOf(policies: EntityPolicy[]): AllEntityPolicies; + oneOf(policies: EntityPolicy[]): AnyEntityPolicy; +}; + +// Warning: (ae-missing-release-tag) "EntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityPolicy = { + enforce(entity: Entity): Promise; +}; + +// Warning: (ae-missing-release-tag) "EntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityRef = string | { + kind?: string; + namespace?: string; + name: string; +}; + +// Warning: (ae-missing-release-tag) "EntityRelation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityRelation = { + type: string; + target: EntityName; +}; + +// Warning: (ae-missing-release-tag) "EntityRelationSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type EntityRelationSpec = { + source: EntityName; + type: string; + target: EntityName; +}; + +// Warning: (ae-missing-release-tag) "FieldFormatEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class FieldFormatEntityPolicy implements EntityPolicy { + constructor(validators?: Validators); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// Warning: (ae-missing-release-tag) "generateEntityEtag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function generateEntityEtag(): string; + +// Warning: (ae-missing-release-tag) "generateEntityUid" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function generateEntityUid(): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "generateUpdatedEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function generateUpdatedEntity(previous: Entity, next: Entity): Entity; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getEntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getEntityName(entity: Entity): EntityName; + +// Warning: (ae-missing-release-tag) "GroupEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface GroupEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_4[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_4; + // (undocumented) + spec: { + type: string; + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + parent?: string; + children: string[]; + members?: string[]; + }; +} + +export { GroupEntityV1alpha1 as GroupEntity } + +export { GroupEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "groupEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const groupEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "JSONSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type JSONSchema = JSONSchema7 & { + [key in string]?: JsonValue; +}; + +// Warning: (ae-missing-release-tag) "KindValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type KindValidator = { + check(entity: Entity): Promise; +}; + +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool +// Warning: (ae-missing-release-tag) "KubernetesValidatorFunctions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class KubernetesValidatorFunctions { + // (undocumented) + static isValidAnnotationKey(value: unknown): boolean; + // (undocumented) + static isValidAnnotationValue(value: unknown): boolean; + // (undocumented) + static isValidApiVersion(value: unknown): boolean; + // (undocumented) + static isValidKind(value: unknown): boolean; + // (undocumented) + static isValidLabelKey(value: unknown): boolean; + // (undocumented) + static isValidLabelValue(value: unknown): boolean; + // (undocumented) + static isValidNamespace(value: unknown): boolean; + // (undocumented) + static isValidObjectName(value: unknown): boolean; +} + +// Warning: (ae-missing-release-tag) "Location" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +type Location_2 = { + id: string; +} & LocationSpec; + +export { Location_2 as Location } + +// Warning: (ae-missing-release-tag) "LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const LOCATION_ANNOTATION = "backstage.io/managed-by-location"; + +// Warning: (ae-missing-release-tag) "LocationEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface LocationEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_5[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_5; + // (undocumented) + spec: { + type?: string; + target?: string; + targets?: string[]; + }; +} + +export { LocationEntityV1alpha1 as LocationEntity } + +export { LocationEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "locationEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const locationEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "locationSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const locationSchema: yup.ObjectSchema; + +// Warning: (ae-missing-release-tag) "LocationSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LocationSpec = { + type: string; + target: string; + presence?: 'optional' | 'required'; +}; + +// Warning: (ae-missing-release-tag) "locationSpecSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const locationSpecSchema: yup.ObjectSchema; + +// Warning: (ae-missing-release-tag) "makeValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function makeValidator(overrides?: Partial): Validators; + +// Warning: (ae-missing-release-tag) "NoForeignRootFieldsEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { + constructor(knownFields?: string[]); + // (undocumented) + enforce(entity: Entity): Promise; + } + +// Warning: (ae-missing-release-tag) "ORIGIN_LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const ORIGIN_LOCATION_ANNOTATION = "backstage.io/managed-by-origin-location"; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "parseEntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function parseEntityName(ref: EntityRef, context?: EntityRefContext): EntityName; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; + defaultNamespace: string; +}): { + kind: string; + namespace: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultKind: string; +}): { + kind: string; + namespace?: string; + name: string; +}; + +// @public (undocumented) +export function parseEntityRef(ref: EntityRef, context?: { + defaultNamespace: string; +}): { + kind?: string; + namespace: string; + name: string; +}; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (ae-missing-release-tag) "parseLocationReference" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function parseLocationReference(ref: string): { + type: string; + target: string; +}; + +// Warning: (ae-missing-release-tag) "RELATION_API_CONSUMED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_API_CONSUMED_BY = "apiConsumedBy"; + +// Warning: (ae-missing-release-tag) "RELATION_API_PROVIDED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_API_PROVIDED_BY = "apiProvidedBy"; + +// Warning: (ae-missing-release-tag) "RELATION_CHILD_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_CHILD_OF = "childOf"; + +// Warning: (ae-missing-release-tag) "RELATION_CONSUMES_API" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_CONSUMES_API = "consumesApi"; + +// Warning: (ae-missing-release-tag) "RELATION_DEPENDENCY_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_DEPENDENCY_OF = "dependencyOf"; + +// Warning: (ae-missing-release-tag) "RELATION_DEPENDS_ON" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_DEPENDS_ON = "dependsOn"; + +// Warning: (ae-missing-release-tag) "RELATION_HAS_MEMBER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_HAS_MEMBER = "hasMember"; + +// Warning: (ae-missing-release-tag) "RELATION_HAS_PART" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_HAS_PART = "hasPart"; + +// Warning: (ae-missing-release-tag) "RELATION_MEMBER_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_MEMBER_OF = "memberOf"; + +// Warning: (ae-missing-release-tag) "RELATION_OWNED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_OWNED_BY = "ownedBy"; + +// Warning: (ae-missing-release-tag) "RELATION_OWNER_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_OWNER_OF = "ownerOf"; + +// Warning: (ae-missing-release-tag) "RELATION_PARENT_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_PARENT_OF = "parentOf"; + +// Warning: (ae-missing-release-tag) "RELATION_PART_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const RELATION_PART_OF = "partOf"; + +// Warning: (ae-missing-release-tag) "RELATION_PROVIDES_API" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const RELATION_PROVIDES_API = "providesApi"; + +// Warning: (ae-missing-release-tag) "ResourceEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface ResourceEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_6[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_6; + // (undocumented) + spec: { + type: string; + owner: string; + system?: string; + }; +} + +export { ResourceEntityV1alpha1 as ResourceEntity } + +export { ResourceEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "resourceEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const resourceEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "schemaValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public @deprecated (undocumented) +export function schemaValidator(kind: string, apiVersion: readonly string[], schema: yup.Schema): KindValidator; + +// Warning: (ae-missing-release-tag) "SchemaValidEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class SchemaValidEntityPolicy implements EntityPolicy { + // (undocumented) + enforce(entity: Entity): Promise; + } + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "serializeEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public @deprecated +export function serializeEntityRef(ref: Entity | { + kind?: string; + namespace?: string; + name: string; +}): EntityRef; + +// Warning: (ae-missing-release-tag) "SOURCE_LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SOURCE_LOCATION_ANNOTATION = "backstage.io/source-location"; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "stringifyEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function stringifyEntityRef(ref: Entity | { + kind: string; + namespace?: string; + name: string; +}): string; + +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "stringifyLocationReference" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function stringifyLocationReference(ref: { + type: string; + target: string; +}): string; + +// Warning: (ae-missing-release-tag) "SystemEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface SystemEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_7[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_7; + // (undocumented) + spec: { + owner: string; + domain?: string; + }; +} + +export { SystemEntityV1alpha1 as SystemEntity } + +export { SystemEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "systemEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const systemEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "TemplateEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface TemplateEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_8[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_8; + // (undocumented) + spec: { + type: string; + templater: string; + path?: string; + schema: JSONSchema; + }; +} + +export { TemplateEntityV1alpha1 as TemplateEntity } + +export { TemplateEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "templateEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const templateEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "TemplateEntityV1beta2" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface TemplateEntityV1beta2 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_9[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_9; + // (undocumented) + metadata: EntityMeta & { + title?: string; + }; + // (undocumented) + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + parameters?: JsonObject; + }>; + output?: { + [name: string]: string; + }; + }; +} + +// Warning: (ae-missing-release-tag) "templateEntityV1beta2Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const templateEntityV1beta2Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "UserEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +interface UserEntityV1alpha1 extends Entity { + // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts + // + // (undocumented) + apiVersion: typeof API_VERSION_10[number]; + // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts + // + // (undocumented) + kind: typeof KIND_10; + // (undocumented) + spec: { + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + memberOf: string[]; + }; +} + +export { UserEntityV1alpha1 as UserEntity } + +export { UserEntityV1alpha1 } + +// Warning: (ae-missing-release-tag) "userEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const userEntityV1alpha1Validator: KindValidator; + +// Warning: (ae-missing-release-tag) "Validators" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type Validators = { + isValidApiVersion(value: unknown): boolean; + isValidKind(value: unknown): boolean; + isValidEntityName(value: unknown): boolean; + isValidNamespace(value: unknown): boolean; + isValidLabelKey(value: unknown): boolean; + isValidLabelValue(value: unknown): boolean; + isValidAnnotationKey(value: unknown): boolean; + isValidAnnotationValue(value: unknown): boolean; + isValidTag(value: unknown): boolean; +}; + +// Warning: (ae-missing-release-tag) "VIEW_URL_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const VIEW_URL_ANNOTATION = "backstage.io/view-url"; + + +// Warnings were encountered during analysis: +// +// src/EntityPolicies.ts:55:28 - (ae-forgotten-export) The symbol "AllEntityPolicies" needs to be exported by the entry point index.d.ts +// src/EntityPolicies.ts:57:3 - (ae-forgotten-export) The symbol "AnyEntityPolicy" needs to be exported by the entry point index.d.ts +// src/entity/policies/types.ts:27:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/kinds/types.ts:26:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md new file mode 100644 index 0000000000..81d494e4ef --- /dev/null +++ b/packages/cli-common/api-report.md @@ -0,0 +1,35 @@ +## API Report File for "@backstage/cli-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// Warning: (ae-missing-release-tag) "findPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function findPaths(searchDir: string): Paths; + +// Warning: (ae-missing-release-tag) "Paths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type Paths = { + ownDir: string; + ownRoot: string; + targetDir: string; + targetRoot: string; + resolveOwn: ResolveFunc; + resolveOwnRoot: ResolveFunc; + resolveTarget: ResolveFunc; + resolveTargetRoot: ResolveFunc; +}; + +// Warnings were encountered during analysis: +// +// src/paths.ts:38:3 - (ae-forgotten-export) The symbol "ResolveFunc" needs to be exported by the entry point index.d.ts +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md new file mode 100644 index 0000000000..0c011aea65 --- /dev/null +++ b/packages/config-loader/api-report.md @@ -0,0 +1,72 @@ +## API Report File for "@backstage/config-loader" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AppConfig } from '@backstage/config'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema7 } from 'json-schema'; + +// Warning: (ae-missing-release-tag) "ConfigSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ConfigSchema = { + process(appConfigs: AppConfig[], options?: ConfigProcessingOptions): AppConfig[]; + serialize(): JsonObject; +}; + +// Warning: (ae-forgotten-export) The symbol "CONFIG_VISIBILITIES" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "ConfigVisibility" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; + +// Warning: (ae-missing-release-tag) "loadConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function loadConfig(options: LoadConfigOptions): Promise; + +// Warning: (ae-missing-release-tag) "LoadConfigOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LoadConfigOptions = { + configRoot: string; + configPaths: string[]; + env?: string; + experimentalEnvFunc?: EnvFunc; +}; + +// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "loadConfigSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function loadConfigSchema(options: Options): Promise; + +// Warning: (ae-missing-release-tag) "mergeConfigSchemas" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function mergeConfigSchemas(schemas: JSONSchema7[]): JSONSchema7; + +// Warning: (ae-missing-release-tag) "readEnvConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readEnvConfig(env: { + [name: string]: string | undefined; +}): AppConfig[]; + + +// Warnings were encountered during analysis: +// +// src/lib/schema/types.ts:105:3 - (ae-forgotten-export) The symbol "ConfigProcessingOptions" needs to be exported by the entry point index.d.ts +// src/loader.ts:42:6 - (tsdoc-unsupported-tag) The TSDoc tag "@experimental" is not supported by this tool +// src/loader.ts:44:3 - (ae-forgotten-export) The symbol "EnvFunc" needs to be exported by the entry point index.d.ts +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/config/api-report.md b/packages/config/api-report.md new file mode 100644 index 0000000000..8ff881a34b --- /dev/null +++ b/packages/config/api-report.md @@ -0,0 +1,112 @@ +## API Report File for "@backstage/config" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// Warning: (ae-missing-release-tag) "AppConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AppConfig = { + context: string; + data: JsonObject; +}; + +// Warning: (ae-missing-release-tag) "Config" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type Config = { + has(key: string): boolean; + keys(): string[]; + get(key?: string): T; + getOptional(key?: string): T | undefined; + getConfig(key: string): Config; + getOptionalConfig(key: string): Config | undefined; + getConfigArray(key: string): Config[]; + getOptionalConfigArray(key: string): Config[] | undefined; + getNumber(key: string): number; + getOptionalNumber(key: string): number | undefined; + getBoolean(key: string): boolean; + getOptionalBoolean(key: string): boolean | undefined; + getString(key: string): string; + getOptionalString(key: string): string | undefined; + getStringArray(key: string): string[]; + getOptionalStringArray(key: string): string[] | undefined; +}; + +// Warning: (ae-missing-release-tag) "ConfigReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ConfigReader implements Config { + constructor(data: JsonObject | undefined, context?: string, fallback?: ConfigReader | undefined, prefix?: string); + // (undocumented) + static fromConfigs(configs: AppConfig[]): ConfigReader; + // (undocumented) + get(key?: string): T; + // (undocumented) + getBoolean(key: string): boolean; + // (undocumented) + getConfig(key: string): ConfigReader; + // (undocumented) + getConfigArray(key: string): ConfigReader[]; + // (undocumented) + getNumber(key: string): number; + // (undocumented) + getOptional(key?: string): T | undefined; + // (undocumented) + getOptionalBoolean(key: string): boolean | undefined; + // (undocumented) + getOptionalConfig(key: string): ConfigReader | undefined; + // (undocumented) + getOptionalConfigArray(key: string): ConfigReader[] | undefined; + // (undocumented) + getOptionalNumber(key: string): number | undefined; + // (undocumented) + getOptionalString(key: string): string | undefined; + // (undocumented) + getOptionalStringArray(key: string): string[] | undefined; + // (undocumented) + getString(key: string): string; + // (undocumented) + getStringArray(key: string): string[]; + // (undocumented) + has(key: string): boolean; + // (undocumented) + keys(): string[]; + } + +// Warning: (ae-missing-release-tag) "JsonArray" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface JsonArray extends Array { +} + +// Warning: (ae-missing-release-tag) "JsonObject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type JsonObject = { + [key in string]?: JsonValue; +}; + +// Warning: (ae-missing-release-tag) "JsonPrimitive" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type JsonPrimitive = number | string | boolean | null; + +// Warning: (ae-missing-release-tag) "JsonValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type JsonValue = JsonObject | JsonArray | JsonPrimitive; + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md new file mode 100644 index 0000000000..41c793e189 --- /dev/null +++ b/packages/dev-utils/api-report.md @@ -0,0 +1,39 @@ +## API Report File for "@backstage/dev-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiFactory } from '@backstage/core'; +import { ComponentType } from 'react'; +import { createPlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { GridProps } from '@material-ui/core'; +import { IconComponent } from '@backstage/core'; +import { ReactNode } from 'react'; + +// Warning: (ae-forgotten-export) The symbol "DevAppBuilder" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "createDevApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function createDevApp(): DevAppBuilder; + +// Warning: (ae-missing-release-tag) "EntityGridItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityGridItem: ({ entity, classes, ...rest }: Omit, "container" | "item"> & { + entity: Entity; +}) => JSX.Element; + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md new file mode 100644 index 0000000000..2a4b482463 --- /dev/null +++ b/packages/errors/api-report.md @@ -0,0 +1,125 @@ +## API Report File for "@backstage/errors" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonObject } from '@backstage/config'; + +// Warning: (ae-missing-release-tag) "AuthenticationError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class AuthenticationError extends CustomErrorBase { +} + +// Warning: (ae-missing-release-tag) "ConflictError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class ConflictError extends CustomErrorBase { +} + +// Warning: (ae-missing-release-tag) "CustomErrorBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class CustomErrorBase extends Error { + constructor(message?: string, cause?: Error); + // (undocumented) + readonly cause?: Error; +} + +// Warning: (ae-missing-release-tag) "deserializeError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function deserializeError(data: SerializedError): T; + +// Warning: (ae-missing-release-tag) "ErrorResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ErrorResponse = { + error: SerializedError; + request?: { + method: string; + url: string; + }; + response: { + statusCode: number; + }; +}; + +// Warning: (ae-missing-release-tag) "InputError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class InputError extends CustomErrorBase { +} + +// Warning: (ae-missing-release-tag) "NotAllowedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class NotAllowedError extends CustomErrorBase { +} + +// Warning: (ae-missing-release-tag) "NotFoundError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class NotFoundError extends CustomErrorBase { +} + +// Warning: (ae-missing-release-tag) "NotModifiedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class NotModifiedError extends CustomErrorBase { +} + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "parseErrorResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function parseErrorResponse(response: Response): Promise; + +// Warning: (ae-missing-release-tag) "ResponseError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class ResponseError extends Error { + constructor(props: { + message: string; + response: Response; + data: ErrorResponse; + cause: Error; + }); + readonly cause: Error; + readonly data: ErrorResponse; + static fromResponse(response: Response): Promise; + readonly response: Response; +} + +// Warning: (ae-missing-release-tag) "SerializedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type SerializedError = JsonObject & { + name: string; + message: string; + stack?: string; + code?: string; +}; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// Warning: (ae-missing-release-tag) "serializeError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function serializeError(error: Error, options?: { + includeStack?: boolean; +}): SerializedError; + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md new file mode 100644 index 0000000000..aa12687415 --- /dev/null +++ b/packages/integration-react/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/integration-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core'; +import { Config } from '@backstage/config'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +// Warning: (ae-missing-release-tag) "ScmIntegrationsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ScmIntegrationsApi { + // (undocumented) + static fromConfig(config: Config): ScmIntegrationRegistry; +} + +// Warning: (ae-missing-release-tag) "scmIntegrationsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const scmIntegrationsApiRef: ApiRef; + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md new file mode 100644 index 0000000000..8782abaf1a --- /dev/null +++ b/packages/integration/api-report.md @@ -0,0 +1,414 @@ +## API Report File for "@backstage/integration" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; + +// Warning: (ae-missing-release-tag) "AzureIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class AzureIntegration implements ScmIntegration { + constructor(integrationConfig: AzureIntegrationConfig); + // (undocumented) + get config(): AzureIntegrationConfig; + // Warning: (ae-forgotten-export) The symbol "ScmIntegrationsFactory" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// Warning: (ae-missing-release-tag) "AzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AzureIntegrationConfig = { + host: string; + token?: string; +}; + +// Warning: (ae-missing-release-tag) "BitbucketIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class BitbucketIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketIntegrationConfig); + // (undocumented) + get config(): BitbucketIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// Warning: (ae-missing-release-tag) "BitbucketIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type BitbucketIntegrationConfig = { + host: string; + apiBaseUrl?: string; + token?: string; + username?: string; + appPassword?: string; +}; + +// Warning: (ae-missing-release-tag) "defaultScmResolveUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function defaultScmResolveUrl(options: { + url: string; + base: string; + lineNumber?: number; +}): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getAzureCommitsUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getAzureCommitsUrl(url: string): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getAzureDownloadUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getAzureDownloadUrl(url: string): string; + +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getAzureFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getAzureFileFetchUrl(url: string): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getAzureRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getAzureRequestOptions(config: AzureIntegrationConfig, additionalHeaders?: Record): RequestInit; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getBitbucketDefaultBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getBitbucketDefaultBranch(url: string, config: BitbucketIntegrationConfig): Promise; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getBitbucketDownloadUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getBitbucketDownloadUrl(url: string, config: BitbucketIntegrationConfig): Promise; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getBitbucketFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getBitbucketFileFetchUrl(url: string, config: BitbucketIntegrationConfig): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getBitbucketRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getBitbucketRequestOptions(config: BitbucketIntegrationConfig): RequestInit; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getGitHubFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getGitHubFileFetchUrl(url: string, config: GitHubIntegrationConfig): string; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getGitHubRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getGitHubRequestOptions(config: GitHubIntegrationConfig): RequestInit; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getGitLabFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getGitLabFileFetchUrl(url: string, config: GitLabIntegrationConfig): Promise; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "getGitLabRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function getGitLabRequestOptions(config: GitLabIntegrationConfig): RequestInit; + +// Warning: (ae-missing-release-tag) "GithubCredentialsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class GithubCredentialsProvider { + // (undocumented) + static create(config: GitHubIntegrationConfig): GithubCredentialsProvider; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@type" is not defined in this configuration + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (ae-forgotten-export) The symbol "GithubCredentials" needs to be exported by the entry point index.d.ts + getCredentials(opts: { + url: string; + }): Promise; + } + +// Warning: (ae-missing-release-tag) "GitHubIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class GitHubIntegration implements ScmIntegration { + constructor(integrationConfig: GitHubIntegrationConfig); + // (undocumented) + get config(): GitHubIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// Warning: (ae-missing-release-tag) "GitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type GitHubIntegrationConfig = { + host: string; + apiBaseUrl?: string; + rawBaseUrl?: string; + token?: string; + apps?: GithubAppConfig[]; +}; + +// Warning: (ae-missing-release-tag) "GitLabIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class GitLabIntegration implements ScmIntegration { + constructor(integrationConfig: GitLabIntegrationConfig); + // (undocumented) + get config(): GitLabIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// Warning: (ae-missing-release-tag) "GitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type GitLabIntegrationConfig = { + host: string; + apiBaseUrl: string; + token?: string; + baseUrl: string; +}; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readAzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readAzureIntegrationConfig(config: Config): AzureIntegrationConfig; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readAzureIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readAzureIntegrationConfigs(configs: Config[]): AzureIntegrationConfig[]; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readBitbucketIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readBitbucketIntegrationConfig(config: Config): BitbucketIntegrationConfig; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readBitbucketIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readBitbucketIntegrationConfigs(configs: Config[]): BitbucketIntegrationConfig[]; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readGitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readGitHubIntegrationConfig(config: Config): GitHubIntegrationConfig; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readGitHubIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readGitHubIntegrationConfigs(configs: Config[]): GitHubIntegrationConfig[]; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readGitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readGitLabIntegrationConfig(config: Config): GitLabIntegrationConfig; + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "readGitLabIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function readGitLabIntegrationConfigs(configs: Config[]): GitLabIntegrationConfig[]; + +// Warning: (ae-missing-release-tag) "ScmIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface ScmIntegration { + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + resolveEditUrl(url: string): string; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + title: string; + type: string; +} + +// Warning: (ae-missing-release-tag) "ScmIntegrationRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { + // (undocumented) + azure: ScmIntegrationsGroup; + // (undocumented) + bitbucket: ScmIntegrationsGroup; + // (undocumented) + github: ScmIntegrationsGroup; + // (undocumented) + gitlab: ScmIntegrationsGroup; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + resolveEditUrl(url: string): string; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; +} + +// Warning: (ae-missing-release-tag) "ScmIntegrations" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ScmIntegrations implements ScmIntegrationRegistry { + // Warning: (ae-forgotten-export) The symbol "IntegrationsByType" needs to be exported by the entry point index.d.ts + constructor(integrationsByType: IntegrationsByType); + // (undocumented) + get azure(): ScmIntegrationsGroup; + // (undocumented) + get bitbucket(): ScmIntegrationsGroup; + // (undocumented) + byHost(host: string): ScmIntegration | undefined; + // (undocumented) + byUrl(url: string | URL): ScmIntegration | undefined; + // (undocumented) + static fromConfig(config: Config): ScmIntegrations; + // (undocumented) + get github(): ScmIntegrationsGroup; + // (undocumented) + get gitlab(): ScmIntegrationsGroup; + // (undocumented) + list(): ScmIntegration[]; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; +} + +// Warning: (ae-missing-release-tag) "ScmIntegrationsGroup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface ScmIntegrationsGroup { + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + byHost(host: string): T | undefined; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + byUrl(url: string | URL): T | undefined; + list(): T[]; +} + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. +// src/github/config.ts:67:3 - (ae-forgotten-export) The symbol "GithubAppConfig" needs to be exported by the entry point index.d.ts +// src/gitlab/config.ts:51:66 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// src/gitlab/config.ts:51:61 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md new file mode 100644 index 0000000000..3911059177 --- /dev/null +++ b/packages/search-common/api-report.md @@ -0,0 +1,70 @@ +## API Report File for "@backstage/search-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { JsonObject } from '@backstage/config'; + +// Warning: (ae-missing-release-tag) "DocumentCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface DocumentCollator { + // (undocumented) + execute(): Promise; +} + +// Warning: (ae-missing-release-tag) "DocumentDecorator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface DocumentDecorator { + // (undocumented) + execute(documents: IndexableDocument[]): Promise; +} + +// Warning: (ae-missing-release-tag) "IndexableDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface IndexableDocument { + location: string; + text: string; + title: string; +} + +// Warning: (ae-missing-release-tag) "SearchQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SearchQuery { + // (undocumented) + filters?: JsonObject; + // (undocumented) + pageCursor: string; + // (undocumented) + term: string; +} + +// Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SearchResult { + // (undocumented) + document: IndexableDocument; +} + +// Warning: (ae-missing-release-tag) "SearchResultSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SearchResultSet { + // (undocumented) + results: SearchResult[]; +} + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md new file mode 100644 index 0000000000..df1bea443c --- /dev/null +++ b/packages/techdocs-common/api-report.md @@ -0,0 +1,259 @@ +## API Report File for "@backstage/techdocs-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegrationConfig } from '@backstage/integration'; +import { Config } from '@backstage/config'; +import Docker from 'dockerode'; +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import express from 'express'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { GitLabIntegrationConfig } from '@backstage/integration'; +import { Logger } from 'winston'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; +import { Writable } from 'stream'; + +// Warning: (ae-missing-release-tag) "checkoutGitRepository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger) => Promise; + +// Warning: (ae-missing-release-tag) "CommonGitPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class CommonGitPreparer implements PreparerBase { + constructor(config: Config, logger: Logger); + // Warning: (ae-forgotten-export) The symbol "PreparerResponse" needs to be exported by the entry point index.d.ts + // + // (undocumented) + prepare(entity: Entity, options?: { + etag?: string; + }): Promise; +} + +// Warning: (ae-missing-release-tag) "DirectoryPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class DirectoryPreparer implements PreparerBase { + constructor(config: Config, logger: Logger, reader: UrlReader); + // (undocumented) + prepare(entity: Entity): Promise; + } + +// Warning: (ae-missing-release-tag) "GeneratorBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type GeneratorBase = { + run(opts: GeneratorRunOptions): Promise; +}; + +// Warning: (ae-missing-release-tag) "GeneratorBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type GeneratorBuilder = { + register(protocol: SupportedGeneratorKey, generator: GeneratorBase): void; + get(entity: Entity): GeneratorBase; +}; + +// Warning: (ae-missing-release-tag) "Generators" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class Generators implements GeneratorBuilder { + // (undocumented) + static fromConfig(config: Config, { logger }: { + logger: Logger; + }): Promise; + // (undocumented) + get(entity: Entity): GeneratorBase; + // (undocumented) + register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase): void; +} + +// Warning: (ae-missing-release-tag) "getAzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getAzureIntegrationConfig: (config: Config, host: string) => AzureIntegrationConfig; + +// Warning: (ae-missing-release-tag) "getDefaultBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getDefaultBranch: (repositoryUrl: string, config: Config) => Promise; + +// Warning: (ae-missing-release-tag) "getDocFilesFromRepository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getDocFilesFromRepository: (reader: UrlReader, entity: Entity, opts?: { + etag?: string | undefined; + logger?: Logger | undefined; +} | undefined) => Promise; + +// Warning: (ae-missing-release-tag) "getGitHost" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getGitHost(url: string): string; + +// Warning: (ae-missing-release-tag) "getGitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getGitHubIntegrationConfig: (config: Config, host: string) => GitHubIntegrationConfig; + +// Warning: (ae-missing-release-tag) "getGitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getGitLabIntegrationConfig: (config: Config, host: string) => GitLabIntegrationConfig; + +// Warning: (ae-missing-release-tag) "getGitRepositoryTempFolder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getGitRepositoryTempFolder: (repositoryUrl: string, config: Config) => Promise; + +// Warning: (ae-missing-release-tag) "getGitRepoType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getGitRepoType(url: string): string; + +// Warning: (ae-missing-release-tag) "getLastCommitTimestamp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger) => Promise; + +// Warning: (ae-missing-release-tag) "getLocationForEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation; + +// Warning: (ae-missing-release-tag) "getTokenForGitRepo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getTokenForGitRepo: (repositoryUrl: string, config: Config) => Promise; + +// Warning: (ae-missing-release-tag) "ParsedLocationAnnotation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ParsedLocationAnnotation = { + type: RemoteProtocol; + target: string; +}; + +// Warning: (ae-missing-release-tag) "parseReferenceAnnotation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const parseReferenceAnnotation: (annotationName: string, entity: Entity) => ParsedLocationAnnotation; + +// Warning: (ae-missing-release-tag) "PreparerBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PreparerBase = { + prepare(entity: Entity, options?: { + logger?: Logger; + etag?: string; + }): Promise; +}; + +// Warning: (ae-missing-release-tag) "PreparerBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PreparerBuilder = { + register(protocol: RemoteProtocol, preparer: PreparerBase): void; + get(entity: Entity): PreparerBase; +}; + +// Warning: (ae-missing-release-tag) "Preparers" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class Preparers implements PreparerBuilder { + // Warning: (ae-forgotten-export) The symbol "factoryOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static fromConfig(config: Config, { logger, reader }: factoryOptions): Promise; + // (undocumented) + get(entity: Entity): PreparerBase; + // (undocumented) + register(protocol: RemoteProtocol, preparer: PreparerBase): void; +} + +// Warning: (ae-missing-release-tag) "Publisher" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class Publisher { + // Warning: (ae-forgotten-export) The symbol "factoryOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static fromConfig(config: Config, { logger, discovery }: factoryOptions_2): Promise; +} + +// Warning: (ae-missing-release-tag) "PublisherBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface PublisherBase { + docsRouter(): express.Handler; + fetchTechDocsMetadata(entityName: EntityName): Promise; + hasDocsBeenGenerated(entityName: Entity): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (ae-forgotten-export) The symbol "PublishRequest" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "PublishResponse" needs to be exported by the entry point index.d.ts + publish(request: PublishRequest): Promise; +} + +// Warning: (ae-missing-release-tag) "PublisherType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type PublisherType = 'local' | 'googleGcs' | 'awsS3' | 'azureBlobStorage' | 'openStackSwift'; + +// Warning: (ae-missing-release-tag) "RemoteProtocol" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azure/api'; + +// Warning: (ae-missing-release-tag) "TechdocsGenerator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class TechdocsGenerator implements GeneratorBase { + constructor(logger: Logger, config: Config); + // (undocumented) + run({ inputDir, outputDir, dockerClient, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise; +} + +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "TechDocsMetadata" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type TechDocsMetadata = { + site_name: string; + site_description: string; + etag: string; +}; + +// Warning: (ae-missing-release-tag) "UrlPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class UrlPreparer implements PreparerBase { + constructor(reader: UrlReader, logger: Logger); + // (undocumented) + prepare(entity: Entity, options?: { + etag?: string; + }): Promise; + } + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. +// src/stages/generate/types.ts:42:3 - (ae-forgotten-export) The symbol "GeneratorRunOptions" needs to be exported by the entry point index.d.ts +// src/stages/generate/types.ts:54:3 - (ae-forgotten-export) The symbol "SupportedGeneratorKey" needs to be exported by the entry point index.d.ts +// src/stages/prepare/types.ts:35:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/stages/prepare/types.ts:36:6 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/stages/prepare/types.ts:38:31 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// src/stages/prepare/types.ts:38:14 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md new file mode 100644 index 0000000000..a462be7aca --- /dev/null +++ b/packages/test-utils-core/api-report.md @@ -0,0 +1,113 @@ +## API Report File for "@backstage/test-utils-core" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ReactElement } from 'react'; +import { RenderResult } from '@testing-library/react'; + +// Warning: (ae-missing-release-tag) "AsyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AsyncLogCollector = () => Promise; + +// Warning: (ae-missing-release-tag) "CollectedLogs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CollectedLogs = { + [key in T]: string[]; +}; + +// Warning: (ae-missing-release-tag) "Keyboard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class Keyboard { + constructor(target: any, { debug }?: { + debug?: boolean | undefined; + }); + // (undocumented) + click(): Promise; + // (undocumented) + debug: boolean; + // (undocumented) + document: any; + // (undocumented) + enter(value: any): Promise; + // (undocumented) + escape(): Promise; + // (undocumented) + get focused(): any; + // (undocumented) + static fromReadableInput(input: any): any; + // (undocumented) + _log(message: any, ...args: any[]): void; + // (undocumented) + _pretty(element: any): string; + // (undocumented) + send(chars: any): Promise; + // (undocumented) + _sendKey(key: any, charCode: any, action: any): Promise; + // (undocumented) + tab(): Promise; + // (undocumented) + static toReadableInput(chars: any): any; + // (undocumented) + toString(): string; + // (undocumented) + static type(target: any, input: any): Promise; + // (undocumented) + type(input: any): Promise; + // (undocumented) + static typeDebug(target: any, input: any): Promise; +} + +// Warning: (ae-missing-release-tag) "LogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LogCollector = AsyncLogCollector | SyncLogCollector; + +// Warning: (ae-missing-release-tag) "LogFuncs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LogFuncs = 'log' | 'warn' | 'error'; + +// Warning: (ae-missing-release-tag) "renderWithEffects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function renderWithEffects(nodes: ReactElement): Promise; + +// Warning: (ae-missing-release-tag) "SyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SyncLogCollector = () => void; + +// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function withLogCollector(callback: AsyncLogCollector): Promise>; + +// @public (undocumented) +export function withLogCollector(callback: SyncLogCollector): CollectedLogs; + +// @public (undocumented) +export function withLogCollector(logsToCollect: T[], callback: AsyncLogCollector): Promise>; + +// @public (undocumented) +export function withLogCollector(logsToCollect: T[], callback: SyncLogCollector): CollectedLogs; + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md new file mode 100644 index 0000000000..9e72a070f7 --- /dev/null +++ b/packages/test-utils/api-report.md @@ -0,0 +1,109 @@ +## API Report File for "@backstage/test-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentType } from 'react'; +import { ErrorApi } from '@backstage/core-api'; +import { ErrorContext } from '@backstage/core-api'; +import { ExternalRouteRef } from '@backstage/core-api'; +import { Observable } from '@backstage/core-api'; +import { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { RenderResult } from '@testing-library/react'; +import { RouteRef } from '@backstage/core-api'; +import { StorageApi } from '@backstage/core-api'; +import { StorageValueChange } from '@backstage/core-api'; + +// Warning: (ae-forgotten-export) The symbol "Breakpoint" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "mockBreakpoint" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function mockBreakpoint(initialBreakpoint?: Breakpoint): { + set(breakpoint: Breakpoint): void; + remove(): void; +}; + +// Warning: (ae-missing-release-tag) "MockErrorApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class MockErrorApi implements ErrorApi { + // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts + constructor(options?: Options); + // (undocumented) + error$(): Observable<{ + error: Error; + context?: ErrorContext; + }>; + // Warning: (ae-forgotten-export) The symbol "ErrorWithContext" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getErrors(): ErrorWithContext[]; + // (undocumented) + post(error: Error, context?: ErrorContext): void; + // (undocumented) + waitForError(pattern: RegExp, timeoutMs?: number): Promise; +} + +// Warning: (ae-missing-release-tag) "MockStorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class MockStorageApi implements StorageApi { + // (undocumented) + static create(data?: MockStorageBucket): MockStorageApi; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + get(key: string): T | undefined; + // (undocumented) + observe$(key: string): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + } + +// Warning: (ae-missing-release-tag) "MockStorageBucket" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MockStorageBucket = { + [key: string]: any; +}; + +// Warning: (ae-missing-release-tag) "msw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const msw: { + setupDefaultHandlers: (worker: { + listen: (t: any) => void; + close: () => void; + resetHandlers: () => void; + }) => void; +}; + +// Warning: (ae-forgotten-export) The symbol "TestAppOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "renderInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function renderInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): Promise; + +// Warning: (ae-missing-release-tag) "wrapInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function wrapInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): ReactElement; + + +export * from "@backstage/test-utils-core"; + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md new file mode 100644 index 0000000000..87656beb2c --- /dev/null +++ b/packages/theme/api-report.md @@ -0,0 +1,130 @@ +## API Report File for "@backstage/theme" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Overrides } from '@material-ui/core/styles/overrides'; +import { Palette } from '@material-ui/core/styles/createPalette'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; +import { Theme } from '@material-ui/core'; +import { ThemeOptions } from '@material-ui/core'; + +// Warning: (ae-forgotten-export) The symbol "PaletteAdditions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "BackstagePalette" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BackstagePalette = Palette & PaletteAdditions; + +// Warning: (ae-missing-release-tag) "BackstagePaletteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BackstagePaletteOptions = PaletteOptions & PaletteAdditions; + +// Warning: (ae-missing-release-tag) "BackstageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface BackstageTheme extends Theme { + // (undocumented) + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; + // (undocumented) + page: PageTheme; + // (undocumented) + palette: BackstagePalette; +} + +// Warning: (ae-missing-release-tag) "BackstageThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface BackstageThemeOptions extends ThemeOptions { + // (undocumented) + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; + // (undocumented) + page: PageTheme; + // (undocumented) + palette: BackstagePaletteOptions; +} + +// Warning: (ae-missing-release-tag) "colorVariants" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const colorVariants: Record; + +// Warning: (ae-missing-release-tag) "createTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createTheme(options: SimpleThemeOptions): BackstageTheme; + +// Warning: (ae-missing-release-tag) "createThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createThemeOptions(options: SimpleThemeOptions): BackstageThemeOptions; + +// Warning: (ae-missing-release-tag) "createThemeOverrides" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createThemeOverrides(theme: BackstageTheme): Overrides; + +// Warning: (ae-missing-release-tag) "darkTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const darkTheme: BackstageTheme; + +// Warning: (ae-missing-release-tag) "genPageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function genPageTheme(colors: string[], shape: string): PageTheme; + +// Warning: (ae-missing-release-tag) "lightTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const lightTheme: BackstageTheme; + +// Warning: (ae-missing-release-tag) "PageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PageTheme = { + colors: string[]; + shape: string; + backgroundImage: string; +}; + +// Warning: (ae-missing-release-tag) "pageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const pageTheme: Record; + +// Warning: (ae-missing-release-tag) "PageThemeSelector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PageThemeSelector = { + themeId: string; +}; + +// Warning: (ae-missing-release-tag) "shapes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const shapes: Record; + +// Warning: (ae-missing-release-tag) "SimpleThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type SimpleThemeOptions = { + palette: BackstagePaletteOptions; + defaultPageTheme: string; + pageTheme?: Record; + fontFamily?: string; +}; + + +// Warnings were encountered during analysis: +// +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. +// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. + +// (No @packageDocumentation comment for this package) + +``` From d3c54f9663a890bc9cadad2db7d071e2d8c461ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 19:23:00 +0200 Subject: [PATCH 100/176] scripts/api-extractor: log instructions after run is complete to avoid race Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index e232170e9d..6eca3b0de3 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -166,6 +166,8 @@ async function runApiExtraction({ // Message verbosity can't be configured, so just skip the check instead (Extractor as any)._checkCompilerCompatibility = () => {}; + let shouldLogInstructions = false; + // Invoke API Extractor const extractorResult = Extractor.invoke(extractorConfig, { localBuild: isLocalBuild, @@ -177,26 +179,29 @@ async function runApiExtraction({ 'You have changed the public API signature for this project.', ) ) { - console.log(''); - console.log( - '*************************************************************************************', - ); - console.log( - '* You have uncommitted changes to the public API of a package. *', - ); - console.log( - '* To solve this, run `yarn build:api-reports` and commit all api-report.md changes. *', - ); - console.log( - '*************************************************************************************', - ); - console.log(''); + shouldLogInstructions = true; } }, compilerState, }); if (!extractorResult.succeeded) { + if (shouldLogInstructions) { + console.log(''); + console.log( + '*************************************************************************************', + ); + console.log( + '* You have uncommitted changes to the public API of a package. *', + ); + console.log( + '* To solve this, run `yarn build:api-reports` and commit all api-report.md changes. *', + ); + console.log( + '*************************************************************************************', + ); + console.log(''); + } throw new Error( `API Extractor completed with ${extractorResult.errorCount} errors` + ` and ${extractorResult.warningCount} warnings`, From 0eda63fe9da28397288d7d52be4d8dfd0e98591b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 20:12:52 +0200 Subject: [PATCH 101/176] scripts/api-extractor: disable inline compilation warnings Signed-off-by: Patrik Oldsberg --- packages/backend-common/api-report.md | 5 --- packages/catalog-client/api-report.md | 5 --- packages/catalog-model/api-report.md | 5 --- packages/cli-common/api-report.md | 24 ++++++------- packages/config-loader/api-report.md | 5 --- packages/config/api-report.md | 8 ----- packages/dev-utils/api-report.md | 8 ----- packages/errors/api-report.md | 8 ----- packages/integration-react/api-report.md | 13 +++---- packages/integration/api-report.md | 5 --- packages/search-common/api-report.md | 43 +++++++++++------------- packages/techdocs-common/api-report.md | 5 --- packages/test-utils-core/api-report.md | 8 ----- packages/test-utils/api-report.md | 8 ----- packages/theme/api-report.md | 8 ----- scripts/api-extractor.ts | 3 +- 16 files changed, 36 insertions(+), 125 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index bdef061eda..79a7ca1a5c 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -458,11 +458,6 @@ export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; // src/service/types.ts:78:3 - (ae-forgotten-export) The symbol "HttpsSettings" needs to be exported by the entry point index.d.ts // src/service/types.ts:83:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/service/types.ts:84:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index fe12d3a62c..467cf752af 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -94,11 +94,6 @@ export type CatalogListResponse = { // Warnings were encountered during analysis: // // src/CatalogClient.ts:40:26 - (ae-forgotten-export) The symbol "DiscoveryApi" needs to be exported by the entry point index.d.ts -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index b3e3303589..7aa98e52c7 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -800,11 +800,6 @@ export const VIEW_URL_ANNOTATION = "backstage.io/view-url"; // src/EntityPolicies.ts:57:3 - (ae-forgotten-export) The symbol "AnyEntityPolicy" needs to be exported by the entry point index.d.ts // src/entity/policies/types.ts:27:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/kinds/types.ts:26:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index 81d494e4ef..f4df3562b8 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts + // Warning: (ae-missing-release-tag) "findPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -12,24 +13,21 @@ export function findPaths(searchDir: string): Paths; // // @public (undocumented) export type Paths = { - ownDir: string; - ownRoot: string; - targetDir: string; - targetRoot: string; - resolveOwn: ResolveFunc; - resolveOwnRoot: ResolveFunc; - resolveTarget: ResolveFunc; - resolveTargetRoot: ResolveFunc; + ownDir: string; + ownRoot: string; + targetDir: string; + targetRoot: string; + resolveOwn: ResolveFunc; + resolveOwnRoot: ResolveFunc; + resolveTarget: ResolveFunc; + resolveTargetRoot: ResolveFunc; }; + // Warnings were encountered during analysis: // // src/paths.ts:38:3 - (ae-forgotten-export) The symbol "ResolveFunc" needs to be exported by the entry point index.d.ts -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) + ``` diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 0c011aea65..ffa599df2b 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -61,11 +61,6 @@ export function readEnvConfig(env: { // src/lib/schema/types.ts:105:3 - (ae-forgotten-export) The symbol "ConfigProcessingOptions" needs to be exported by the entry point index.d.ts // src/loader.ts:42:6 - (tsdoc-unsupported-tag) The TSDoc tag "@experimental" is not supported by this tool // src/loader.ts:44:3 - (ae-forgotten-export) The symbol "EnvFunc" needs to be exported by the entry point index.d.ts -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) diff --git a/packages/config/api-report.md b/packages/config/api-report.md index 8ff881a34b..4e0da777ec 100644 --- a/packages/config/api-report.md +++ b/packages/config/api-report.md @@ -99,14 +99,6 @@ export type JsonPrimitive = number | string | boolean | null; export type JsonValue = JsonObject | JsonArray | JsonPrimitive; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md index 41c793e189..841f9c19b2 100644 --- a/packages/dev-utils/api-report.md +++ b/packages/dev-utils/api-report.md @@ -26,14 +26,6 @@ export const EntityGridItem: ({ entity, classes, ...rest }: Omit JSX.Element; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 2a4b482463..18861fdc84 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -112,14 +112,6 @@ export function serializeError(error: Error, options?: { }): SerializedError; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md index aa12687415..8dcc02a443 100644 --- a/packages/integration-react/api-report.md +++ b/packages/integration-react/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts + import { ApiRef } from '@backstage/core'; import { Config } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; @@ -11,8 +12,8 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; // // @public (undocumented) export class ScmIntegrationsApi { - // (undocumented) - static fromConfig(config: Config): ScmIntegrationRegistry; + // (undocumented) + static fromConfig(config: Config): ScmIntegrationRegistry; } // Warning: (ae-missing-release-tag) "scmIntegrationsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -20,13 +21,7 @@ export class ScmIntegrationsApi { // @public (undocumented) export const scmIntegrationsApiRef: ApiRef; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) + ``` diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 8782abaf1a..6d85bf4a51 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -400,11 +400,6 @@ export interface ScmIntegrationsGroup { // Warnings were encountered during analysis: // -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // src/github/config.ts:67:3 - (ae-forgotten-export) The symbol "GithubAppConfig" needs to be exported by the entry point index.d.ts // src/gitlab/config.ts:51:66 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // src/gitlab/config.ts:51:61 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md index 3911059177..ed53275017 100644 --- a/packages/search-common/api-report.md +++ b/packages/search-common/api-report.md @@ -3,68 +3,63 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts + import { JsonObject } from '@backstage/config'; // Warning: (ae-missing-release-tag) "DocumentCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export interface DocumentCollator { - // (undocumented) - execute(): Promise; + // (undocumented) + execute(): Promise; } // Warning: (ae-missing-release-tag) "DocumentDecorator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export interface DocumentDecorator { - // (undocumented) - execute(documents: IndexableDocument[]): Promise; + // (undocumented) + execute(documents: IndexableDocument[]): Promise; } // Warning: (ae-missing-release-tag) "IndexableDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export interface IndexableDocument { - location: string; - text: string; - title: string; + location: string; + text: string; + title: string; } // Warning: (ae-missing-release-tag) "SearchQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SearchQuery { - // (undocumented) - filters?: JsonObject; - // (undocumented) - pageCursor: string; - // (undocumented) - term: string; + // (undocumented) + filters?: JsonObject; + // (undocumented) + pageCursor: string; + // (undocumented) + term: string; } // Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SearchResult { - // (undocumented) - document: IndexableDocument; + // (undocumented) + document: IndexableDocument; } // Warning: (ae-missing-release-tag) "SearchResultSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface SearchResultSet { - // (undocumented) - results: SearchResult[]; + // (undocumented) + results: SearchResult[]; } -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // (No @packageDocumentation comment for this package) + ``` diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index df1bea443c..aa6c9da2f8 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -242,11 +242,6 @@ export class UrlPreparer implements PreparerBase { // Warnings were encountered during analysis: // -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. // src/stages/generate/types.ts:42:3 - (ae-forgotten-export) The symbol "GeneratorRunOptions" needs to be exported by the entry point index.d.ts // src/stages/generate/types.ts:54:3 - (ae-forgotten-export) The symbol "SupportedGeneratorKey" needs to be exported by the entry point index.d.ts // src/stages/prepare/types.ts:35:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md index a462be7aca..bda058ad23 100644 --- a/packages/test-utils-core/api-report.md +++ b/packages/test-utils-core/api-report.md @@ -100,14 +100,6 @@ export function withLogCollector(logsToCollect: T[], callbac export function withLogCollector(logsToCollect: T[], callback: SyncLogCollector): CollectedLogs; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 9e72a070f7..f62b1e6dec 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -96,14 +96,6 @@ export function wrapInTestApp(Component: ComponentType | ReactNode, options?: Te export * from "@backstage/test-utils-core"; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 87656beb2c..adfbcee346 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -117,14 +117,6 @@ export type SimpleThemeOptions = { }; -// Warnings were encountered during analysis: -// -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:18:31 - (TS2307) Cannot find module './assets/missingAnnotation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:19:27 - (TS2307) Cannot find module './assets/noInformation.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:20:29 - (TS2307) Cannot find module './assets/createComponent.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/components/EmptyState/EmptyStateImage.tsx:21:21 - (TS2307) Cannot find module './assets/noBuild.svg' or its corresponding type declarations. -// /Users/patriko/dev/backstage/packages/core/src/layout/ErrorPage/MicDrop.tsx:19:27 - (TS2307) Cannot find module './mic-drop.svg' or its corresponding type declarations. - // (No @packageDocumentation comment for this package) ``` diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 6eca3b0de3..6095b1f15f 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -132,7 +132,8 @@ async function runApiExtraction({ compilerMessageReporting: { default: { logLevel: 'warning' as ExtractorLogLevel.Warning, - addToApiReportFile: true, + // These contain absolute file paths, so can't be included in the report + // addToApiReportFile: true, }, }, extractorMessageReporting: { From 6768b397bd644ae5e9d5ec35e0ee4c82bb64c79a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Apr 2021 22:41:35 +0200 Subject: [PATCH 102/176] scripts/api-extractor: silence compiler warnings Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 6095b1f15f..2391832f3e 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -131,7 +131,8 @@ async function runApiExtraction({ messages: { compilerMessageReporting: { default: { - logLevel: 'warning' as ExtractorLogLevel.Warning, + // Silence compiler warnings, as these will prevent the CI build to work + logLevel: 'none' as ExtractorLogLevel.None, // These contain absolute file paths, so can't be included in the report // addToApiReportFile: true, }, From c04a465330dbc35543b224aa4562265c59a287a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Apr 2021 18:40:04 +0200 Subject: [PATCH 103/176] scripts/api-extractor: figure out the conflicting file in CI and print the contents Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 2391832f3e..4135c52701 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -17,7 +17,11 @@ /* eslint-disable import/no-extraneous-dependencies */ // eslint-disable-next-line no-restricted-imports -import { resolve as resolvePath, join as joinPath, dirname } from 'path'; +import { + resolve as resolvePath, + relative as relativePath, + dirname, +} from 'path'; import fs from 'fs-extra'; import { Extractor, @@ -169,6 +173,7 @@ async function runApiExtraction({ (Extractor as any)._checkCompilerCompatibility = () => {}; let shouldLogInstructions = false; + let conflictingFile: undefined | string = undefined; // Invoke API Extractor const extractorResult = Extractor.invoke(extractorConfig, { @@ -182,6 +187,12 @@ async function runApiExtraction({ ) ) { shouldLogInstructions = true; + const match = message.text.match( + /Please copy the file "(.*)" to "api-report\.md"/, + ); + if (match) { + conflictingFile = match[1]; + } } }, compilerState, @@ -203,7 +214,23 @@ async function runApiExtraction({ '*************************************************************************************', ); console.log(''); + + if (conflictingFile) { + console.log(''); + console.log( + `The conflicting file is ${relativePath( + tmpDir, + conflictingFile, + )}, with the following content:`, + ); + console.log(''); + + const content = await fs.readFile(conflictingFile, 'utf8'); + console.log(content); + console.log(''); + } } + throw new Error( `API Extractor completed with ${extractorResult.errorCount} errors` + ` and ${extractorResult.warningCount} warnings`, From 658de01647c9fbd3e206ccc3d6c9673b8c9e90ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Apr 2021 20:00:37 +0200 Subject: [PATCH 104/176] scripts/api-extractor: disable all inline warnings Signed-off-by: Patrik Oldsberg --- packages/backend-common/api-report.md | 123 ----------- packages/catalog-client/api-report.md | 22 +- packages/catalog-model/api-report.md | 251 +---------------------- packages/cli-common/api-report.md | 8 - packages/config-loader/api-report.md | 22 -- packages/config/api-report.md | 14 -- packages/dev-utils/api-report.md | 5 - packages/errors/api-report.md | 29 --- packages/integration-react/api-report.md | 4 - packages/integration/api-report.md | 148 +------------ packages/search-common/api-report.md | 12 -- packages/techdocs-common/api-report.md | 77 ------- packages/test-utils-core/api-report.md | 19 -- packages/test-utils/api-report.md | 19 -- packages/theme/api-report.md | 33 --- scripts/api-extractor.ts | 10 +- 16 files changed, 24 insertions(+), 772 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 79a7ca1a5c..58b9aa36d9 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -29,33 +29,23 @@ import { Server } from 'http'; import * as winston from 'winston'; import { Writable } from 'stream'; -// Warning: (ae-missing-release-tag) "AzureUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class AzureUrlReader implements UrlReader { constructor(integration: AzureIntegration, deps: { treeResponseFactory: ReadTreeResponseFactory; }); - // Warning: (ae-forgotten-export) The symbol "ReaderFactory" needs to be exported by the entry point index.d.ts - // // (undocumented) static factory: ReaderFactory; // (undocumented) read(url: string): Promise; - // Warning: (ae-forgotten-export) The symbol "ReadTreeOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) readTree(url: string, options?: ReadTreeOptions): Promise; - // Warning: (ae-forgotten-export) The symbol "SearchOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) search(url: string, options?: SearchOptions): Promise; // (undocumented) toString(): string; } -// Warning: (ae-missing-release-tag) "BitbucketUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class BitbucketUrlReader implements UrlReader { constructor(integration: BitbucketIntegration, deps: { @@ -73,52 +63,30 @@ export class BitbucketUrlReader implements UrlReader { toString(): string; } -// Warning: (ae-missing-release-tag) "coloredFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const coloredFormat: Format; -// Warning: (ae-missing-release-tag) "createDatabase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public @deprecated export const createDatabase: typeof createDatabaseClient; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "createDatabaseClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function createDatabaseClient(dbConfig: Config, overrides?: Partial): Knex; -// Warning: (ae-missing-release-tag) "createRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function createRootLogger(options?: winston.LoggerOptions, env?: NodeJS.ProcessEnv): winston.Logger; -// Warning: (ae-forgotten-export) The symbol "ServiceBuilderImpl" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createServiceBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl; -// Warning: (ae-forgotten-export) The symbol "StatusCheckRouterOptions" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createStatusCheckRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise; -// Warning: (ae-missing-release-tag) "ensureDatabaseExists" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function ensureDatabaseExists(dbConfig: Config, ...databases: Array): Promise; -// Warning: (ae-missing-release-tag) "errorHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler; -// Warning: (ae-missing-release-tag) "ErrorHandlerOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type ErrorHandlerOptions = { showStackTraces?: boolean; @@ -126,18 +94,12 @@ export type ErrorHandlerOptions = { logClientErrors?: boolean; }; -// Warning: (ae-missing-release-tag) "getRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function getRootLogger(): winston.Logger; -// Warning: (ae-missing-release-tag) "getVoidLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getVoidLogger(): winston.Logger; -// Warning: (ae-missing-release-tag) "Git" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class Git { // (undocumented) @@ -221,8 +183,6 @@ export class Git { }): Promise; } -// Warning: (ae-missing-release-tag) "GithubUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class GithubUrlReader implements UrlReader { constructor(integration: GitHubIntegration, deps: { @@ -241,8 +201,6 @@ export class GithubUrlReader implements UrlReader { toString(): string; } -// Warning: (ae-missing-release-tag) "GitlabUrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class GitlabUrlReader implements UrlReader { constructor(integration: GitLabIntegration, deps: { @@ -260,34 +218,23 @@ export class GitlabUrlReader implements UrlReader { toString(): string; } -// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "loadBackendConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function loadBackendConfig(options: Options): Promise; -// Warning: (ae-missing-release-tag) "notFoundHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function notFoundHandler(): RequestHandler; -// Warning: (ae-missing-release-tag) "PluginDatabaseManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface PluginDatabaseManager { getClient(): Promise; } -// Warning: (ae-missing-release-tag) "PluginEndpointDiscovery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type PluginEndpointDiscovery = { getBaseUrl(pluginId: string): Promise; getExternalBaseUrl(pluginId: string): Promise; }; -// Warning: (ae-missing-release-tag) "ReadTreeResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type ReadTreeResponse = { files(): Promise; @@ -296,62 +243,36 @@ export type ReadTreeResponse = { etag: string; }; -// Warning: (ae-missing-release-tag) "ReadTreeResponseFile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ReadTreeResponseFile = { path: string; content(): Promise; }; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "requestLoggingHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function requestLoggingHandler(logger?: Logger): RequestHandler; -// Warning: (ae-missing-release-tag) "resolvePackagePath" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function resolvePackagePath(name: string, ...paths: string[]): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (ae-forgotten-export) The symbol "RunDockerContainerOptions" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "runDockerContainer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const runDockerContainer: ({ imageName, args, logStream, dockerClient, mountDirs, workingDir, envVars, createOptions, }: RunDockerContainerOptions) => Promise<{ error: any; statusCode: any; }>; -// Warning: (ae-missing-release-tag) "SearchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type SearchResponse = { files: SearchResponseFile[]; etag: string; }; -// Warning: (ae-missing-release-tag) "SearchResponseFile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type SearchResponseFile = { url: string; content(): Promise; }; -// Warning: (ae-missing-release-tag) "ServiceBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type ServiceBuilder = { loadConfig(config: ConfigReader): ServiceBuilder; @@ -364,23 +285,15 @@ export type ServiceBuilder = { start(): Promise; }; -// Warning: (ae-missing-release-tag) "setRootLogger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function setRootLogger(newLogger: winston.Logger): void; -// Warning: (ae-missing-release-tag) "SingleConnectionDatabaseManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class SingleConnectionDatabaseManager { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen forPlugin(pluginId: string): PluginDatabaseManager; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen static fromConfig(config: Config): SingleConnectionDatabaseManager; } -// Warning: (ae-missing-release-tag) "SingleHostDiscovery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class SingleHostDiscovery implements PluginEndpointDiscovery { static fromConfig(config: Config, options?: { @@ -392,26 +305,17 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery { getExternalBaseUrl(pluginId: string): Promise; } -// Warning: (ae-missing-release-tag) "StatusCheck" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type StatusCheck = () => Promise; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "statusCheckHandler" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function statusCheckHandler(options?: StatusCheckHandlerOptions): Promise; -// Warning: (ae-missing-release-tag) "StatusCheckHandlerOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface StatusCheckHandlerOptions { statusCheck?: StatusCheck; } -// Warning: (ae-missing-release-tag) "UrlReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type UrlReader = { read(url: string): Promise; @@ -419,46 +323,19 @@ export type UrlReader = { search(url: string, options?: SearchOptions): Promise; }; -// Warning: (ae-missing-release-tag) "UrlReaders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class UrlReaders { - // Warning: (ae-forgotten-export) The symbol "CreateOptions" needs to be exported by the entry point index.d.ts static create({ logger, config, factories }: CreateOptions): UrlReader; static default({ logger, config, factories }: CreateOptions): UrlReader; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "useHotCleanup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function useHotCleanup(_module: NodeModule, cancelEffect: () => void): void; -// Warning: (tsdoc-undefined-tag) The TSDoc tag "@warning" is not defined in this configuration -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "useHotMemoize" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function useHotMemoize(_module: NodeModule, valueFactory: () => T): T; -// Warnings were encountered during analysis: -// -// src/middleware/errorHandler.ts:47:24 - (tsdoc-malformed-html-name) Invalid HTML element: A space is not allowed here -// src/reading/AzureUrlReader.ts:53:30 - (ae-forgotten-export) The symbol "ReadTreeResponseFactory" needs to be exported by the entry point index.d.ts -// src/reading/types.ts:87:3 - (ae-forgotten-export) The symbol "ReadTreeResponseDirOptions" needs to be exported by the entry point index.d.ts -// src/service/types.ts:28:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:39:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:48:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:57:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:67:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:76:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:78:3 - (ae-forgotten-export) The symbol "HttpsSettings" needs to be exported by the entry point index.d.ts -// src/service/types.ts:83:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/service/types.ts:84:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 467cf752af..a17a6cdc10 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -8,8 +8,6 @@ import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; import { Location as Location_2 } from '@backstage/catalog-model'; -// Warning: (ae-missing-release-tag) "AddLocationRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type AddLocationRequest = { type?: string; @@ -18,22 +16,16 @@ export type AddLocationRequest = { presence?: 'optional' | 'required'; }; -// Warning: (ae-missing-release-tag) "AddLocationResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type AddLocationResponse = { location: Location_2; entities: Entity[]; }; -// Warning: (ae-missing-release-tag) "CatalogApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface CatalogApi { // (undocumented) addLocation(location: AddLocationRequest, options?: CatalogRequestOptions): Promise; - // Warning: (ae-forgotten-export) The symbol "CatalogRequestOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) getEntities(request?: CatalogEntitiesRequest, options?: CatalogRequestOptions): Promise>; // (undocumented) @@ -41,7 +33,7 @@ export interface CatalogApi { // (undocumented) getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; // (undocumented) - getLocationById(id: String, options?: CatalogRequestOptions): Promise; + getLocationById(id: string, options?: CatalogRequestOptions): Promise; // (undocumented) getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; // (undocumented) @@ -50,8 +42,6 @@ export interface CatalogApi { removeLocationById(id: string, options?: CatalogRequestOptions): Promise; } -// Warning: (ae-missing-release-tag) "CatalogClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class CatalogClient implements CatalogApi { constructor(options: { @@ -66,7 +56,7 @@ export class CatalogClient implements CatalogApi { // (undocumented) getLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; // (undocumented) - getLocationById(id: String, options?: CatalogRequestOptions): Promise; + getLocationById(id: string, options?: CatalogRequestOptions): Promise; // (undocumented) getOriginLocationByEntity(entity: Entity, options?: CatalogRequestOptions): Promise; // (undocumented) @@ -75,26 +65,18 @@ export class CatalogClient implements CatalogApi { removeLocationById(id: string, options?: CatalogRequestOptions): Promise; } -// Warning: (ae-missing-release-tag) "CatalogEntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type CatalogEntitiesRequest = { filter?: Record | undefined; fields?: string[] | undefined; }; -// Warning: (ae-missing-release-tag) "CatalogListResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type CatalogListResponse = { items: T[]; }; -// Warnings were encountered during analysis: -// -// src/CatalogClient.ts:40:26 - (ae-forgotten-export) The symbol "DiscoveryApi" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 7aa98e52c7..c37c86b01f 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -9,23 +9,15 @@ import { JSONSchema7 } from 'json-schema'; import { JsonValue } from '@backstage/config'; import * as yup from 'yup'; -// Warning: (ae-missing-release-tag) "analyzeLocationSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const analyzeLocationSchema: yup.ObjectSchema<{ location: LocationSpec; }, object>; -// Warning: (ae-missing-release-tag) "ApiEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface ApiEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND; // (undocumented) @@ -42,53 +34,26 @@ export { ApiEntityV1alpha1 as ApiEntity } export { ApiEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "apiEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const apiEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "CommonValidatorFunctions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class CommonValidatorFunctions { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen static isJsonSafe(value: unknown): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool static isValidDnsLabel(value: unknown): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool static isValidDnsSubdomain(value: unknown): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen static isValidPrefixAndOrSuffix(value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen static isValidString(value: unknown): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen static isValidUrl(value: unknown): boolean; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-forgotten-export) The symbol "EntityRefContext" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "compareEntityToRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName, context?: EntityRefContext): boolean; -// Warning: (ae-missing-release-tag) "ComponentEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface ComponentEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_2[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_2; // (undocumented) @@ -99,6 +64,7 @@ interface ComponentEntityV1alpha1 extends Entity { subcomponentOf?: string; providesApis?: string[]; consumesApis?: string[]; + dependsOn?: string[]; system?: string; }; } @@ -107,13 +73,9 @@ export { ComponentEntityV1alpha1 as ComponentEntity } export { ComponentEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "componentEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const componentEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "DefaultNamespaceEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class DefaultNamespaceEntityPolicy implements EntityPolicy { constructor(namespace?: string); @@ -121,16 +83,10 @@ export class DefaultNamespaceEntityPolicy implements EntityPolicy { enforce(entity: Entity): Promise; } -// Warning: (ae-missing-release-tag) "DomainEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface DomainEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_3[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_3; // (undocumented) @@ -143,19 +99,12 @@ export { DomainEntityV1alpha1 as DomainEntity } export { DomainEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "domainEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const domainEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "EDIT_URL_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EDIT_URL_ANNOTATION = "backstage.io/edit-url"; -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (ae-missing-release-tag) "Entity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type Entity = { apiVersion: string; @@ -165,25 +114,15 @@ export type Entity = { relations?: EntityRelation[]; }; -// Warning: (ae-missing-release-tag) "ENTITY_DEFAULT_NAMESPACE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const ENTITY_DEFAULT_NAMESPACE = "default"; -// Warning: (ae-missing-release-tag) "ENTITY_META_GENERATED_FIELDS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"]; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "entityHasChanges" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function entityHasChanges(previous: Entity, next: Entity): boolean; -// Warning: (ae-missing-release-tag) "EntityLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityLink = { url: string; @@ -191,10 +130,6 @@ export type EntityLink = { icon?: string; }; -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (ae-missing-release-tag) "EntityMeta" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityMeta = JsonObject & { uid?: string; @@ -209,8 +144,6 @@ export type EntityMeta = JsonObject & { links?: EntityLink[]; }; -// Warning: (ae-missing-release-tag) "EntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityName = { kind: string; @@ -218,23 +151,17 @@ export type EntityName = { name: string; }; -// Warning: (ae-missing-release-tag) "EntityPolicies" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityPolicies: { allOf(policies: EntityPolicy[]): AllEntityPolicies; oneOf(policies: EntityPolicy[]): AnyEntityPolicy; }; -// Warning: (ae-missing-release-tag) "EntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityPolicy = { enforce(entity: Entity): Promise; }; -// Warning: (ae-missing-release-tag) "EntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityRef = string | { kind?: string; @@ -242,16 +169,12 @@ export type EntityRef = string | { name: string; }; -// Warning: (ae-missing-release-tag) "EntityRelation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityRelation = { type: string; target: EntityName; }; -// Warning: (ae-missing-release-tag) "EntityRelationSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityRelationSpec = { source: EntityName; @@ -259,8 +182,6 @@ export type EntityRelationSpec = { target: EntityName; }; -// Warning: (ae-missing-release-tag) "FieldFormatEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class FieldFormatEntityPolicy implements EntityPolicy { constructor(validators?: Validators); @@ -268,39 +189,28 @@ export class FieldFormatEntityPolicy implements EntityPolicy { enforce(entity: Entity): Promise; } -// Warning: (ae-missing-release-tag) "generateEntityEtag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function generateEntityEtag(): string; -// Warning: (ae-missing-release-tag) "generateEntityUid" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function generateEntityUid(): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "generateUpdatedEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function generateUpdatedEntity(previous: Entity, next: Entity): Entity; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getEntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getEntityName(entity: Entity): EntityName; -// Warning: (ae-missing-release-tag) "GroupEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export function getEntitySourceLocation(entity: Entity): { + type: string; + target: string; +}; + // @public (undocumented) interface GroupEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_4[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_4; // (undocumented) @@ -321,30 +231,19 @@ export { GroupEntityV1alpha1 as GroupEntity } export { GroupEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "groupEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const groupEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "JSONSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue; }; -// Warning: (ae-missing-release-tag) "KindValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type KindValidator = { check(entity: Entity): Promise; }; -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (tsdoc-unsupported-tag) The TSDoc tag "@see" is not supported by this tool -// Warning: (ae-missing-release-tag) "KubernetesValidatorFunctions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class KubernetesValidatorFunctions { // (undocumented) @@ -365,8 +264,6 @@ export class KubernetesValidatorFunctions { static isValidObjectName(value: unknown): boolean; } -// Warning: (ae-missing-release-tag) "Location" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) type Location_2 = { id: string; @@ -374,21 +271,13 @@ type Location_2 = { export { Location_2 as Location } -// Warning: (ae-missing-release-tag) "LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const LOCATION_ANNOTATION = "backstage.io/managed-by-location"; -// Warning: (ae-missing-release-tag) "LocationEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface LocationEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_5[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_5; // (undocumented) @@ -403,18 +292,12 @@ export { LocationEntityV1alpha1 as LocationEntity } export { LocationEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "locationEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const locationEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "locationSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const locationSchema: yup.ObjectSchema; -// Warning: (ae-missing-release-tag) "LocationSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type LocationSpec = { type: string; @@ -422,18 +305,12 @@ export type LocationSpec = { presence?: 'optional' | 'required'; }; -// Warning: (ae-missing-release-tag) "locationSpecSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const locationSpecSchema: yup.ObjectSchema; -// Warning: (ae-missing-release-tag) "makeValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function makeValidator(overrides?: Partial): Validators; -// Warning: (ae-missing-release-tag) "NoForeignRootFieldsEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { constructor(knownFields?: string[]); @@ -441,24 +318,12 @@ export class NoForeignRootFieldsEntityPolicy implements EntityPolicy { enforce(entity: Entity): Promise; } -// Warning: (ae-missing-release-tag) "ORIGIN_LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const ORIGIN_LOCATION_ANNOTATION = "backstage.io/managed-by-origin-location"; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "parseEntityName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function parseEntityName(ref: EntityRef, context?: EntityRefContext): EntityName; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "parseEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function parseEntityRef(ref: EntityRef, context?: { defaultKind: string; @@ -487,103 +352,65 @@ export function parseEntityRef(ref: EntityRef, context?: { name: string; }; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (ae-missing-release-tag) "parseLocationReference" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function parseLocationReference(ref: string): { type: string; target: string; }; -// Warning: (ae-missing-release-tag) "RELATION_API_CONSUMED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_API_CONSUMED_BY = "apiConsumedBy"; -// Warning: (ae-missing-release-tag) "RELATION_API_PROVIDED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_API_PROVIDED_BY = "apiProvidedBy"; -// Warning: (ae-missing-release-tag) "RELATION_CHILD_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_CHILD_OF = "childOf"; -// Warning: (ae-missing-release-tag) "RELATION_CONSUMES_API" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_CONSUMES_API = "consumesApi"; -// Warning: (ae-missing-release-tag) "RELATION_DEPENDENCY_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_DEPENDENCY_OF = "dependencyOf"; -// Warning: (ae-missing-release-tag) "RELATION_DEPENDS_ON" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_DEPENDS_ON = "dependsOn"; -// Warning: (ae-missing-release-tag) "RELATION_HAS_MEMBER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_HAS_MEMBER = "hasMember"; -// Warning: (ae-missing-release-tag) "RELATION_HAS_PART" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_HAS_PART = "hasPart"; -// Warning: (ae-missing-release-tag) "RELATION_MEMBER_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_MEMBER_OF = "memberOf"; -// Warning: (ae-missing-release-tag) "RELATION_OWNED_BY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_OWNED_BY = "ownedBy"; -// Warning: (ae-missing-release-tag) "RELATION_OWNER_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_OWNER_OF = "ownerOf"; -// Warning: (ae-missing-release-tag) "RELATION_PARENT_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_PARENT_OF = "parentOf"; -// Warning: (ae-missing-release-tag) "RELATION_PART_OF" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const RELATION_PART_OF = "partOf"; -// Warning: (ae-missing-release-tag) "RELATION_PROVIDES_API" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const RELATION_PROVIDES_API = "providesApi"; -// Warning: (ae-missing-release-tag) "ResourceEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface ResourceEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_6[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_6; // (undocumented) spec: { type: string; owner: string; + dependsOn?: string[]; system?: string; }; } @@ -592,27 +419,18 @@ export { ResourceEntityV1alpha1 as ResourceEntity } export { ResourceEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "resourceEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const resourceEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "schemaValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public @deprecated (undocumented) export function schemaValidator(kind: string, apiVersion: readonly string[], schema: yup.Schema): KindValidator; -// Warning: (ae-missing-release-tag) "SchemaValidEntityPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class SchemaValidEntityPolicy implements EntityPolicy { // (undocumented) enforce(entity: Entity): Promise; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "serializeEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public @deprecated export function serializeEntityRef(ref: Entity | { kind?: string; @@ -620,14 +438,9 @@ export function serializeEntityRef(ref: Entity | { name: string; }): EntityRef; -// Warning: (ae-missing-release-tag) "SOURCE_LOCATION_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const SOURCE_LOCATION_ANNOTATION = "backstage.io/source-location"; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "stringifyEntityRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function stringifyEntityRef(ref: Entity | { kind: string; @@ -635,27 +448,16 @@ export function stringifyEntityRef(ref: Entity | { name: string; }): string; -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "stringifyLocationReference" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function stringifyLocationReference(ref: { type: string; target: string; }): string; -// Warning: (ae-missing-release-tag) "SystemEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface SystemEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_7[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_7; // (undocumented) @@ -669,21 +471,13 @@ export { SystemEntityV1alpha1 as SystemEntity } export { SystemEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "systemEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const systemEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "TemplateEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface TemplateEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_8[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_8; // (undocumented) @@ -699,21 +493,13 @@ export { TemplateEntityV1alpha1 as TemplateEntity } export { TemplateEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "templateEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const templateEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "TemplateEntityV1beta2" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface TemplateEntityV1beta2 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_9[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_9; // (undocumented) @@ -736,21 +522,13 @@ export interface TemplateEntityV1beta2 extends Entity { }; } -// Warning: (ae-missing-release-tag) "templateEntityV1beta2Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const templateEntityV1beta2Validator: KindValidator; -// Warning: (ae-missing-release-tag) "UserEntityV1alpha1" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) interface UserEntityV1alpha1 extends Entity { - // Warning: (ae-forgotten-export) The symbol "API_VERSION" needs to be exported by the entry point index.d.ts - // // (undocumented) apiVersion: typeof API_VERSION_10[number]; - // Warning: (ae-forgotten-export) The symbol "KIND" needs to be exported by the entry point index.d.ts - // // (undocumented) kind: typeof KIND_10; // (undocumented) @@ -768,13 +546,9 @@ export { UserEntityV1alpha1 as UserEntity } export { UserEntityV1alpha1 } -// Warning: (ae-missing-release-tag) "userEntityV1alpha1Validator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const userEntityV1alpha1Validator: KindValidator; -// Warning: (ae-missing-release-tag) "Validators" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type Validators = { isValidApiVersion(value: unknown): boolean; @@ -788,19 +562,10 @@ export type Validators = { isValidTag(value: unknown): boolean; }; -// Warning: (ae-missing-release-tag) "VIEW_URL_ANNOTATION" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export const VIEW_URL_ANNOTATION = "backstage.io/view-url"; -// Warnings were encountered during analysis: -// -// src/EntityPolicies.ts:55:28 - (ae-forgotten-export) The symbol "AllEntityPolicies" needs to be exported by the entry point index.d.ts -// src/EntityPolicies.ts:57:3 - (ae-forgotten-export) The symbol "AnyEntityPolicy" needs to be exported by the entry point index.d.ts -// src/entity/policies/types.ts:27:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/kinds/types.ts:26:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index f4df3562b8..bcdc023c22 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -4,13 +4,9 @@ ```ts -// Warning: (ae-missing-release-tag) "findPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function findPaths(searchDir: string): Paths; -// Warning: (ae-missing-release-tag) "Paths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type Paths = { ownDir: string; @@ -24,10 +20,6 @@ export type Paths = { }; -// Warnings were encountered during analysis: -// -// src/paths.ts:38:3 - (ae-forgotten-export) The symbol "ResolveFunc" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index ffa599df2b..a09311b786 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -8,27 +8,18 @@ import { AppConfig } from '@backstage/config'; import { JsonObject } from '@backstage/config'; import { JSONSchema7 } from 'json-schema'; -// Warning: (ae-missing-release-tag) "ConfigSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ConfigSchema = { process(appConfigs: AppConfig[], options?: ConfigProcessingOptions): AppConfig[]; serialize(): JsonObject; }; -// Warning: (ae-forgotten-export) The symbol "CONFIG_VISIBILITIES" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "ConfigVisibility" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; -// Warning: (ae-missing-release-tag) "loadConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function loadConfig(options: LoadConfigOptions): Promise; -// Warning: (ae-missing-release-tag) "LoadConfigOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type LoadConfigOptions = { configRoot: string; @@ -37,31 +28,18 @@ export type LoadConfigOptions = { experimentalEnvFunc?: EnvFunc; }; -// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "loadConfigSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function loadConfigSchema(options: Options): Promise; -// Warning: (ae-missing-release-tag) "mergeConfigSchemas" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function mergeConfigSchemas(schemas: JSONSchema7[]): JSONSchema7; -// Warning: (ae-missing-release-tag) "readEnvConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readEnvConfig(env: { [name: string]: string | undefined; }): AppConfig[]; -// Warnings were encountered during analysis: -// -// src/lib/schema/types.ts:105:3 - (ae-forgotten-export) The symbol "ConfigProcessingOptions" needs to be exported by the entry point index.d.ts -// src/loader.ts:42:6 - (tsdoc-unsupported-tag) The TSDoc tag "@experimental" is not supported by this tool -// src/loader.ts:44:3 - (ae-forgotten-export) The symbol "EnvFunc" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/config/api-report.md b/packages/config/api-report.md index 4e0da777ec..be250778ec 100644 --- a/packages/config/api-report.md +++ b/packages/config/api-report.md @@ -4,16 +4,12 @@ ```ts -// Warning: (ae-missing-release-tag) "AppConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type AppConfig = { context: string; data: JsonObject; }; -// Warning: (ae-missing-release-tag) "Config" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type Config = { has(key: string): boolean; @@ -34,8 +30,6 @@ export type Config = { getOptionalStringArray(key: string): string[] | undefined; }; -// Warning: (ae-missing-release-tag) "ConfigReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class ConfigReader implements Config { constructor(data: JsonObject | undefined, context?: string, fallback?: ConfigReader | undefined, prefix?: string); @@ -75,26 +69,18 @@ export class ConfigReader implements Config { keys(): string[]; } -// Warning: (ae-missing-release-tag) "JsonArray" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface JsonArray extends Array { } -// Warning: (ae-missing-release-tag) "JsonObject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type JsonObject = { [key in string]?: JsonValue; }; -// Warning: (ae-missing-release-tag) "JsonPrimitive" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type JsonPrimitive = number | string | boolean | null; -// Warning: (ae-missing-release-tag) "JsonValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type JsonValue = JsonObject | JsonArray | JsonPrimitive; diff --git a/packages/dev-utils/api-report.md b/packages/dev-utils/api-report.md index 841f9c19b2..d3e6447cd5 100644 --- a/packages/dev-utils/api-report.md +++ b/packages/dev-utils/api-report.md @@ -12,14 +12,9 @@ import { GridProps } from '@material-ui/core'; import { IconComponent } from '@backstage/core'; import { ReactNode } from 'react'; -// Warning: (ae-forgotten-export) The symbol "DevAppBuilder" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "createDevApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function createDevApp(): DevAppBuilder; -// Warning: (ae-missing-release-tag) "EntityGridItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityGridItem: ({ entity, classes, ...rest }: Omit, "container" | "item"> & { entity: Entity; diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 18861fdc84..cb24cba80f 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -6,20 +6,14 @@ import { JsonObject } from '@backstage/config'; -// Warning: (ae-missing-release-tag) "AuthenticationError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class AuthenticationError extends CustomErrorBase { } -// Warning: (ae-missing-release-tag) "ConflictError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class ConflictError extends CustomErrorBase { } -// Warning: (ae-missing-release-tag) "CustomErrorBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class CustomErrorBase extends Error { constructor(message?: string, cause?: Error); @@ -27,13 +21,9 @@ export class CustomErrorBase extends Error { readonly cause?: Error; } -// Warning: (ae-missing-release-tag) "deserializeError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function deserializeError(data: SerializedError): T; -// Warning: (ae-missing-release-tag) "ErrorResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type ErrorResponse = { error: SerializedError; @@ -46,38 +36,25 @@ export type ErrorResponse = { }; }; -// Warning: (ae-missing-release-tag) "InputError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class InputError extends CustomErrorBase { } -// Warning: (ae-missing-release-tag) "NotAllowedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class NotAllowedError extends CustomErrorBase { } -// Warning: (ae-missing-release-tag) "NotFoundError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class NotFoundError extends CustomErrorBase { } -// Warning: (ae-missing-release-tag) "NotModifiedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class NotModifiedError extends CustomErrorBase { } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "parseErrorResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function parseErrorResponse(response: Response): Promise; -// Warning: (ae-missing-release-tag) "ResponseError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class ResponseError extends Error { constructor(props: { @@ -92,8 +69,6 @@ export class ResponseError extends Error { readonly response: Response; } -// Warning: (ae-missing-release-tag) "SerializedError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type SerializedError = JsonObject & { name: string; @@ -102,10 +77,6 @@ export type SerializedError = JsonObject & { code?: string; }; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// Warning: (ae-missing-release-tag) "serializeError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function serializeError(error: Error, options?: { includeStack?: boolean; diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md index 8dcc02a443..7c37ab0392 100644 --- a/packages/integration-react/api-report.md +++ b/packages/integration-react/api-report.md @@ -8,16 +8,12 @@ import { ApiRef } from '@backstage/core'; import { Config } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; -// Warning: (ae-missing-release-tag) "ScmIntegrationsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class ScmIntegrationsApi { // (undocumented) static fromConfig(config: Config): ScmIntegrationRegistry; } -// Warning: (ae-missing-release-tag) "scmIntegrationsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const scmIntegrationsApiRef: ApiRef; diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 6d85bf4a51..5387eb12db 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -6,15 +6,11 @@ import { Config } from '@backstage/config'; -// Warning: (ae-missing-release-tag) "AzureIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class AzureIntegration implements ScmIntegration { constructor(integrationConfig: AzureIntegrationConfig); // (undocumented) get config(): AzureIntegrationConfig; - // Warning: (ae-forgotten-export) The symbol "ScmIntegrationsFactory" needs to be exported by the entry point index.d.ts - // // (undocumented) static factory: ScmIntegrationsFactory; // (undocumented) @@ -31,16 +27,12 @@ export class AzureIntegration implements ScmIntegration { get type(): string; } -// Warning: (ae-missing-release-tag) "AzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type AzureIntegrationConfig = { host: string; token?: string; }; -// Warning: (ae-missing-release-tag) "BitbucketIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class BitbucketIntegration implements ScmIntegration { constructor(integrationConfig: BitbucketIntegrationConfig); @@ -62,8 +54,6 @@ export class BitbucketIntegration implements ScmIntegration { get type(): string; } -// Warning: (ae-missing-release-tag) "BitbucketIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type BitbucketIntegrationConfig = { host: string; @@ -73,8 +63,6 @@ export type BitbucketIntegrationConfig = { appPassword?: string; }; -// Warning: (ae-missing-release-tag) "defaultScmResolveUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function defaultScmResolveUrl(options: { url: string; @@ -82,123 +70,51 @@ export function defaultScmResolveUrl(options: { lineNumber?: number; }): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getAzureCommitsUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getAzureCommitsUrl(url: string): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getAzureDownloadUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getAzureDownloadUrl(url: string): string; -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getAzureFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getAzureFileFetchUrl(url: string): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getAzureRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getAzureRequestOptions(config: AzureIntegrationConfig, additionalHeaders?: Record): RequestInit; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getBitbucketDefaultBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getBitbucketDefaultBranch(url: string, config: BitbucketIntegrationConfig): Promise; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getBitbucketDownloadUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getBitbucketDownloadUrl(url: string, config: BitbucketIntegrationConfig): Promise; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getBitbucketFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getBitbucketFileFetchUrl(url: string, config: BitbucketIntegrationConfig): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getBitbucketRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getBitbucketRequestOptions(config: BitbucketIntegrationConfig): RequestInit; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getGitHubFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getGitHubFileFetchUrl(url: string, config: GitHubIntegrationConfig): string; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getGitHubRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getGitHubRequestOptions(config: GitHubIntegrationConfig): RequestInit; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getGitLabFileFetchUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getGitLabFileFetchUrl(url: string, config: GitLabIntegrationConfig): Promise; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "getGitLabRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function getGitLabRequestOptions(config: GitLabIntegrationConfig): RequestInit; -// Warning: (ae-missing-release-tag) "GithubCredentialsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class GithubCredentialsProvider { // (undocumented) static create(config: GitHubIntegrationConfig): GithubCredentialsProvider; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag - // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag - // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // Warning: (tsdoc-undefined-tag) The TSDoc tag "@type" is not defined in this configuration - // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag - // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag - // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // Warning: (ae-forgotten-export) The symbol "GithubCredentials" needs to be exported by the entry point index.d.ts getCredentials(opts: { url: string; }): Promise; } -// Warning: (ae-missing-release-tag) "GitHubIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class GitHubIntegration implements ScmIntegration { constructor(integrationConfig: GitHubIntegrationConfig); @@ -220,8 +136,6 @@ export class GitHubIntegration implements ScmIntegration { get type(): string; } -// Warning: (ae-missing-release-tag) "GitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type GitHubIntegrationConfig = { host: string; @@ -231,8 +145,6 @@ export type GitHubIntegrationConfig = { apps?: GithubAppConfig[]; }; -// Warning: (ae-missing-release-tag) "GitLabIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class GitLabIntegration implements ScmIntegration { constructor(integrationConfig: GitLabIntegrationConfig); @@ -254,8 +166,6 @@ export class GitLabIntegration implements ScmIntegration { get type(): string; } -// Warning: (ae-missing-release-tag) "GitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type GitLabIntegrationConfig = { host: string; @@ -264,63 +174,42 @@ export type GitLabIntegrationConfig = { baseUrl: string; }; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readAzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export type GoogleGcsIntegrationConfig = { + clientEmail?: string; + privateKey?: string; +}; + // @public export function readAzureIntegrationConfig(config: Config): AzureIntegrationConfig; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readAzureIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readAzureIntegrationConfigs(configs: Config[]): AzureIntegrationConfig[]; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readBitbucketIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readBitbucketIntegrationConfig(config: Config): BitbucketIntegrationConfig; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readBitbucketIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readBitbucketIntegrationConfigs(configs: Config[]): BitbucketIntegrationConfig[]; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readGitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readGitHubIntegrationConfig(config: Config): GitHubIntegrationConfig; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readGitHubIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readGitHubIntegrationConfigs(configs: Config[]): GitHubIntegrationConfig[]; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readGitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readGitLabIntegrationConfig(config: Config): GitLabIntegrationConfig; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "readGitLabIntegrationConfigs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function readGitLabIntegrationConfigs(configs: Config[]): GitLabIntegrationConfig[]; -// Warning: (ae-missing-release-tag) "ScmIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export function readGoogleGcsIntegrationConfig(config: Config): GoogleGcsIntegrationConfig; + // @public export interface ScmIntegration { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen resolveEditUrl(url: string): string; - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters resolveUrl(options: { url: string; base: string; @@ -330,8 +219,6 @@ export interface ScmIntegration { type: string; } -// Warning: (ae-missing-release-tag) "ScmIntegrationRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { // (undocumented) @@ -342,11 +229,7 @@ export interface ScmIntegrationRegistry extends ScmIntegrationsGroup; // (undocumented) gitlab: ScmIntegrationsGroup; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen resolveEditUrl(url: string): string; - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters resolveUrl(options: { url: string; base: string; @@ -354,11 +237,8 @@ export interface ScmIntegrationRegistry extends ScmIntegrationsGroup; @@ -386,24 +266,14 @@ export class ScmIntegrations implements ScmIntegrationRegistry { }): string; } -// Warning: (ae-missing-release-tag) "ScmIntegrationsGroup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface ScmIntegrationsGroup { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen byHost(host: string): T | undefined; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen byUrl(url: string | URL): T | undefined; list(): T[]; } -// Warnings were encountered during analysis: -// -// src/github/config.ts:67:3 - (ae-forgotten-export) The symbol "GithubAppConfig" needs to be exported by the entry point index.d.ts -// src/gitlab/config.ts:51:66 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// src/gitlab/config.ts:51:61 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md index ed53275017..70522f4ee4 100644 --- a/packages/search-common/api-report.md +++ b/packages/search-common/api-report.md @@ -6,24 +6,18 @@ import { JsonObject } from '@backstage/config'; -// Warning: (ae-missing-release-tag) "DocumentCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface DocumentCollator { // (undocumented) execute(): Promise; } -// Warning: (ae-missing-release-tag) "DocumentDecorator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface DocumentDecorator { // (undocumented) execute(documents: IndexableDocument[]): Promise; } -// Warning: (ae-missing-release-tag) "IndexableDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface IndexableDocument { location: string; @@ -31,8 +25,6 @@ export interface IndexableDocument { title: string; } -// Warning: (ae-missing-release-tag) "SearchQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface SearchQuery { // (undocumented) @@ -43,16 +35,12 @@ export interface SearchQuery { term: string; } -// Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface SearchResult { // (undocumented) document: IndexableDocument; } -// Warning: (ae-missing-release-tag) "SearchResultSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface SearchResultSet { // (undocumented) diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index aa6c9da2f8..c26f62e981 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -17,26 +17,18 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; -// Warning: (ae-missing-release-tag) "checkoutGitRepository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger) => Promise; -// Warning: (ae-missing-release-tag) "CommonGitPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class CommonGitPreparer implements PreparerBase { constructor(config: Config, logger: Logger); - // Warning: (ae-forgotten-export) The symbol "PreparerResponse" needs to be exported by the entry point index.d.ts - // // (undocumented) prepare(entity: Entity, options?: { etag?: string; }): Promise; } -// Warning: (ae-missing-release-tag) "DirectoryPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class DirectoryPreparer implements PreparerBase { constructor(config: Config, logger: Logger, reader: UrlReader); @@ -44,23 +36,17 @@ export class DirectoryPreparer implements PreparerBase { prepare(entity: Entity): Promise; } -// Warning: (ae-missing-release-tag) "GeneratorBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type GeneratorBase = { run(opts: GeneratorRunOptions): Promise; }; -// Warning: (ae-missing-release-tag) "GeneratorBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type GeneratorBuilder = { register(protocol: SupportedGeneratorKey, generator: GeneratorBase): void; get(entity: Entity): GeneratorBase; }; -// Warning: (ae-missing-release-tag) "Generators" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class Generators implements GeneratorBuilder { // (undocumented) @@ -73,79 +59,51 @@ export class Generators implements GeneratorBuilder { register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase): void; } -// Warning: (ae-missing-release-tag) "getAzureIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getAzureIntegrationConfig: (config: Config, host: string) => AzureIntegrationConfig; -// Warning: (ae-missing-release-tag) "getDefaultBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getDefaultBranch: (repositoryUrl: string, config: Config) => Promise; -// Warning: (ae-missing-release-tag) "getDocFilesFromRepository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getDocFilesFromRepository: (reader: UrlReader, entity: Entity, opts?: { etag?: string | undefined; logger?: Logger | undefined; } | undefined) => Promise; -// Warning: (ae-missing-release-tag) "getGitHost" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function getGitHost(url: string): string; -// Warning: (ae-missing-release-tag) "getGitHubIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getGitHubIntegrationConfig: (config: Config, host: string) => GitHubIntegrationConfig; -// Warning: (ae-missing-release-tag) "getGitLabIntegrationConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getGitLabIntegrationConfig: (config: Config, host: string) => GitLabIntegrationConfig; -// Warning: (ae-missing-release-tag) "getGitRepositoryTempFolder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getGitRepositoryTempFolder: (repositoryUrl: string, config: Config) => Promise; -// Warning: (ae-missing-release-tag) "getGitRepoType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function getGitRepoType(url: string): string; -// Warning: (ae-missing-release-tag) "getLastCommitTimestamp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger) => Promise; -// Warning: (ae-missing-release-tag) "getLocationForEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation; -// Warning: (ae-missing-release-tag) "getTokenForGitRepo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const getTokenForGitRepo: (repositoryUrl: string, config: Config) => Promise; -// Warning: (ae-missing-release-tag) "ParsedLocationAnnotation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type ParsedLocationAnnotation = { type: RemoteProtocol; target: string; }; -// Warning: (ae-missing-release-tag) "parseReferenceAnnotation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const parseReferenceAnnotation: (annotationName: string, entity: Entity) => ParsedLocationAnnotation; -// Warning: (ae-missing-release-tag) "PreparerBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type PreparerBase = { prepare(entity: Entity, options?: { @@ -154,20 +112,14 @@ export type PreparerBase = { }): Promise; }; -// Warning: (ae-missing-release-tag) "PreparerBuilder" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type PreparerBuilder = { register(protocol: RemoteProtocol, preparer: PreparerBase): void; get(entity: Entity): PreparerBase; }; -// Warning: (ae-missing-release-tag) "Preparers" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class Preparers implements PreparerBuilder { - // Warning: (ae-forgotten-export) The symbol "factoryOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static fromConfig(config: Config, { logger, reader }: factoryOptions): Promise; // (undocumented) @@ -176,41 +128,26 @@ export class Preparers implements PreparerBuilder { register(protocol: RemoteProtocol, preparer: PreparerBase): void; } -// Warning: (ae-missing-release-tag) "Publisher" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class Publisher { - // Warning: (ae-forgotten-export) The symbol "factoryOptions" needs to be exported by the entry point index.d.ts - // // (undocumented) static fromConfig(config: Config, { logger, discovery }: factoryOptions_2): Promise; } -// Warning: (ae-missing-release-tag) "PublisherBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export interface PublisherBase { docsRouter(): express.Handler; fetchTechDocsMetadata(entityName: EntityName): Promise; hasDocsBeenGenerated(entityName: Entity): Promise; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (ae-forgotten-export) The symbol "PublishRequest" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "PublishResponse" needs to be exported by the entry point index.d.ts publish(request: PublishRequest): Promise; } -// Warning: (ae-missing-release-tag) "PublisherType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type PublisherType = 'local' | 'googleGcs' | 'awsS3' | 'azureBlobStorage' | 'openStackSwift'; -// Warning: (ae-missing-release-tag) "RemoteProtocol" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azure/api'; -// Warning: (ae-missing-release-tag) "TechdocsGenerator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class TechdocsGenerator implements GeneratorBase { constructor(logger: Logger, config: Config); @@ -218,9 +155,6 @@ export class TechdocsGenerator implements GeneratorBase { run({ inputDir, outputDir, dockerClient, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "TechDocsMetadata" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type TechDocsMetadata = { site_name: string; @@ -228,8 +162,6 @@ export type TechDocsMetadata = { etag: string; }; -// Warning: (ae-missing-release-tag) "UrlPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class UrlPreparer implements PreparerBase { constructor(reader: UrlReader, logger: Logger); @@ -240,15 +172,6 @@ export class UrlPreparer implements PreparerBase { } -// Warnings were encountered during analysis: -// -// src/stages/generate/types.ts:42:3 - (ae-forgotten-export) The symbol "GeneratorRunOptions" needs to be exported by the entry point index.d.ts -// src/stages/generate/types.ts:54:3 - (ae-forgotten-export) The symbol "SupportedGeneratorKey" needs to be exported by the entry point index.d.ts -// src/stages/prepare/types.ts:35:6 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/stages/prepare/types.ts:36:6 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// src/stages/prepare/types.ts:38:31 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// src/stages/prepare/types.ts:38:14 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md index bda058ad23..4b603b8e7b 100644 --- a/packages/test-utils-core/api-report.md +++ b/packages/test-utils-core/api-report.md @@ -7,20 +7,14 @@ import { ReactElement } from 'react'; import { RenderResult } from '@testing-library/react'; -// Warning: (ae-missing-release-tag) "AsyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type AsyncLogCollector = () => Promise; -// Warning: (ae-missing-release-tag) "CollectedLogs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type CollectedLogs = { [key in T]: string[]; }; -// Warning: (ae-missing-release-tag) "Keyboard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class Keyboard { constructor(target: any, { debug }?: { @@ -62,31 +56,18 @@ export class Keyboard { static typeDebug(target: any, input: any): Promise; } -// Warning: (ae-missing-release-tag) "LogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type LogCollector = AsyncLogCollector | SyncLogCollector; -// Warning: (ae-missing-release-tag) "LogFuncs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type LogFuncs = 'log' | 'warn' | 'error'; -// Warning: (ae-missing-release-tag) "renderWithEffects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function renderWithEffects(nodes: ReactElement): Promise; -// Warning: (ae-missing-release-tag) "SyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type SyncLogCollector = () => void; -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function withLogCollector(callback: AsyncLogCollector): Promise>; diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index f62b1e6dec..dcac9b64a7 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -16,28 +16,20 @@ import { RouteRef } from '@backstage/core-api'; import { StorageApi } from '@backstage/core-api'; import { StorageValueChange } from '@backstage/core-api'; -// Warning: (ae-forgotten-export) The symbol "Breakpoint" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "mockBreakpoint" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function mockBreakpoint(initialBreakpoint?: Breakpoint): { set(breakpoint: Breakpoint): void; remove(): void; }; -// Warning: (ae-missing-release-tag) "MockErrorApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class MockErrorApi implements ErrorApi { - // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts constructor(options?: Options); // (undocumented) error$(): Observable<{ error: Error; context?: ErrorContext; }>; - // Warning: (ae-forgotten-export) The symbol "ErrorWithContext" needs to be exported by the entry point index.d.ts - // // (undocumented) getErrors(): ErrorWithContext[]; // (undocumented) @@ -46,8 +38,6 @@ export class MockErrorApi implements ErrorApi { waitForError(pattern: RegExp, timeoutMs?: number): Promise; } -// Warning: (ae-missing-release-tag) "MockStorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export class MockStorageApi implements StorageApi { // (undocumented) @@ -64,15 +54,11 @@ export class MockStorageApi implements StorageApi { set(key: string, data: T): Promise; } -// Warning: (ae-missing-release-tag) "MockStorageBucket" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type MockStorageBucket = { [key: string]: any; }; -// Warning: (ae-missing-release-tag) "msw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const msw: { setupDefaultHandlers: (worker: { @@ -82,14 +68,9 @@ export const msw: { }) => void; }; -// Warning: (ae-forgotten-export) The symbol "TestAppOptions" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "renderInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function renderInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): Promise; -// Warning: (ae-missing-release-tag) "wrapInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function wrapInTestApp(Component: ComponentType | ReactNode, options?: TestAppOptions): ReactElement; diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index adfbcee346..93d0957f5a 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -10,19 +10,12 @@ import { PaletteOptions } from '@material-ui/core/styles/createPalette'; import { Theme } from '@material-ui/core'; import { ThemeOptions } from '@material-ui/core'; -// Warning: (ae-forgotten-export) The symbol "PaletteAdditions" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "BackstagePalette" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type BackstagePalette = Palette & PaletteAdditions; -// Warning: (ae-missing-release-tag) "BackstagePaletteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type BackstagePaletteOptions = PaletteOptions & PaletteAdditions; -// Warning: (ae-missing-release-tag) "BackstageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface BackstageTheme extends Theme { // (undocumented) @@ -33,8 +26,6 @@ export interface BackstageTheme extends Theme { palette: BackstagePalette; } -// Warning: (ae-missing-release-tag) "BackstageThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface BackstageThemeOptions extends ThemeOptions { // (undocumented) @@ -45,43 +36,27 @@ export interface BackstageThemeOptions extends ThemeOptions { palette: BackstagePaletteOptions; } -// Warning: (ae-missing-release-tag) "colorVariants" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const colorVariants: Record; -// Warning: (ae-missing-release-tag) "createTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function createTheme(options: SimpleThemeOptions): BackstageTheme; -// Warning: (ae-missing-release-tag) "createThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function createThemeOptions(options: SimpleThemeOptions): BackstageThemeOptions; -// Warning: (ae-missing-release-tag) "createThemeOverrides" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function createThemeOverrides(theme: BackstageTheme): Overrides; -// Warning: (ae-missing-release-tag) "darkTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const darkTheme: BackstageTheme; -// Warning: (ae-missing-release-tag) "genPageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export function genPageTheme(colors: string[], shape: string): PageTheme; -// Warning: (ae-missing-release-tag) "lightTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const lightTheme: BackstageTheme; -// Warning: (ae-missing-release-tag) "PageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type PageTheme = { colors: string[]; @@ -89,25 +64,17 @@ export type PageTheme = { backgroundImage: string; }; -// Warning: (ae-missing-release-tag) "pageTheme" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const pageTheme: Record; -// Warning: (ae-missing-release-tag) "PageThemeSelector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type PageThemeSelector = { themeId: string; }; -// Warning: (ae-missing-release-tag) "shapes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const shapes: Record; -// Warning: (ae-missing-release-tag) "SimpleThemeOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type SimpleThemeOptions = { palette: BackstagePaletteOptions; diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 4135c52701..7bbf955f9a 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -133,9 +133,9 @@ async function runApiExtraction({ }, messages: { + // Silence warnings, as these will prevent the CI build to work compilerMessageReporting: { default: { - // Silence compiler warnings, as these will prevent the CI build to work logLevel: 'none' as ExtractorLogLevel.None, // These contain absolute file paths, so can't be included in the report // addToApiReportFile: true, @@ -143,14 +143,14 @@ async function runApiExtraction({ }, extractorMessageReporting: { default: { - logLevel: 'warning' as ExtractorLogLevel.Warning, - addToApiReportFile: true, + logLevel: 'none' as ExtractorLogLevel.Warning, + // addToApiReportFile: true, }, }, tsdocMessageReporting: { default: { - logLevel: 'warning' as ExtractorLogLevel.Warning, - addToApiReportFile: true, + logLevel: 'none' as ExtractorLogLevel.Warning, + // addToApiReportFile: true, }, }, }, From 70ac6d68c25d398435d0fb0f11d8f5067556598d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Apr 2021 18:24:41 +0200 Subject: [PATCH 105/176] scripts/api-extractor: some more explanations Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 7bbf955f9a..7da9fac515 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -35,7 +35,13 @@ import { MarkdownDocumenter } from '@microsoft/api-documenter/lib/documenters/Ma const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); /** - * Yup + * All of this monkey patching below is because MUI has these bare package.json file as a method + * for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking + * by declaring them side effect free. + * + * The package.json lookup logic in api-extractor really doesn't like that though, as it enforces + * that the 'name' field exists in all package.json files that it discovers. This below is just + * making sure that we ignore those file package.json files instead of crashing. */ const { PackageJsonLookup, @@ -61,6 +67,7 @@ const DOCUMENTED_PACKAGES = [ 'packages/cli-common', 'packages/config', 'packages/config-loader', + // TODO(Rugvip): Enable these once `import * as ...` and `import()` PRs have landed, #1796 & #1916. // 'packages/core', // 'packages/core-api', 'packages/dev-utils', From 215b44de9c10a9b6d8f657ad42c91a15c3f41116 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Apr 2021 18:29:22 +0200 Subject: [PATCH 106/176] scripts/api-extractor: fix comment Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 7da9fac515..37dc4769fd 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -247,7 +247,7 @@ async function runApiExtraction({ } function isComponentMember(member: any) { - // React components are annotated with @components, and we want to skip those + // React components are annotated with @component, and we want to skip those return Boolean(member.docComment.match(/\n\s*\**\s*@component/m)); } From 9d3fb9a30aaeb8d9901ff76047a88b0eef722790 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Apr 2021 20:51:12 +0200 Subject: [PATCH 107/176] scripts/api-extractor: sync API definitions Signed-off-by: Patrik Oldsberg --- packages/search-common/api-report.md | 2 ++ packages/techdocs-common/api-report.md | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/search-common/api-report.md b/packages/search-common/api-report.md index 70522f4ee4..76bd937a1c 100644 --- a/packages/search-common/api-report.md +++ b/packages/search-common/api-report.md @@ -33,6 +33,8 @@ export interface SearchQuery { pageCursor: string; // (undocumented) term: string; + // (undocumented) + types?: string[]; } // @public (undocumented) diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index c26f62e981..a53fb9e681 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -138,6 +138,7 @@ export class Publisher { export interface PublisherBase { docsRouter(): express.Handler; fetchTechDocsMetadata(entityName: EntityName): Promise; + getReadiness(): Promise; hasDocsBeenGenerated(entityName: Entity): Promise; publish(request: PublishRequest): Promise; } From b74e3e1efc3739304e37435a58ea3f0f16478bfe Mon Sep 17 00:00:00 2001 From: Victor Perera Date: Mon, 26 Apr 2021 18:53:11 -0500 Subject: [PATCH 108/176] 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 109/176] 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 111d30a8cb6fb91142d267ccc2e6eeb033915b2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Apr 2021 04:20:03 +0000 Subject: [PATCH 110/176] chore(deps-dev): bump @types/express-serve-static-core Bumps [@types/express-serve-static-core](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-serve-static-core) from 4.17.18 to 4.17.19. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-serve-static-core) Signed-off-by: dependabot[bot] --- yarn.lock | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index f2c2bee93c..ce39d4425e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5875,7 +5875,16 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/express-serve-static-core@*", "@types/express-serve-static-core@4.17.18", "@types/express-serve-static-core@^4.17.5": +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": + version "4.17.19" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d" + integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express-serve-static-core@4.17.18": version "4.17.18" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== From 4c42ecca2d106c80f358655b7e0863be463049c6 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Mon, 19 Apr 2021 16:31:30 +0100 Subject: [PATCH 111/176] 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 112/176] 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 113/176] 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 266e46d3c3a4f73f5c27f50c4275be254b4ed9e9 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Tue, 27 Apr 2021 11:07:08 +0200 Subject: [PATCH 114/176] switch from map to forEach Signed-off-by: Emma Indal --- plugins/search-backend-node/src/IndexBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 768dee5b67..71d374e704 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -98,7 +98,7 @@ export class IndexBuilder { async build(): Promise<{ scheduler: Scheduler }> { const scheduler = new Scheduler({ logger: this.logger }); - Object.keys(this.collators).map(type => { + Object.keys(this.collators).forEach(type => { scheduler.addToSchedule(async () => { // Collate, Decorate, Index. const decorators: DocumentDecorator[] = ( From 4906a4c2210c38e1364bf69c882ab90df2fc7f3c Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Tue, 27 Apr 2021 11:07:55 +0200 Subject: [PATCH 115/176] rename test file to be more specific to IndexBuilder Signed-off-by: Emma Indal --- .../src/{index.test.ts => IndexBuilder.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/search-backend-node/src/{index.test.ts => IndexBuilder.test.ts} (100%) diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/IndexBuilder.test.ts similarity index 100% rename from plugins/search-backend-node/src/index.test.ts rename to plugins/search-backend-node/src/IndexBuilder.test.ts From 6c46febf6326a8a119529e2b37f2029178fa7a3e Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Tue, 27 Apr 2021 11:08:37 +0200 Subject: [PATCH 116/176] Add tests for Scheduler Signed-off-by: Emma Indal --- .../search-backend-node/src/Scheduler.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 plugins/search-backend-node/src/Scheduler.test.ts diff --git a/plugins/search-backend-node/src/Scheduler.test.ts b/plugins/search-backend-node/src/Scheduler.test.ts new file mode 100644 index 0000000000..43fdb7ea92 --- /dev/null +++ b/plugins/search-backend-node/src/Scheduler.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { Scheduler } from './index'; + +describe('Scheduler', () => { + let testScheduler: Scheduler; + + beforeEach(() => { + const logger = getVoidLogger(); + testScheduler = new Scheduler({ + logger, + }); + }); + + describe('addToSchedule', () => { + it('should not add a task and interval to schedule, if already started', async () => { + jest.useFakeTimers(); + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + + // Add a task and interval to schedule + testScheduler.addToSchedule(mockTask1, 2); + + // Starts scheduling process + testScheduler.start(); + + // Throws Error if task and interval is added to a already started schedule + expect(() => testScheduler.addToSchedule(mockTask2, 2)).toThrowError(); + + jest.runOnlyPendingTimers(); + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).not.toHaveBeenCalled(); + }); + + it('should be possible to add a task and interval to schedule, if already started, but stopped in between', async () => { + jest.useFakeTimers(); + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + + // Add a task and interval to schedule + testScheduler.addToSchedule(mockTask1, 2); + + // Starts scheduling process + testScheduler.start(); + + // Stop scheduling process + testScheduler.stop(); + + // Should't throw error, as it is stopped. + expect(() => + testScheduler.addToSchedule(mockTask2, 4), + ).not.toThrowError(); + + // Starts scheduling process + testScheduler.start(); + + jest.runOnlyPendingTimers(); + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).toHaveBeenCalled(); + }); + }); +}); From 1a142ae8a42fb018e9c746f853e3d5c460dc544d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Apr 2021 21:36:50 +0200 Subject: [PATCH 117/176] catalog: switch out time-based greeting for plain title Signed-off-by: Patrik Oldsberg --- .changeset/witty-lies-shave.md | 5 +++++ .../src/components/CatalogPage/CatalogLayout.tsx | 13 ++++--------- .../src}/utils/locales/goodAfternoon.locales.json | 0 .../src}/utils/locales/goodEvening.locales.json | 0 .../src}/utils/locales/goodMorning.locales.json | 0 .../CatalogPage => welcome/src}/utils/timeUtil.js | 0 .../src}/utils/timeUtil.test.js | 0 7 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 .changeset/witty-lies-shave.md rename plugins/{catalog/src/components/CatalogPage => welcome/src}/utils/locales/goodAfternoon.locales.json (100%) rename plugins/{catalog/src/components/CatalogPage => welcome/src}/utils/locales/goodEvening.locales.json (100%) rename plugins/{catalog/src/components/CatalogPage => welcome/src}/utils/locales/goodMorning.locales.json (100%) rename plugins/{catalog/src/components/CatalogPage => welcome/src}/utils/timeUtil.js (100%) rename plugins/{catalog/src/components/CatalogPage => welcome/src}/utils/timeUtil.test.js (100%) diff --git a/.changeset/witty-lies-shave.md b/.changeset/witty-lies-shave.md new file mode 100644 index 0000000000..4c0aeef80a --- /dev/null +++ b/.changeset/witty-lies-shave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Switch out the time-based personal greeting for a plain title on the catalog index page. diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 298aeacfec..c038f3f08c 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -18,29 +18,24 @@ import { configApiRef, Header, HomepageTimer, - identityApiRef, Page, useApi, } from '@backstage/core'; import React from 'react'; -import { getTimeBasedGreeting } from './utils/timeUtil'; type Props = { children?: React.ReactNode; }; const CatalogLayout = ({ children }: Props) => { - const greeting = getTimeBasedGreeting(); - const profile = useApi(identityApiRef).getProfile(); - const userId = useApi(identityApiRef).getUserId(); - const orgName = useApi(configApiRef).getOptionalString('organization.name'); + const orgName = + useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; return (
diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodAfternoon.locales.json b/plugins/welcome/src/utils/locales/goodAfternoon.locales.json similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodAfternoon.locales.json rename to plugins/welcome/src/utils/locales/goodAfternoon.locales.json diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json b/plugins/welcome/src/utils/locales/goodEvening.locales.json similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json rename to plugins/welcome/src/utils/locales/goodEvening.locales.json diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodMorning.locales.json b/plugins/welcome/src/utils/locales/goodMorning.locales.json similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/locales/goodMorning.locales.json rename to plugins/welcome/src/utils/locales/goodMorning.locales.json diff --git a/plugins/catalog/src/components/CatalogPage/utils/timeUtil.js b/plugins/welcome/src/utils/timeUtil.js similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/timeUtil.js rename to plugins/welcome/src/utils/timeUtil.js diff --git a/plugins/catalog/src/components/CatalogPage/utils/timeUtil.test.js b/plugins/welcome/src/utils/timeUtil.test.js similarity index 100% rename from plugins/catalog/src/components/CatalogPage/utils/timeUtil.test.js rename to plugins/welcome/src/utils/timeUtil.test.js From 8b43d5f8a231ba58be5cbbad0467834c13e23f6d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Apr 2021 21:45:51 +0200 Subject: [PATCH 118/176] catalog: remove home page clocks Signed-off-by: Patrik Oldsberg --- .changeset/witty-lies-shave.md | 2 +- .../src/components/CatalogPage/CatalogLayout.tsx | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.changeset/witty-lies-shave.md b/.changeset/witty-lies-shave.md index 4c0aeef80a..68fd4b1137 100644 --- a/.changeset/witty-lies-shave.md +++ b/.changeset/witty-lies-shave.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog': patch --- -Switch out the time-based personal greeting for a plain title on the catalog index page. +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/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index c038f3f08c..e762efdee8 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -14,13 +14,7 @@ * limitations under the License. */ -import { - configApiRef, - Header, - HomepageTimer, - Page, - useApi, -} from '@backstage/core'; +import { configApiRef, Header, Page, useApi } from '@backstage/core'; import React from 'react'; type Props = { @@ -37,9 +31,7 @@ const CatalogLayout = ({ children }: Props) => { title={`${orgName} Catalog`} subtitle={`Index of software components in ${orgName}`} pageTitleOverride="Home" - > - -
+ /> {children}
); From f2a5ed93b68927475fbb6cb57c129f113e0d9a40 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Apr 2021 12:25:28 +0200 Subject: [PATCH 119/176] catalog: tweak index page subtitle Signed-off-by: Patrik Oldsberg --- plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index e762efdee8..a57b223afe 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -29,7 +29,7 @@ const CatalogLayout = ({ children }: Props) => {
{children} From 4bfff07eaec3b7255bc56c48acd0cf8b2210def9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Apr 2021 12:27:06 +0200 Subject: [PATCH 120/176] e2e-test: update to search for new catalog index page title Signed-off-by: Patrik Oldsberg --- packages/e2e-test/src/commands/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index c055bcd21e..ebf4e6c835 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -335,7 +335,7 @@ async function testAppServe(pluginName: string, appDir: string) { try { const browser = new Browser(); - await waitForPageWithText(browser, '/', 'My Company Service Catalog'); + await waitForPageWithText(browser, '/', 'My Company Catalog'); await waitForPageWithText( browser, `/${pluginName}`, From 73f3f5d78d2388576c25b462a181fb3ddf2767c7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Apr 2021 12:29:53 +0200 Subject: [PATCH 121/176] create-app: update app template to check for new catalog index page title Signed-off-by: Patrik Oldsberg --- .changeset/little-trees-fold.md | 5 +++++ .../default-app/packages/app/cypress/integration/app.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/little-trees-fold.md diff --git a/.changeset/little-trees-fold.md b/.changeset/little-trees-fold.md new file mode 100644 index 0000000000..4963dd5aa1 --- /dev/null +++ b/.changeset/little-trees-fold.md @@ -0,0 +1,5 @@ +--- +'@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/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js index efcd5b8d93..43fb2e32de 100644 --- a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js +++ b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js @@ -1,6 +1,6 @@ describe('App', () => { it('should render the catalog', () => { cy.visit('/'); - cy.contains('My Company Service Catalog'); + cy.contains('My Company Catalog'); }); }); From 8ce7d6e8f81d621c66e069c64cfe67f4e42fed3c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 27 Apr 2021 14:16:49 +0200 Subject: [PATCH 122/176] 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 07a7806c31847c459b3aaea8bad7d76bfe952c94 Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Thu, 22 Apr 2021 16:20:03 +0200 Subject: [PATCH 123/176] Filter returned fields in apis explorer Signed-off-by: Anastasia Rodionova --- .changeset/kind-suns-melt.md | 5 +++++ .../components/ApiExplorerPage/ApiExplorerPage.tsx | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/kind-suns-melt.md diff --git a/.changeset/kind-suns-melt.md b/.changeset/kind-suns-melt.md new file mode 100644 index 0000000000..7cbec28fea --- /dev/null +++ b/.changeset/kind-suns-melt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Added fields filtering in get API entities to avoid the requesting of unused data diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 83d0bfc2ba..1ed47811ef 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -34,7 +34,19 @@ export const ApiExplorerPage = () => { const createComponentLink = useRouteRef(createComponentRouteRef); const catalogApi = useApi(catalogApiRef); const { loading, error, value: catalogResponse } = useAsync(() => { - return catalogApi.getEntities({ filter: { kind: 'API' } }); + return catalogApi.getEntities({ + filter: { kind: 'API' }, + fields: [ + 'apiVersion', + 'kind', + 'metadata', + 'relations', + 'spec.lifecycle', + 'spec.owner', + 'spec.type', + 'spec.system', + ], + }); }, [catalogApi]); return ( From 74f74ccbdbb9932cbda2a88eb81a371bbb57a26f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Apr 2021 17:16:16 +0200 Subject: [PATCH 124/176] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw --- docs/features/techdocs/getting-started.md | 2 +- docs/overview/architecture-overview.md | 6 +++--- plugins/api-docs/README.md | 2 +- plugins/catalog-import/README.md | 2 +- plugins/rollbar/README.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 934e91bd26..223b276d68 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -29,7 +29,7 @@ yarn add @backstage/plugin-techdocs Once the package has been installed, you need to import the plugin in your app. -In `packages/app/src/App.tsx`, import TechDocsPage and add the following to +In `packages/app/src/App.tsx`, import `TechDocsPage` and add the following to `FlatRoutes`: ```tsx diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index fab5698014..44bc1293f0 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -95,10 +95,10 @@ any route we want for the page, as long as it doesn't collide with the routes that we choose for the other plugins in the app. These components that are exported from plugins are referred to as "Plugin -Extension Components", or "Extensions Components". They are regular React +Extension Components", or "Extension Components". They are regular React components, but in addition to being able to be rendered by React, they also -contain various pieces of metadata that is used to write together the entire -app. Extensions components are created using `create*Extension` methods, which +contain various pieces of metadata that is used to wire together the entire +app. Extension components are created using `create*Extension` methods, which you can read more about in the [composability documentation](../plugins/composability.md). diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 3d44eaaa29..dac4b6ff4d 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -33,7 +33,7 @@ To link that a component provides or consumes an API, see the [`providesApis`](h yarn add @backstage/plugin-api-docs ``` -2. Add the `ApiExplorerPage` extensions to the app: +2. Add the `ApiExplorerPage` extension to the app: ```tsx // packages/app/src/App.tsx diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index 90190e4356..5e773ad288 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -23,7 +23,7 @@ Some features are not yet available for all supported Git providers. yarn add @backstage/plugin-catalog-import ``` -2. Add the `CatalogImportPage` extensions to the app: +2. Add the `CatalogImportPage` extension to the app: ```tsx // packages/app/src/App.tsx diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index 584bd7d6bc..3a33fe223c 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -30,7 +30,7 @@ const serviceEntityPage = ( ); ``` -4. Setup the `app.config.yaml` and account token environment variable +4. Setup the `app-config.yaml` and account token environment variable ```yaml # app.config.yaml From 6b580d979b4d5aed9f9b167436c8b3a448af58b0 Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Tue, 27 Apr 2021 09:16:41 -0600 Subject: [PATCH 125/176] 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 d6ead26753337e84150e4aa7dececfbd4abbd086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 23 Apr 2021 16:50:06 +0200 Subject: [PATCH 126/176] Update the Bitrise plugin README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/bitrise/README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/plugins/bitrise/README.md b/plugins/bitrise/README.md index ae3917d78a..411db3198e 100644 --- a/plugins/bitrise/README.md +++ b/plugins/bitrise/README.md @@ -8,30 +8,30 @@ Welcome to the Bitrise plugin! ## Installation ```sh +# The plugin must be added in the app package +$ cd packages/app $ yarn add @backstage/plugin-bitrise ``` -Bitrise Plugin exposes an "Entity Tab Content" component `EntityBitriseContent`. You can include it in the [`EntityPage.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/catalog/EntityPage.tsx)`: +Bitrise Plugin exposes an entity tab component named `EntityBitriseContent`. You can include it in the +[`EntityPage.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/catalog/EntityPage.tsx)`: ```tsx // At the top imports import { EntityBitriseContent } from '@backstage/plugin-bitrise'; -// Inside `WebsiteEntityPage` component - - - - - - } -/>; +// Farther down at the website declaration +const websiteEntityPage = ( + + {/* Place the following section where you want the tab to appear */} + + + ``` -Now your plugin should be visible in the entity page, however in a state with a missing `bitrise.io/app` annotation. +Now your plugin should be visible as a tab at the top of the entity pages, +specifically for components that are of the type `website`. +However, it warns of a missing `bitrise.io/app` annotation. Add the annotation to your component [catalog-info.yaml](https://github.com/backstage/backstage/blob/master/catalog-info.yaml) as shown in the highlighted example below: From e5d4cd4dc8b4db1fa48ec059341c14c112e76c96 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Apr 2021 17:30:34 +0200 Subject: [PATCH 127/176] docs: prettier Signed-off-by: Patrik Oldsberg --- docs/overview/architecture-overview.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 44bc1293f0..316f066614 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -97,9 +97,9 @@ that we choose for the other plugins in the app. These components that are exported from plugins are referred to as "Plugin Extension Components", or "Extension Components". They are regular React components, but in addition to being able to be rendered by React, they also -contain various pieces of metadata that is used to wire together the entire -app. Extension components are created using `create*Extension` methods, which -you can read more about in the +contain various pieces of metadata that is used to wire together the entire app. +Extension components are created using `create*Extension` methods, which you can +read more about in the [composability documentation](../plugins/composability.md). As of this moment, there is no config based install procedure for plugins. Some From 826bcc46b6afebaa915d5d0da0df2803d031e2a3 Mon Sep 17 00:00:00 2001 From: Victor Perera Date: Tue, 27 Apr 2021 10:36:14 -0500 Subject: [PATCH 128/176] 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 129/176] 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 130/176] 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 131/176] 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 a1783f3060bc306fd3e2261eee14491aa43c397b Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 27 Apr 2021 11:04:07 -0600 Subject: [PATCH 132/176] Add nebula-preview Signed-off-by: Tim Hansen --- .changeset/thin-coins-compete.md | 5 +++++ .../src/scaffolder/actions/builtin/publish/github.ts | 1 + .../src/scaffolder/stages/publish/github.ts | 1 + 3 files changed, 7 insertions(+) create mode 100644 .changeset/thin-coins-compete.md diff --git a/.changeset/thin-coins-compete.md b/.changeset/thin-coins-compete.md new file mode 100644 index 0000000000..36511ec1cb --- /dev/null +++ b/.changeset/thin-coins-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added the `nebula-preview` preview to `Octokit` for repository visibility. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 3ae43c5763..66dfc1d655 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -149,6 +149,7 @@ export function createPublishGithubAction(options: { const client = new Octokit({ auth: token, baseUrl: integrationConfig.config.apiBaseUrl, + previews: ['nebula-preview'], }); const user = await client.users.getByUsername({ diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index d0bfc89529..829856ec0c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -72,6 +72,7 @@ export class GithubPublisher implements PublisherBase { const client = new Octokit({ auth: token, baseUrl: this.config.apiBaseUrl, + previews: ['nebula-preview'], }); const description = values.description as string; From 1a3ce09aee63fa19714f149d73b3985a960fc43c Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 27 Apr 2021 21:23:35 +0200 Subject: [PATCH 133/176] 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 134/176] 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 135/176] 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 136/176] 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 da546ce005ab90240a0fec4c2969826e7cfb1d38 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 28 Apr 2021 09:12:15 +0200 Subject: [PATCH 137/176] 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 138/176] 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 139/176] 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 140/176] 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 141/176] 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 142/176] [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 143/176] 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 144/176] 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 145/176] 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 146/176] 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 147/176] 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 148/176] 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 149/176] 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 150/176] 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 151/176] 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 152/176] 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 153/176] 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 154/176] 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 155/176] 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 156/176] 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 157/176] 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 158/176] 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 159/176] 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 160/176] 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 161/176] 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 162/176] 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 163/176] 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 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 164/176] 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 55e1d9f3a58696ad23e50e49a1a48a173aef10bb Mon Sep 17 00:00:00 2001 From: jrusso1020 Date: Wed, 28 Apr 2021 08:45:35 -0600 Subject: [PATCH 165/176] 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 166/176] 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 167/176] 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 168/176] 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 169/176] 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 170/176] 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 171/176] 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 172/176] [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 173/176] 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 174/176] 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 175/176] 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 a99e0bc42c08d81a5a01794baca372f726082860 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 29 Apr 2021 11:38:21 +0200 Subject: [PATCH 176/176] [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) || '', }; }, );