diff --git a/.changeset/fair-geese-yell.md b/.changeset/fair-geese-yell.md deleted file mode 100644 index 00c5b16854..0000000000 --- a/.changeset/fair-geese-yell.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Fix bug where `$${...}` was not being escaped to `${...}` - -Add support for environment variable substitution in `$include`, `$file` and -`$env` transform values. - -- This change allows for including dynamic paths, such as environment specific - secrets by using the same environment variable substitution (`${..}`) already - supported outside of the various include transforms. -- If you are currently using the syntax `${...}` in your include transform values, - you will need to escape the substitution by using `$${...}` instead to maintain - the same behavior. diff --git a/.changeset/forty-ladybugs-move.md b/.changeset/forty-ladybugs-move.md deleted file mode 100644 index 254efea584..0000000000 --- a/.changeset/forty-ladybugs-move.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -When using OAuth2 authentication the name is now taken from the name property of the JWT instead of the email property diff --git a/.changeset/funny-donkeys-jam.md b/.changeset/funny-donkeys-jam.md deleted file mode 100644 index 0c45d4b5ff..0000000000 --- a/.changeset/funny-donkeys-jam.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-api': patch -'@backstage/core': patch ---- - -Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error. diff --git a/.changeset/little-starfishes-whisper.md b/.changeset/little-starfishes-whisper.md deleted file mode 100644 index ea00753b4f..0000000000 --- a/.changeset/little-starfishes-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-model': patch ---- - -Added `stringifyEntityRef`, which always creates a string representation of an entity reference. Also deprecated `serializeEntityRef`, as `stringifyEntityRef` should be used instead. diff --git a/.changeset/moody-pigs-repeat.md b/.changeset/moody-pigs-repeat.md new file mode 100644 index 0000000000..396fa80281 --- /dev/null +++ b/.changeset/moody-pigs-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Exported SignInProviderConfig to strongly type SignInPage providers diff --git a/.changeset/neat-lions-peel.md b/.changeset/neat-lions-peel.md deleted file mode 100644 index 1cd50641ec..0000000000 --- a/.changeset/neat-lions-peel.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-api': patch -'@backstage/core': patch ---- - -Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail. diff --git a/.changeset/polite-cameras-sin.md b/.changeset/polite-cameras-sin.md deleted file mode 100644 index b7989be2c6..0000000000 --- a/.changeset/polite-cameras-sin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/test-utils': patch ---- - -Remove unnecessary wrapping of elements rendered by `wrapInTestApp` and `renderInTestApp`, which was breaking mount discovery. diff --git a/.changeset/stale-geese-rule.md b/.changeset/stale-geese-rule.md deleted file mode 100644 index f22ca2983d..0000000000 --- a/.changeset/stale-geese-rule.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-org': patch ---- - -Optimize data fetched for the `OwnershipCard`. diff --git a/.changeset/techdocs-swift-mugs-invent.md b/.changeset/techdocs-swift-mugs-invent.md new file mode 100644 index 0000000000..912089fb3c --- /dev/null +++ b/.changeset/techdocs-swift-mugs-invent.md @@ -0,0 +1,5 @@ +--- +'@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/thirty-panthers-learn.md b/.changeset/thirty-panthers-learn.md deleted file mode 100644 index f0e2c05e81..0000000000 --- a/.changeset/thirty-panthers-learn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-deployments': patch ---- - -Add a button to reload the GitHub Deployments card diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index e339c256a4..226af9bf0a 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -9,9 +9,11 @@ Blackbox Chai Changesets Chanwit +Cloudformation Codecov Codehilite Config +Debounce Discoverability Dockerfile Dockerize @@ -110,7 +112,9 @@ cookiecutter css dataflow deadnaming +debounce declaratively +deps destructured dev devops diff --git a/.tugboat/tugboat.app-config.production.yaml b/.tugboat/tugboat.app-config.production.yaml index f606574d89..b264d53f3f 100644 --- a/.tugboat/tugboat.app-config.production.yaml +++ b/.tugboat/tugboat.app-config.production.yaml @@ -1,13 +1,10 @@ app: title: Backstage Tugboat Preview - baseUrl: - $env: TUGBOAT_DEFAULT_SERVICE_URL + baseUrl: ${TUGBOAT_DEFAULT_SERVICE_URL} backend: - baseUrl: - $env: TUGBOAT_DEFAULT_SERVICE_URL + baseUrl: ${TUGBOAT_DEFAULT_SERVICE_URL} cors: - origin: - $env: TUGBOAT_DEFAULT_SERVICE_URL + origin: ${TUGBOAT_DEFAULT_SERVICE_URL} methods: [GET, POST, PUT, DELETE] credentials: true diff --git a/ADOPTERS.md b/ADOPTERS.md index 218a4edeba..45842b1833 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -24,3 +24,4 @@ | [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | | [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | | [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | diff --git a/app-config.yaml b/app-config.yaml index 465c7b0049..afbda40852 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -40,47 +40,40 @@ proxy: '/circleci/api': target: https://circleci.com/api/v1.1 headers: - Circle-Token: - $env: CIRCLECI_AUTH_TOKEN + Circle-Token: ${CIRCLECI_AUTH_TOKEN} '/jenkins/api': target: http://localhost:8080 headers: - Authorization: - $env: JENKINS_BASIC_AUTH_HEADER + Authorization: ${JENKINS_BASIC_AUTH_HEADER} '/travisci/api': target: https://api.travis-ci.com changeOrigin: true headers: - Authorization: - $env: TRAVISCI_AUTH_TOKEN + Authorization: ${TRAVISCI_AUTH_TOKEN} travis-api-version: '3' '/newrelic/apm/api': target: https://api.newrelic.com/v2 headers: - X-Api-Key: - $env: NEW_RELIC_REST_API_KEY + X-Api-Key: ${NEW_RELIC_REST_API_KEY} '/pagerduty': target: https://api.pagerduty.com headers: - Authorization: - $env: PAGERDUTY_TOKEN + Authorization: ${PAGERDUTY_TOKEN} '/buildkite/api': target: https://api.buildkite.com/v2/ headers: - Authorization: - $env: BUILDKITE_TOKEN + Authorization: ${BUILDKITE_TOKEN} '/sentry/api': target: https://sentry.io/api/ allowedMethods: ['GET'] headers: - Authorization: - $env: SENTRY_TOKEN + Authorization: ${SENTRY_TOKEN} organization: name: My Company @@ -124,32 +117,28 @@ kafka: integrations: github: - host: github.com - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} ### Example for how to add your GitHub Enterprise instance using the API: # - host: ghe.example.net # apiBaseUrl: https://ghe.example.net/api/v3 - # token: - # $env: GHE_TOKEN + # token: ${GHE_TOKEN} ### Example for how to add your GitHub Enterprise instance using raw HTTP fetches (token is optional): # - host: ghe.example.net # rawBaseUrl: https://ghe.example.net/raw - # token: - # $env: GHE_TOKEN + # token: ${GHE_TOKEN} gitlab: - host: gitlab.com - token: - $env: GITLAB_TOKEN + token: ${GITLAB_TOKEN} bitbucket: - host: bitbucket.org - username: - $env: BITBUCKET_USERNAME - appPassword: - $env: BITBUCKET_APP_PASSWORD + username: ${BITBUCKET_USERNAME} + appPassword: ${BITBUCKET_APP_PASSWORD} azure: - host: dev.azure.com - token: - $env: AZURE_TOKEN + token: ${AZURE_TOKEN} +# googleGcs: +# clientEmail: 'example@example.com' +# privateKey: ${GCS_PRIVATE_KEY} catalog: rules: @@ -168,21 +157,18 @@ catalog: githubOrg: providers: - target: https://github.com - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} #### Example for how to add your GitHub Enterprise instance using the API: # - target: https://ghe.example.net # apiBaseUrl: https://ghe.example.net/api - # token: - # $env: GHE_TOKEN + # token: ${GHE_TOKEN} ldapOrg: ### Example for how to add your enterprise LDAP server # providers: # - target: ldaps://ds.example.net # bind: # dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net - # secret: - # $env: LDAP_SECRET + # secret: ${LDAP_SECRET} # users: # dn: ou=people,ou=example,dc=example,dc=net # options: @@ -198,12 +184,9 @@ catalog: #providers: # - target: https://graph.microsoft.com/v1.0 # authority: https://login.microsoftonline.com - # tenantId: - # $env: MICROSOFT_GRAPH_TENANT_ID - # clientId: - # $env: MICROSOFT_GRAPH_CLIENT_ID - # clientSecret: - # $env: MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN + # tenantId: ${MICROSOFT_GRAPH_TENANT_ID} + # clientId: ${MICROSOFT_GRAPH_CLIENT_ID} + # clientSecret: ${MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN} # userFilter: accountEnabled eq true and userType eq 'member' # groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') @@ -251,27 +234,22 @@ catalog: scaffolder: github: - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} visibility: public # or 'internal' or 'private' gitlab: api: baseUrl: https://gitlab.com - token: - $env: GITLAB_TOKEN + token: ${GITLAB_TOKEN} visibility: public # or 'internal' or 'private' azure: baseUrl: https://dev.azure.com/{your-organization} api: - token: - $env: AZURE_TOKEN + token: ${AZURE_TOKEN} bitbucket: api: host: https://bitbucket.org - username: - $env: BITBUCKET_USERNAME - token: - $env: BITBUCKET_TOKEN + username: ${BITBUCKET_USERNAME} + token: ${BITBUCKET_TOKEN} visibility: public # or or 'private' auth: @@ -282,89 +260,59 @@ auth: providers: google: development: - clientId: - $env: AUTH_GOOGLE_CLIENT_ID - clientSecret: - $env: AUTH_GOOGLE_CLIENT_SECRET + clientId: ${AUTH_GOOGLE_CLIENT_ID} + clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} github: development: - clientId: - $env: AUTH_GITHUB_CLIENT_ID - clientSecret: - $env: AUTH_GITHUB_CLIENT_SECRET - enterpriseInstanceUrl: - $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + clientId: ${AUTH_GITHUB_CLIENT_ID} + clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} + enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} gitlab: development: - clientId: - $env: AUTH_GITLAB_CLIENT_ID - clientSecret: - $env: AUTH_GITLAB_CLIENT_SECRET - audience: - $env: GITLAB_BASE_URL + clientId: ${AUTH_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} + audience: ${GITLAB_BASE_URL} saml: entryPoint: 'http://localhost:7001/' issuer: 'passport-saml' okta: development: - clientId: - $env: AUTH_OKTA_CLIENT_ID - clientSecret: - $env: AUTH_OKTA_CLIENT_SECRET - audience: - $env: AUTH_OKTA_AUDIENCE + clientId: ${AUTH_OKTA_CLIENT_ID} + clientSecret: ${AUTH_OKTA_CLIENT_SECRET} + audience: ${AUTH_OKTA_AUDIENCE} oauth2: development: - clientId: - $env: AUTH_OAUTH2_CLIENT_ID - clientSecret: - $env: AUTH_OAUTH2_CLIENT_SECRET - authorizationUrl: - $env: AUTH_OAUTH2_AUTH_URL - tokenUrl: - $env: AUTH_OAUTH2_TOKEN_URL + clientId: ${AUTH_OAUTH2_CLIENT_ID} + clientSecret: ${AUTH_OAUTH2_CLIENT_SECRET} + authorizationUrl: ${AUTH_OAUTH2_AUTH_URL} + tokenUrl: ${AUTH_OAUTH2_TOKEN_URL} ### # provide a list of scopes as needed for your OAuth2 Server: # # scope: saml-login-selector openid profile email oidc: development: - metadataUrl: - $env: AUTH_OIDC_METADATA_URL - clientId: - $env: AUTH_OIDC_CLIENT_ID - clientSecret: - $env: AUTH_OIDC_CLIENT_SECRET - authorizationUrl: - $env: AUTH_OIDC_AUTH_URL - tokenUrl: - $env: AUTH_OIDC_TOKEN_URL - tokenSignedResponseAlg: - $env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG + metadataUrl: ${AUTH_OIDC_METADATA_URL} + clientId: ${AUTH_OIDC_CLIENT_ID} + clientSecret: ${AUTH_OIDC_CLIENT_SECRET} + authorizationUrl: ${AUTH_OIDC_AUTH_URL} + tokenUrl: ${AUTH_OIDC_TOKEN_URL} + tokenSignedResponseAlg: ${AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG} auth0: development: - clientId: - $env: AUTH_AUTH0_CLIENT_ID - clientSecret: - $env: AUTH_AUTH0_CLIENT_SECRET - domain: - $env: AUTH_AUTH0_DOMAIN + clientId: ${AUTH_AUTH0_CLIENT_ID} + clientSecret: ${AUTH_AUTH0_CLIENT_SECRET} + domain: ${AUTH_AUTH0_DOMAIN} microsoft: development: - clientId: - $env: AUTH_MICROSOFT_CLIENT_ID - clientSecret: - $env: AUTH_MICROSOFT_CLIENT_SECRET - tenantId: - $env: AUTH_MICROSOFT_TENANT_ID + clientId: ${AUTH_MICROSOFT_CLIENT_ID} + clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET} + tenantId: ${AUTH_MICROSOFT_TENANT_ID} onelogin: development: - clientId: - $env: AUTH_ONELOGIN_CLIENT_ID - clientSecret: - $env: AUTH_ONELOGIN_CLIENT_SECRET - issuer: - $env: AUTH_ONELOGIN_ISSUER + clientId: ${AUTH_ONELOGIN_CLIENT_ID} + clientSecret: ${AUTH_ONELOGIN_CLIENT_SECRET} + issuer: ${AUTH_ONELOGIN_ISSUER} costInsights: engineerCost: 200000 products: diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index 7b26d2bccd..bd80bc22b6 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -127,68 +127,47 @@ appConfig: development: appOrigin: 'http://localhost:3000/' secure: false - clientId: - $env: AUTH_GOOGLE_CLIENT_ID - clientSecret: - $env: AUTH_GOOGLE_CLIENT_SECRET + clientId: ${AUTH_GOOGLE_CLIENT_ID} + clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} github: development: appOrigin: 'http://localhost:3000/' secure: false - clientId: - $env: AUTH_GITHUB_CLIENT_ID - clientSecret: - $env: AUTH_GITHUB_CLIENT_SECRET - enterpriseInstanceUrl: - $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + clientId: ${AUTH_GITHUB_CLIENT_ID} + clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} + enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} gitlab: development: appOrigin: 'http://localhost:3000/' secure: false - clientId: - $env: AUTH_GITLAB_CLIENT_ID - clientSecret: - $env: AUTH_GITLAB_CLIENT_SECRET - audience: - $env: GITLAB_BASE_URL + clientId: ${AUTH_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} + audience: ${GITLAB_BASE_URL} okta: development: appOrigin: 'http://localhost:3000/' secure: false - clientId: - $env: AUTH_OKTA_CLIENT_ID - clientSecret: - $env: AUTH_OKTA_CLIENT_SECRET - audience: - $env: AUTH_OKTA_AUDIENCE + clientId: ${AUTH_OKTA_CLIENT_ID} + clientSecret: ${AUTH_OKTA_CLIENT_SECRET} + audience: ${AUTH_OKTA_AUDIENCE} oauth2: development: appOrigin: 'http://localhost:3000/' secure: false - clientId: - $env: AUTH_OAUTH2_CLIENT_ID - clientSecret: - $env: AUTH_OAUTH2_CLIENT_SECRET - authorizationURL: - $env: AUTH_OAUTH2_AUTH_URL - tokenURL: - $env: AUTH_OAUTH2_TOKEN_URL + clientId: ${AUTH_OAUTH2_CLIENT_ID} + clientSecret: ${AUTH_OAUTH2_CLIENT_SECRET} + authorizationURL: ${AUTH_OAUTH2_AUTH_URL} + tokenURL: ${AUTH_OAUTH2_TOKEN_URL} auth0: development: - clientId: - $env: AUTH_AUTH0_CLIENT_ID - clientSecret: - $env: AUTH_AUTH0_CLIENT_SECRET - domain: - $env: AUTH_AUTH0_DOMAIN + clientId: ${AUTH_AUTH0_CLIENT_ID} + clientSecret: ${AUTH_AUTH0_CLIENT_SECRET} + domain: ${AUTH_AUTH0_DOMAIN} microsoft: development: - clientId: - $env: AUTH_MICROSOFT_CLIENT_ID - clientSecret: - $env: AUTH_MICROSOFT_CLIENT_SECRET - tenantId: - $env: AUTH_MICROSOFT_TENANT_ID + clientId: ${AUTH_MICROSOFT_CLIENT_ID} + clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET} + tenantId: ${AUTH_MICROSOFT_TENANT_ID} auth: google: diff --git a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md new file mode 100644 index 0000000000..ba0aef89ea --- /dev/null +++ b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md @@ -0,0 +1,170 @@ +# Using AWS Application Load Balancer with Azure Active Directory to authenticate requests + +Backstage allows offloading the responsibility of authenticating users to an AWS Application Load Balancer (**ALB**), leveraging the authentication support on ALB. +This tutorial shows how to use authentication on an ALB sitting in front of Backstage. +Azure Active Directory (**AAD**) is used as identity provider but any identity provider supporting OpenID Connect (OIDC) can be used. + +It is assumed an ALB is already serving traffic in front of a Backstage instance configured to serve the frontend app from the backend. + +## Infrastructure setup + +### AAD App + +The AAD App is used to execute the authentication flow, serve and refresh the identity token. + +Create the AAD App following the steps outlined in `Create a Microsoft App Registration in Microsoft Portal` section from the tutorial [Monorepo App Setup With Authentication][monorepo-app-setup-with-auth]. + +Instead of `localhost` addresses, use the following values. + +- Identifier URI: `https://backstage.yourdomain.com` +- Redirect URI: `https://backstage.yourdomain.com/oauth2/idpresponse` + +`Application (client) Id`, `Directory (tenant) ID` and `client secret`values will be used while configuring the ALB. + +### ALB + +In the AWS console, configure ALB Authentication: + +- Edit the ALB rule used to forward the traffic to Backstage and add a new `Authenticate` action. The action will have higher priority compared to the existing `Forward to`. +- Select `OIDC` under `Authenticate` +- Set `Issuer` to `https://login.microsoftonline.com/{TENANT_ID}/v2.0`, replacing `{TENANT_ID}` with the value of `Directory (tenant) ID` of the AAD App. +- Set `Authorization endpoint` to `https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize`, replacing `{TENANT_ID}` with the value of `Directory (tenant) ID` of the AAD App. +- Set `Token endpoint` to `https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize`, replacing `{TENANT_ID}` with the value of `Directory (tenant) ID` of the AAD App. +- Set `User info endpoint` to `https://graph.microsoft.com/oidc/userinfo` +- Set `Client ID` to the AAD App `Application (client) Id` +- Set `Client secret` to the AAD APP `client secret` + +Use the following advanced settings: + +- `Session cookie name` = `AWSELBAuthSessionCookie` +- `Session timeout` = `604800` seconds +- `Scope` = `openid profile offline_access` +- `Action on unauthenticated request` = `Autenticate (client reattempt)` + +Once you've saved the action, you should see an authentication flow be triggered against AAD when visiting Backstage address at `https://backstage.yourdomain.com`. The flow will not complete successfully as the Backstage app isn't yet configured properly. + +## Backstage changes + +### Frontend + +The Backstage App needs a SignInPage when authentication is required. +When using ALB authentication Backstage will only be loaded once the user has successfully authenticated; we won't need to display a SignIn page, however we will need to create a dummy SignIn component that can refresh the token. + +- edit `packages/app/src/App.tsx` +- import the following two additional definitions from `@backstage/core`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB +- add the following definition just before the app is created (`const app = createApp`): + +```ts +const DummySignInComponent: any = (props: any) => { + try { + const config = useApi(configApiRef); + const shouldAuth = !!config.getOptionalConfig('auth.providers.awsalb'); + if (shouldAuth) { + fetch(`${window.location.origin}/api/auth/awsalb/refresh`) + .then(data => data.json()) + .then(data => { + props.onResult({ + userId: data.backstageIdentity.id, + profile: data.profile, + }); + }); + } else { + props.onResult({ + userId: 'guest', + profile: { + email: 'guest@example.com', + displayName: 'Guest', + picture: '', + }, + }); + } + return
; + } catch (err) { + return
{err.message}
; + } +}; +``` + +- add `DummySignInComponent` as `SignInPage`: + +```ts +const app = createApp({ + ... + components: { + SignInPage: DummySignInComponent, + ... + }, + ... +}); +``` + +### Backend + +When using ALB auth it is not possible to leverage the built-in auth config discovery mechanism implemented in the app created by default; bespoke logic needs to be implemented. + +- replace the content of `packages/backend/plugin/auth.ts` with the below + +```ts +import { + createRouter, + AuthResponse, + AuthProviderFactoryOptions, + defaultAuthProviderFactories, +} from '@backstage/plugin-auth-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + database, + config, + discovery, +}: PluginEnvironment) { + const identityResolver = (payload: any): Promise> => { + return Promise.resolve({ + providerInfo: {}, + profile: { + email: payload.email, + displayName: payload.name, + picture: payload.picture, + }, + backstageIdentity: { + id: payload.email, + }, + }); + }; + const providerFactories = { + awsalb: (options: AuthProviderFactoryOptions) => + defaultAuthProviderFactories.awsalb({ ...options, identityResolver }), + }; + return await createRouter({ + logger, + config, + database, + discovery, + providerFactories, + }); +} +``` + +### Configuration + +Use the following `auth` configuration when running Backstage on AWS: + +```yaml +auth: + providers: + awsalb: + issuer: + issuer: https://login.microsoftonline.com/{TENANT_ID}/v2.0 + region: { AWS_REGION } +``` + +Replace `{TENANT_ID}` with the value of `Directory (tenant) ID` of the AAD App and `{AWS_REGION}` with the AWS region identifier where the ALB is deployed (for example: `eu-central-1`). + +## Conclusion + +Once it's deployed, after going through the AAD authentication flow, Backstage should display the AAD user details. + + + +[monorepo-app-setup-with-auth-ms]: https://backstage.io/docs/tutorials/quickstart-app-auth#the-auth-configuration diff --git a/contrib/docs/tutorials/aws-deployment.md b/contrib/docs/tutorials/aws-deployment.md new file mode 100644 index 0000000000..5f94672b82 --- /dev/null +++ b/contrib/docs/tutorials/aws-deployment.md @@ -0,0 +1,195 @@ +# Deploying Backstage on AWS using ECR and EKS + +Backstage documentation shows how to build a [Docker +image](https://backstage.io/docs/getting-started/deployment-docker); this +tutorial shows how to deploy that Docker image to AWS using Elastic Container +Registry (ECR) and Elastic Kubernetes Service (EKS). Amazon also supports +deployments with Helm, covered in the [Helm +Kubernetes](../kubernetes/basic_kubernetes_example_with_helm) example. + +The basic workflow for this method is to build a Backstage Docker image, upload +the new version to a container registry, and update a Kubernetes deployment with +the new image. + +## Create a container registry + +To create an Elastic Container Registry on AWS, go to the [AWS ECR +console](https://console.aws.amazon.com/ecr/repositories). + +Click `Create repository` and give the repository a name, like `backstage`. + +## Add an ECR IAM user + +To push to this new ECR repository, there must be an IAM user with +`AmazonEC2ContainerRegistry` permission. + +Go to [AWS IAM console](https://console.aws.amazon.com/iam/home) and select +`Users`. + +1. Click `Add User` +2. Set the username to something like `ecr-publisher` and select `Programmatic access` only. +3. For `Permissions`, you can either create a new group or attach policies + directly to the user with `AmazonEC2ContainerRegistryFullAccess`. +4. Finish creating the user; an access key ID and secret access key will be + generated. + +## Publish a Backstage build + +Follow the [Docker +image](https://backstage.io/docs/getting-started/deployment-docker) +documentation to build a new Backstage Docker image: + +```shell +$ yarn build +$ docker image build . -f packages/backend/Dockerfile --tag backstage +``` + +This command builds a backend-only image, but you can similarly build a frontend +or combined Docker image. + +Next, configure the [AWS CLI](https://aws.amazon.com/cli/) to use the +`ecr-publisher` user you created: + +```shell +$ aws configure +AWS Access Key ID: +AWS Secret Access Key: +Default region name: +``` + +Now you can use the AWS CLI to push the built image to the ECR repository. It's +a good practice to use a specific version tag as well as `latest` when pushing; +more about [Semver tagging +here](https://medium.com/@mccode/using-semantic-versioning-for-docker-image-tags-dfde8be06699). + +Go to the [AWS ECR console](https://console.aws.amazon.com/ecr/repositories) and +click on your repository, then `View push commands`. This will show the +repository URL to push and some sample commands. + +```shell +# Copy from push command window +$ aws ecr get-login-password --region | docker login --username AWS --password-stdin +Login Succeeded + +$ docker tag backstage:latest /backstage:latest +$ docker push /backstage:latest + +$ docker tag backstage:latest /backstage:1.0.0 +$ docker push /backstage:1.0.0 +``` + +These will take a few minutes to upload the Docker images, then you can see +the image with `latest, 1.0.0` tags in the ECR repository. Now you can create a +Kubernetes deployment based on this image. + +## Deploy to an EKS cluster + +Creating an Elastic Kubernetes Service (EKS) cluster is beyond the scope of this +document, but it can be as easy as `eksctl create cluster` documented in the +[AWS EKS getting started +guide](https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html), +which uses a Cloudformation template to create the necessary resources. + +To deploy the Docker image to EKS, create a `kubernetes` folder in your +Backstage source folder and add a Kubernetes `deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage-backend + labels: + app: backstage-backend + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: backstage-backend + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + labels: + app: backstage-backend + spec: + containers: + - image: /backstage:1.0.0 + imagePullPolicy: Always + name: backstage-backend + ports: + - containerPort: 7000 + protocol: TCP +``` + +Note the `image` key in the container spec referencing the ECR repository. + +Now create a simple `service.yaml` to map the container ports: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: backstage-backend +spec: + selector: + app: backstage-backend + ports: + - protocol: TCP + port: 80 + targetPort: 7000 +``` + +Apply these Kubernetes definitions to the EKS cluster to complete the Backstage +deployment: + +```shell +$ kubectl apply -f deployment.yaml +$ kubectl apply -f service.yaml +``` + +Now you can see your Backstage workload running from the [EKS +console](https://console.aws.amazon.com/eks/home). + +## Further steps + +### Exposing Backstage with a load balancer + +Backstage users need to query the backend, which means we need to expose +the workload with a load balancer. Follow the [Application load balancing on +EKS](https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html) guide to +set up a Load Balancer controller and Kubernetes ingress to your application. + +This is ultimately a `kubectl apply` with an ingress definition: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + namespace: default + name: backstage-ingress + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: backstage-backend + port: + number: 80 +``` + +### Updating the deployment + +To update the Kubernetes deployment to a newly published version of your +Backstage Docker image, update the image tag reference in `deployment.yaml` and +then apply the changes to EKS with `kubectl apply -f deployment.yaml`. diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index a99ca119f0..5b5950f6c3 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -95,40 +95,27 @@ auth: providers: google: development: - clientId: - $env: AUTH_GOOGLE_CLIENT_ID - clientSecret: - $env: AUTH_GOOGLE_CLIENT_SECRET + clientId: ${AUTH_GOOGLE_CLIENT_ID} + clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} github: development: - clientId: - $env: AUTH_GITHUB_CLIENT_ID - clientSecret: - $env: AUTH_GITHUB_CLIENT_SECRET - enterpriseInstanceUrl: - $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + clientId: ${AUTH_GITHUB_CLIENT_ID} + clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} + enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} gitlab: development: - clientId: - $env: + clientId: ${AUTH_GITLAB_CLIENT_ID} oauth2: development: - clientId: - $env: AUTH_OAUTH2_CLIENT_ID - clientSecret: - $env: AUTH_OAUTH2_CLIENT_SECRET - authorizationUrl: - $env: AUTH_OAUTH2_AUTH_URL - tokenUrl: - $env: AUTH_OAUTH2_TOKEN_URL - scope: - $env: AUTH_OAUTH2_SCOPE + clientId: ${AUTH_OAUTH2_CLIENT_ID} + clientSecret: ${AUTH_OAUTH2_CLIENT_SECRET} + authorizationUrl: ${AUTH_OAUTH2_AUTH_URL} + tokenUrl: ${AUTH_OAUTH2_TOKEN_URL} + scope: ${AUTH_OAUTH2_SCOPE} saml: - entryPoint: - $env: AUTH_SAML_ENTRY_POINT - issuer: - $env: AUTH_SAML_ISSUER - ... + entryPoint: ${AUTH_SAML_ENTRY_POINT} + issuer: ${AUTH_SAML_ISSUER} + ... ``` ## Implementing Your Own Auth Wrapper diff --git a/docs/auth/auth0/provider.md b/docs/auth/auth0/provider.md index 383cda2014..b546285c94 100644 --- a/docs/auth/auth0/provider.md +++ b/docs/auth/auth0/provider.md @@ -46,29 +46,5 @@ The Auth0 provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `auth0AuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { auth0AuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const auth0Provider: SignInConfig = { -+ id: 'auth0-auth-provider', -+ title: 'Auth0', -+ message: 'Sign in using Auth0', -+ apiRef: auth0AuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index 6e194f36f7..4b65c8c3c2 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -49,29 +49,5 @@ The GitHub provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `githubAuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { githubAuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const githubProvider: SignInConfig = { -+ id: 'github-auth-provider', -+ title: 'GitHub', -+ message: 'Sign in using GitHub', -+ apiRef: githubAuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index e9cfdfa98e..8e9c499dc4 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -48,29 +48,5 @@ The GitLab provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `gitlabAuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { gitlabAuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const gitlabProvider: SignInConfig = { -+ id: 'gitlab-auth-provider', -+ title: 'GitLab', -+ message: 'Sign in using GitLab', -+ apiRef: gitlabAuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md index 2765b509f3..457224b2db 100644 --- a/docs/auth/google/provider.md +++ b/docs/auth/google/provider.md @@ -53,29 +53,5 @@ The Google provider is a structure with two configuration keys: ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `googleAuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { googleAuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const googleProvider: SignInConfig = { -+ id: 'google-auth-provider', -+ title: 'Google', -+ message: 'Sign in using Google', -+ apiRef: googleAuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/index.md b/docs/auth/index.md index a8ddf17c6b..f3783c79f3 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -1,99 +1,120 @@ --- id: index -title: User Authentication and Authorization in Backstage -description: Documentation on User Authentication and Authorization in Backstage +title: Adding Authentication +description: How to add authentication to a Backstage application --- -## Summary +Authentication in Backstage identifies the user, and provides a way for plugins +to make requests on behalf of a user to third-party services. Backstage can have +zero (guest access), one, or many authentication providers. The default +`@backstage/create-app` template uses guest access for easy startup. -The purpose of the Auth APIs in Backstage is to identify the user, and to -provide a way for plugins to request access to 3rd party services on behalf of -the user (OAuth). This documentation focuses on the implementation of that -solution and how to extend it. For documentation on how to consume the Auth APIs -in a plugin, see [TODO](#TODO). +See [Using authentication and identity](using-auth.md) for tips on using +Backstage identity information in your app or plugins. -### Accessing Third Party Services +## Adding an authentication provider -The main pattern for talking to third party services in Backstage is -user-to-server requests, where short-lived OAuth Access Tokens are requested by -plugins to authenticate calls to external services. These calls can be made -either directly to the services or through a backend plugin or service. +Backstage comes with many common authentication providers in the core library: -By relying on user-to-server calls we keep the coupling between the frontend and -backend low, and provide a much lower barrier for plugins to make use of third -party services. This is in comparison to for example a session-based system, -where access tokens are stored server-side. Such a solution would require a much -deeper coupling between the auth backend plugin, its session storage, and other -backend plugins or separate services. A goal of Backstage is to make it as easy -as possible to create new plugins, and an auth solution based on user-to-server -OAuth helps in that regard. +- [Auth0](auth0/provider.md) +- [Azure](microsoft/provider.md) +- [GitHub](github/provider.md) +- [GitLab](gitlab/provider.md) +- [Google](google/provider.md) +- [Okta](okta/provider.md) +- [OneLogin](onelogin/provider.md) -The method with which frontend plugins request access to third party services is -through [Utility APIs](../api/utility-apis.md) for each service provider. For a -full list of providers, see the -[Utility API References](../reference/utility-apis/README.md). +These built-in providers handle the authentication flow for a particular service +including required scopes, callbacks, etc. These providers are each added to a +Backstage app in a similar way. -### Identity - WIP +### Adding provider configuration -> NOTE: Identity management and the `SignInPage` in Backstage is NOT a method -> for blocking access for unauthorized users, that either requires additional -> backend implementation or a separate service like Google's Identity-Aware -> Proxy. The identity system only serves to provide a personalized experience -> and access to a Backstage Identity Token, which can be passed to backend -> plugins. +Each built-in provider has a configuration block under the `auth` section of +`app-config.yaml`. For example, the GitHub provider: -Identity management is still work in progress, but there are already a couple of -pieces in place that can be used. +```yaml +auth: + environment: development + providers: + github: + development: + clientId: ${AUTH_GITHUB_CLIENT_ID} + clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} +``` -#### Identity for Plugin Developers +See the documentation for a particular provider to see what configuration is +needed. -As a plugin developer, there are two main touchpoints for identities: the -`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not -yet existing middleware exported by `@backstage/backend-common`. +The `providers` key may have several authentication providers, if multiple +authentication methods are supported. Each provider may also have configuration +for different authentication environments (development, production, etc). This +allows a single auth backend to serve multiple environments, such as running a +local frontend against a deployed backend. The provider configuration matching +the local `auth.environment` setting will be selected. -The `IdentityApi` gives access to the signed-in user's identity in the frontend. -It provides access to the user's ID, lightweight profile information, and an ID -token used to make authenticated calls within Backstage. +### Adding the provider to the sign-in page -The middleware that will be provided by `@backstage/backend-common` allows -verification of Backstage ID tokens, and optionally loading additional -information about the user. The progress is tracked in -https://github.com/backstage/backstage/issues/1435. +After configuring an authentication provider, the `app` frontend package needs a +small update to show this provider as a login option. The `SignInPage` component +handles this, and takes either a `provider` or `providers` (array) prop of +`SignInProviderConfig` definitions. -#### Identity for App Developers +These reference the [ApiRef](../reference/utility-apis/README.md) exported by +the provider. Again, an example using GitHub that can be adapted to any of the +built-in providers: -If you're setting up your own Backstage app, or want to add a new identity -provider, there are three touchpoints: the frontend auth APIs in -`@backstage/core-api`, the backend auth providers in `auth-backend`, and the -`SignInPage` component configured in the Backstage app via `createApp`. +```diff +# packages/app/src/App.tsx ++ import { githubAuthApiRef, SignInProviderConfig, SignInPage } from '@backstage/core'; -The frontend APIs and backend providers are tightly coupled together for each -auth provider, and together they implement an e2e auth flow. Only some auth -providers also act as identity providers though. For example, at the moment of -writing, the Google Auth provider is able to act as a Backstage identity -provider, but the GitHub one can not. For an auth provider to also act as an -identity provider, it needs to implement the `BackstageIdentityApi` in the -frontend, and in the backend it needs to return a `BackstageIdentity` structure. ++ const githubProvider: SignInProviderConfig = { ++ id: 'github-auth-provider', ++ title: 'GitHub', ++ message: 'Sign in using GitHub', ++ apiRef: githubAuthApiRef, ++}; ++ +const app = createApp({ + apis, + plugins: Object.values(plugins), ++ components: { ++ SignInPage: props => ( ++ ++ ), ++ }, + bindRoutes({ bind }) { +``` -It is up to each provider to implement the mapping between a provider identity -and the corresponding Backstage identity. That is currently still work in -progress, and as a stop-gap for example the Google provider returns the local -part of the user's email as the user ID. +To also allow unauthenticated guest access, use the `providers` prop for +`SignInPage`: -The final piece of the puzzle is the `SignInPage` component that can be -configured as part of the app. Without a sign-in page, Backstage will fall back -to a `guest` identity for all users, without any ID token. To enable sign-in, a -`SignInPage` needs to be configured, which in turn has to supply a user to the -app. The `@backstage/core` package provides a basic sign-in page that allows -both the user and the app developer to choose between a couple of different -sign-in methods, or to designate a single provider that may also be logged in to -automatically. +```diff +const app = createApp({ + apis, + plugins: Object.values(plugins), ++ components: { ++ SignInPage: props => ( ++ ++ ), ++ }, + bindRoutes({ bind }) { +``` -## Further Reading +## Adding a custom authentication provider -More details are provided in dedicated sections of the documentation. +There are generic authentication providers for OAuth2 and SAML. These can reduce +the amount of code needed to implement a custom authentication provider that +adheres to these standards. -- [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the - [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend). -- [Glossary](./glossary.md): Glossary of some common terms related to the auth - flows. +Backstage uses [Passport](http://www.passportjs.org/) under the hood, which has +a wide library of authentication strategies for different providers. See +[Add authentication provider](add-auth-provider.md) for details on adding a new +Passport-supported authentication method. diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 3aa9de0df1..b64f9b1902 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -49,29 +49,5 @@ The Microsoft provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `microsoftAuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { microsoftAuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const microsoftProvider: SignInConfig = { -+ id: 'microsoft-auth-provider', -+ title: 'Microsoft Azure', -+ message: 'Sign in using Azure', -+ apiRef: microsoftAuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index 091c5fffb6..da54a30add 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -62,29 +62,5 @@ The values referenced are found on the Application page on your Okta site. ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `oktaAuthApi` reference and -`SignInPage` component to `createApp` in `packages/app/src/App.tsx`: - -```diff -+ import { oktaAuthApiRef, SignInConfig, SignInPage } from '@backstage/core'; - -+ const oktaProvider: SignInConfig = { -+ id: 'okta-auth-provider', -+ title: 'Okta', -+ message: 'Sign in using Okta', -+ apiRef: oktaAuthApiRef, -+}; -+ -const app = createApp({ - apis, - plugins: Object.values(plugins), -+ components: { -+ SignInPage: props => ( -+ -+ ), -+ }, - bindRoutes({ bind }) { -``` +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md new file mode 100644 index 0000000000..62c1657fea --- /dev/null +++ b/docs/auth/onelogin/provider.md @@ -0,0 +1,54 @@ +--- +id: provider +title: OneLogin Authentication Provider +sidebar_label: OneLogin +description: Adding OneLogin OIDC as an authentication provider in Backstage +--- + +The Backstage `core-api` package comes with a OneLogin authentication provider +that can authenticate users using OpenID Connect. + +## Create an Application on OneLogin + +To support OneLogin authentication, you must create an Application: + +1. From the OneLogin Admin portal, choose Applications +2. Click `Add App` and select `OpenID Connect` + - Display Name: Backstage (or your custom app name) +3. Click Save +4. Go to the Configuration tab for the Application and set: + - `Login Url`: `http://localhost:3000` + - `Redirect URIs`: `http://localhost:7000/api/auth/onelogin/handler/frame` +5. Click Save +6. Go to the SSO tab for the Application and set: + - `Token Endpoint` > `Authentication Method`: `POST` +7. Click Save + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the +root `auth` configuration: + +```yaml +auth: + environment: development + providers: + onelogin: + development: + clientId: ${AUTH_ONELOGIN_CLIENT_ID} + clientSecret: ${AUTH_ONELOGIN_CLIENT_SECRET} + issuer: https://.onelogin.com/oidc/2 +``` + +The OneLogin provider is a structure with three configuration keys; **these are +found on the SSO tab** for the OneLogin Application: + +- `clientId`: The client ID +- `clientSecret`: The client secret +- `issuer`: The issuer URL + +## Adding the provider to the Backstage frontend + +To add the provider to the frontend, add the `oneloginAuthApi` reference and +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/using-auth.md b/docs/auth/using-auth.md new file mode 100644 index 0000000000..fbc6017272 --- /dev/null +++ b/docs/auth/using-auth.md @@ -0,0 +1,96 @@ +--- +id: using-auth +title: Using authentication and identity +description: How to use authentication and identity in Backstage +--- + +The Auth APIs in Backstage identify the user, and provide a way for plugins to +request access to 3rd party services on behalf of the user (OAuth). This +documentation focuses on the implementation of that solution and how to extend +it. + +### Accessing Third Party Services + +The main pattern for talking to third party services in Backstage is +user-to-server requests, where short-lived OAuth Access Tokens are requested by +plugins to authenticate calls to external services. These calls can be made +either directly to the services or through a backend plugin or service. + +By relying on user-to-server calls we keep the coupling between the frontend and +backend low, and provide a much lower barrier for plugins to make use of third +party services. This is in comparison to for example a session-based system, +where access tokens are stored server-side. Such a solution would require a much +deeper coupling between the auth backend plugin, its session storage, and other +backend plugins or separate services. A goal of Backstage is to make it as easy +as possible to create new plugins, and an auth solution based on user-to-server +OAuth helps in that regard. + +The method with which frontend plugins request access to third party services is +through [Utility APIs](../api/utility-apis.md) for each service provider. For a +full list of providers, see the +[Utility API References](../reference/utility-apis/README.md). + +### Identity - WIP + +> NOTE: Identity management and the `SignInPage` in Backstage is NOT a method +> for blocking access for unauthorized users, that either requires additional +> backend implementation or a separate service like Google's Identity-Aware +> Proxy. The identity system only serves to provide a personalized experience +> and access to a Backstage Identity Token, which can be passed to backend +> plugins. + +Identity management is still work in progress, but there are already a couple of +pieces in place that can be used. + +#### Identity for Plugin Developers + +As a plugin developer, there are two main touchpoints for identities: the +`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not +yet existing middleware exported by `@backstage/backend-common`. + +The `IdentityApi` gives access to the signed-in user's identity in the frontend. +It provides access to the user's ID, lightweight profile information, and an ID +token used to make authenticated calls within Backstage. + +The middleware that will be provided by `@backstage/backend-common` allows +verification of Backstage ID tokens, and optionally loading additional +information about the user. The progress is tracked in +https://github.com/backstage/backstage/issues/1435. + +#### Identity for App Developers + +If you're setting up your own Backstage app, or want to add a new identity +provider, there are three touchpoints: the frontend auth APIs in +`@backstage/core-api`, the backend auth providers in `auth-backend`, and the +`SignInPage` component configured in the Backstage app via `createApp`. + +The frontend APIs and backend providers are tightly coupled together for each +auth provider, and together they implement an e2e auth flow. Only some auth +providers also act as identity providers though. For example, at the moment of +writing, the Google Auth provider is able to act as a Backstage identity +provider, but the GitHub one can not. For an auth provider to also act as an +identity provider, it needs to implement the `BackstageIdentityApi` in the +frontend, and in the backend it needs to return a `BackstageIdentity` structure. + +It is up to each provider to implement the mapping between a provider identity +and the corresponding Backstage identity. That is currently still work in +progress, and as a stop-gap for example the Google provider returns the local +part of the user's email as the user ID. + +The final piece of the puzzle is the `SignInPage` component that can be +configured as part of the app. Without a sign-in page, Backstage will fall back +to a `guest` identity for all users, without any ID token. To enable sign-in, a +`SignInPage` needs to be configured, which in turn has to supply a user to the +app. The `@backstage/core` package provides a basic sign-in page that allows +both the user and the app developer to choose between a couple of different +sign-in methods, or to designate a single provider that may also be logged in to +automatically. + +## Further Reading + +More details are provided in dedicated sections of the documentation. + +- [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the + [auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend). +- [Glossary](./glossary.md): Glossary of some common terms related to the auth + flows. diff --git a/docs/cli/commands.md b/docs/cli/commands.md index ca89a43a6a..33693b010f 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -54,6 +54,8 @@ versions:check Check Backstage package versioning prepack Prepares a package for packaging before publishing postpack Restores the changes made by the prepack command +create-github-app Create new GitHub App in your organization (experimental) + help [command] display help for command ``` @@ -190,10 +192,13 @@ output of `backstage-cli backend:bundle` into an image: FROM node:14-buster-slim WORKDIR /app -ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +COPY yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz + RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)" -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"] ``` @@ -606,3 +611,18 @@ the resulting archive in the target `workspace-dir`. ```text Usage: backstage-cli build-workspace [options] <workspace-dir> ``` + +## create-github-app + +Scope: `root` + +Creates a GitHub App in your GitHub organization. This is an alternative to +token-based [GitHub integration](../integrations/github/locations.md). See +[GitHub Apps for Backstage Authentication](../plugins/github-apps.md). + +Launches a browser to create the App through GitHub and saves the result as a +YAML file that can be referenced in the GitHub integration configuration. + +```text +Usage: backstage-cli create-github-app <github-org> +``` diff --git a/docs/conf/writing.md b/docs/conf/writing.md index ac8509db0a..c7b3bfe296 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -129,6 +129,9 @@ variable. $env: MY_SECRET ``` +Note however, that it's often more convenient to use +[environment variable substitution](#environment-variable-substitution) instead. + ### File Includes This reads a string value from the entire contents of a text file. The file path diff --git a/docs/getting-started/deployment-docker.md b/docs/deployment/docker.md similarity index 95% rename from docs/getting-started/deployment-docker.md rename to docs/deployment/docker.md index 70963df3d3..ef178b2977 100644 --- a/docs/getting-started/deployment-docker.md +++ b/docs/deployment/docker.md @@ -1,14 +1,15 @@ --- -id: deployment-docker -title: Docker -description: Documentation on how to deploy Backstage as a Docker image +id: docker +title: Building a Docker image +sidebar_label: Docker +description: How to build a Backstage Docker image for deployment --- This section describes how to build a Backstage App into a deployable Docker image. It is split into three sections, first covering the host build approach, which is recommended due its speed and more efficient and often simpler caching. The second section covers a full multi-stage Docker build, and the last section -covers how to split frontend content into a separate image. +covers how to deploy the frontend and backend as separate images. Something that goes for all of these docker deployment strategies is that they are stateless, so for a production deployment you will want to set up and @@ -20,10 +21,10 @@ bundled and served from the backend. This is done using the `@backstage/plugin-app-backend` plugin, which also injects the frontend configuration into the app. This means you that you only need to build and deploy a single container in a minimal setup of Backstage. If you wish to -separate the serving of the frontend out from the backend, see -[the section on that topic below](#separate-frontend). +separate the serving of the frontend out from the backend, see the +[separate frontend](#separate-frontend) topic below. -### Host Build +## Host Build This section describes how to build a Docker image from a Backstage repo with most of the build happening outside of Docker. This is almost always the faster @@ -109,7 +110,7 @@ docker run -it -p 7000:7000 backstage You should then start to get logs in your terminal, and then you can open your browser at `http://localhost:7000` -### Multistage Build +## Multi-stage Build This section describes how to set up a multi-stage Docker build that builds the entire project within Docker. This is typically slower than a host build, but is @@ -207,7 +208,7 @@ docker run -it -p 7000:7000 backstage You should then start to get logs in your terminal, and then you can open your browser at `http://localhost:7000` -### Separate Frontend +## Separate Frontend It is sometimes desirable to serve the frontend separately from the backend, either from a separate image or for example a static file serving provider. The diff --git a/docs/getting-started/deployment-helm.md b/docs/deployment/helm.md similarity index 92% rename from docs/getting-started/deployment-helm.md rename to docs/deployment/helm.md index d1a52a5b30..fd36aeb81f 100644 --- a/docs/getting-started/deployment-helm.md +++ b/docs/deployment/helm.md @@ -1,8 +1,8 @@ --- -id: deployment-helm -title: Helm -description: Documentation on Kubernetes and Helm Deployment -sidebar_label: Kubernetes and Helm +id: helm +title: Deploying Backstage with Helm +description: How to deploy Backstage with Helm and Kubernetes +sidebar_label: Helm --- # Helm charts diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md new file mode 100644 index 0000000000..42ab817b30 --- /dev/null +++ b/docs/deployment/heroku.md @@ -0,0 +1,44 @@ +--- +id: heroku +title: Deploying with Heroku +sidebar_label: Heroku +description: How to deploy Backstage to Heroku +--- + +Heroku is a Platform as a Service (PaaS) designed to handle application +deployment in a hands-off way. Heroku supports container deployment of Docker +images, a natural fit for Backstage. + +## Configuring the CLI + +First, install the +[heroku-cli](https://devcenter.heroku.com/articles/heroku-cli) and login: + +```shell +$ heroku login +``` + +Heroku runs a container registry on `registry.heroku.com`. To push Backstage +Docker images, log in to the container registry also: + +```shell +$ heroku container:login +``` + +You _might_ also need to set your Heroku app's stack to `container`: + +```bash +$ heroku stack:set container -a +``` + +## Push and deploy a Docker image + +Now we can push a Backstage [Docker image](docker.md) to Heroku's container +registry and release it to the `web` worker: + +```bash +$ heroku container:push web -a +$ heroku container:release web -a +``` + +Now you should have Backstage up and running! 🎉 diff --git a/docs/deployment/index.md b/docs/deployment/index.md new file mode 100644 index 0000000000..db4ceca6f1 --- /dev/null +++ b/docs/deployment/index.md @@ -0,0 +1,40 @@ +--- +id: index +title: Deploying Backstage +sidebar_title: Overview +description: Packaging Backstage and deploying to production +--- + +Backstage provides tooling to build Docker images, but can be deployed with or +without Docker on many different infrastructures. The _best_ way to deploy +Backstage is in _the same way_ you deploy other software at your organization. + +This documentation shows common examples that may be useful when deploying +Backstage for the first time, or for those without established deployment +practices. + +> Note: The _easiest_ way to explore Backstage is to visit the +> [live demo site](https://demo.backstage.io). + +At Spotify, we deploy software generally by: + +1. Building a Docker image +2. Storing the Docker image on a container registry +3. Referencing the image in a Kubernetes Deployment YAML +4. Applying that Deployment to a Kubernetes cluster + +This method is covered in [Building a Docker image](docker.md) and +[Deploying with Kubernetes](k8s.md). + +There is also an example of deploying on [Heroku](heroku.md), which only +requires the first two steps. + +An example of deploying Backstage with a [Helm chart](helm.md), a common pattern +in AWS, is also available. + +Please consider contributing other deployment guides if you get Backstage set up +on common infrastructure, it would be a great benefit to the community. + +If you need to run Backstage behind a corporate proxy, this +[contributed guide](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md) +may help. diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md new file mode 100644 index 0000000000..9dbf447740 --- /dev/null +++ b/docs/deployment/k8s.md @@ -0,0 +1,519 @@ +--- +id: k8s +title: Deploying with Kubernetes +sidebar_label: Kubernetes +description: How to deploy Backstage to a Kubernetes cluster +--- + +[Kubernetes](https://kubernetes.io/) is a system for deploying, scaling and +managing containerized applications. Backstage is designed to fit this model and +run as a stateless application with an external PostgreSQL database. + +There are many different tools and patterns for Kubernetes clusters, so the best +way to deploy to an existing Kubernetes setup is _the same way_ you deploy +everything else. + +This guide covers basic Kubernetes definitions needed to get Backstage up and +running in a typical cluster. The object definitions might look familiar, since +the Backstage software catalog +[also uses](../features/software-catalog/descriptor-format.md) the Kubernetes +object format for its entity definition files! + +## Testing locally + +To test out these concepts locally before deploying to a production Kubernetes +cluster, first install [kubectl](https://kubernetes.io/docs/tasks/tools/), the +Kubernetes command-line tool. + +Next, install [minikube](https://minikube.sigs.k8s.io/docs/start/). This creates +a single-node Kubernetes cluster on your local machine: + +```shell +# Assumes Mac + Homebrew; see the minikube site for other installations +$ brew install minikube +$ minikube start + +... +Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default. +``` + +Now you can run `kubectl` commands and have changes applied to the minikube +cluster. You should be able to see the `kube-system` Kubernetes pods running: + +```shell +$ kubectl get pods -A +``` + +When you're done with the tutorial, use `minikube stop` to halt the cluster and +free up resources. + +## Creating a namespace + +Deployments in Kubernetes are commonly assigned to their own +[namespace](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) +to isolate services in a multi-tenant environment. + +This can be done through `kubectl` directly: + +```shell +$ kubectl create namespace backstage +namespace/backstage created +``` + +Alternatively, create and apply a Namespace definition: + +```yaml +# kubernetes/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: backstage +``` + +```shell +$ kubectl apply -f kubernetes/namespace.yaml +namespace/backstage created +``` + +## Creating the PostgreSQL database + +Backstage in production uses PostgreSQL as a database. To isolate the database +from Backstage app deployments, we can create a separate Kubernetes deployment +for PostgreSQL. + +### Creating a PostgreSQL secret + +First, create a Kubernetes Secret for the PostgreSQL username and password. This +will be used by both the PostgreSQL database and Backstage deployments: + +```yaml +# kubernetes/postgres-secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secrets + namespace: backstage +type: Opaque +data: + POSTGRES_USER: YmFja3N0YWdl + POSTGRES_PASSWORD: aHVudGVyMg== +``` + +The data in Kubernetes secrets are base64-encoded. The values can be generated +on the command line: + +```shell +$ echo -n "backstage" | base64 +YmFja3N0YWdl +``` + +> Note: Secrets are base64-encoded, but not encrypted. Be sure to enable +> [Encryption at Rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) +> for the cluster. For storing secrets in Git, consider +> [SealedSecrets or other solutions](https://learnk8s.io/kubernetes-secrets-in-git). + +The secrets can now be applied to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/postgres-secrets.yaml +secret/postgres-secrets created +``` + +### Creating a PostgreSQL persistent volume + +PostgreSQL needs a persistent volume to store data; we'll create one along with +a `PersistentVolumeClaim`. In this case, we're claiming the whole volume - but +claims can ask for only part of a volume as well. + +```yaml +# kubernetes/postgres-storage.yaml +apiVersion: v1 +kind: PersistentVolume +metadata: + name: postgres-storage + namespace: backstage + labels: + type: local +spec: + storageClassName: manual + capacity: + storage: 2G + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + hostPath: + path: '/mnt/data' +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-storage-claim + namespace: backstage +spec: + storageClassName: manual + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2G +``` + +This file contains definitions for two different kinds, separated by a line with +a triple dash. This syntax is helpful if you want to consolidate related +Kubernetes definitions in a single file and apply them at the same time. + +Note the volume `type: local`; this creates a volume using local disk on +Kubernetes nodes. More likely in a production scenario, you'd want to use a more +highly available +[type of PersistentVolume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#types-of-persistent-volumes). + +Apply the storage volume and claim to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/postgres-storage.yaml +persistentvolume/postgres-storage created +persistentvolumeclaim/postgres-storage-claim created +``` + +### Creating a PostgreSQL deployment + +Now we can create a Kubernetes Deployment descriptor for the PostgreSQL database +deployment itself: + +```yaml +# kubernetes/postgres.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: backstage +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:13.2-alpine + imagePullPolicy: 'IfNotPresent' + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: postgres-secrets + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgresdb + volumes: + - name: postgresdb + persistentVolumeClaim: + claimName: postgres-storage-claim +``` + +If you're not used to Kubernetes, this is a lot to take in. We're describing a +Deployment (one or more instances of an application) that we'd like Kubernetes +to know about in the `metadata` block. + +The `spec` block describes the desired state. Here we've requested Kubernetes +create 1 replica (running instance of PostgreSQL), and to create the replica +with the given pod `template`, which again contains Kubernetes metadata and a +desired state. The template `spec` shows one container, created from the +[published](https://hub.docker.com/_/postgres) `postgres:13.2-alpine` Docker +image. + +Note the `envFrom` and `secretRef` - this tells Kubernetes to fill environment +variables in the container with values from the Secret we created. We've also +referenced the volume created for the deployment, and given it the mount path +expected by PostgreSQL. + +Apply the PostgreSQL deployment to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/postgres.yaml +deployment.apps/postgres created + +$ kubectl get pods --namespace=backstage +NAME READY STATUS RESTARTS AGE +postgres-56c86b8bbc-66pt2 1/1 Running 0 21s +``` + +Verify the deployment by connecting to the pod: + +```shell +$ kubectl exec -it --namespace=backstage postgres-56c86b8bbc-66pt2 -- /bin/bash +bash-5.1# psql -U $POSTGRES_USER +psql (13.2) +backstage=# \q +bash-5.1# exit +``` + +### Creating a PostgreSQL service + +The database pod is running, but how does another pod connect to it? + +Kubernetes pods are transient - they can be killed, restarted, or created +dynamically. Therefore we don't want to try to connect to pods directly, but +rather create a Kubernetes Service. Services keep track of pods and direct +traffic to the right place. + +The final step for our database is to create the service descriptor: + +```yaml +# kubernetes/postgres-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: backstage +spec: + selector: + app: postgres + ports: + - port: 5432 +``` + +Apply the service to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/postgres-service.yaml +service/postgres created + +$ kubectl get services --namespace=backstage +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +postgres ClusterIP 10.96.5.103 5432/TCP 29s +``` + +## Creating the Backstage instance + +Now that we have PostgreSQL up and ready to store data, we can create the +Backstage instance. This follows similar steps as the PostgreSQL deployment. + +### Creating a Backstage secret + +For any Backstage configuration secrets, such as authorization tokens, we can +create a similar Kubernetes Secret as we did +[for PostgreSQL](#creating-a-postgresql-secret), remembering to base64 encode +the values: + +```yaml +# kubernetes/backstage-secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: backstage-secrets + namespace: backstage +type: Opaque +data: + GITHUB_TOKEN: VG9rZW5Ub2tlblRva2VuVG9rZW5NYWxrb3ZpY2hUb2tlbg== +``` + +Apply the secret to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/backstage-secrets.yaml +secret/backstage-secrets created +``` + +### Creating a Backstage deployment + +To create the Backstage deployment, first create a [Docker image](docker.md). +We'll use this image to create a Kubernetes deployment. For this example, we'll +use the standard host build with the frontend bundled and served from the +backend. + +First, create a Kubernetes Deployment descriptor: + +```yaml +# kubernetes/backstage.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage + namespace: backstage +spec: + replicas: 1 + selector: + matchLabels: + app: backstage + template: + metadata: + labels: + app: backstage + spec: + containers: + - name: backstage + image: backstage:1.0.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 7000 + envFrom: + - secretRef: + name: postgres-secrets + - secretRef: + name: backstage-secrets +``` + +For production deployments, the `image` reference will usually be a full URL to +a repository on a container registry (for example, ECR on AWS). + +For testing locally with `minikube`, you can point the local Docker daemon to +the `minikube` internal Docker registry and then rebuild the image to install +it: + +```shell +$ eval $(minikube docker-env) +$ yarn build-image --tag backstage:1.0.0 +``` + +There is no special wiring needed to access the PostgreSQL service. Since it's +running on the same cluster, Kubernetes will inject `POSTGRES_SERVICE_HOST` and +`POSTGRES_SERVICE_PORT` environment variables into our Backstage container. +These can be used in the Backstage `app-config.yaml` along with the secrets: + +```yaml +backend: + database: + client: pg + connection: + host: ${POSTGRES_SERVICE_HOST} + port: ${POSTGRES_SERVICE_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} +``` + +Make sure to rebuild the Docker image after applying `app-config.yaml` changes. + +Apply this Deployment to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/backstage.yaml +deployment.apps/backstage created + +$ kubectl get deployments --namespace=backstage +NAME READY UP-TO-DATE AVAILABLE AGE +backstage 1/1 1 1 1m +postgres 1/1 1 1 10m + +$ kubectl get pods --namespace=backstage +NAME READY STATUS RESTARTS AGE +backstage-54bfcd6476-n2jkm 1/1 Running 0 58s +postgres-56c86b8bbc-66pt2 1/1 Running 0 9m +``` + +Beautiful! 🎉 The deployment and pod are running in the cluster. If you run into +any trouble, check the container logs from the pod: + +```shell +# -f to tail, -c +$ kubectl logs --namespace=backstage -f backstage-54bfcd6476-n2jkm -c backstage +``` + +### Creating a Backstage service + +Like the [PostgreSQL service](#creating-a-postgresql-service) above, we need to +create a Kubernetes Service for Backstage to handle connecting requests to the +correct pods. + +Create the Kubernetes Service descriptor: + +```yaml +# kubernetes/backstage-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: backstage + namespace: backstage +spec: + selector: + app: backstage + ports: + - name: http + port: 80 + targetPort: http +``` + +The `selector` here is telling the Service which pods to target, and the port +mapping translates normal HTTP port 80 to the backend http port (7000) on the +pod. + +Apply this Service to the Kubernetes cluster: + +```shell +$ kubectl apply -f kubernetes/backstage-service.yaml +service/backstage created +``` + +Now we have a fully operational Backstage deployment! 🎉 For a _**grand +reveal**_, you can forward a local port to the service: + +```shell +$ sudo kubectl port-forward --namespace=backstage svc/backstage 80:80 +Forwarding from 127.0.0.1:80 -> 7000 +``` + +This shows port 7000 since `port-forward` doesn't _really_ support services, so +it cheats by looking up the first pod for a service and connecting to the mapped +pod port. + +Note that `app.baseUrl` and `backend.baseUrl` in your `app-config.yaml` should +match what we're forwarding here (port omitted in this example since we're using +the default HTTP port 80): + +```yaml +# app-config.yaml +app: + baseUrl: http://localhost + +organization: + name: Spotify + +backend: + baseUrl: http://localhost + listen: + port: 7000 + cors: + origin: http://localhost +``` + +If you're using an [auth provider](../auth/index.md), it should also have this +address configured for the authentication pop-up to work properly. + +Now you can open a browser on your machine to [localhost](http://localhost) and +browse your Kubernetes-deployed Backstage instance. 🚢🚢🚢 + +## Further steps + +This is most of the way to a full production deployment of Backstage on +Kubernetes. There's a few additional steps to that will likely be needed beyond +the scope of this document. + +### Set up a more reliable volume + +The `PersistentVolume` configured above uses `local` Kubernetes node storage. +This should be replaced with a cloud volume, network attached storage, or +something more persistent beyond a Kubernetes node. + +### Expose the Backstage service + +The Kubernetes Service is not exposed for external connections from outside the +cluster. This is generally done with a Kubernetes +[ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) or +an +[external load balancer](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/). + +### Update the Deployment image + +To update the Kubernetes deployment to a newly published version of your +Backstage Docker image, update the image tag reference in `backstage.yaml` and +then apply the changes with `kubectl apply -f kubernetes/backstage.yaml`. + +For production purposes, this image tag will generally be a full-fledged URL +pointing to a container registry where built Docker images are hosted. This can +be hosted internally in your infrastructure, or a managed one offered by a cloud +provider. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index c21ac7e18a..2ecd7d42be 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -25,8 +25,7 @@ kubernetes: - url: http://127.0.0.1:9999 name: minikube authProvider: 'serviceAccount' - serviceAccountToken: - $env: K8S_MINIKUBE_TOKEN + serviceAccountToken: ${K8S_MINIKUBE_TOKEN} - url: http://127.0.0.2:9999 name: aws-cluster-1 authProvider: 'aws' diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 7397ce7d23..f4e1f86dd8 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -519,6 +519,17 @@ consumed by the component, e.g. `artist-api`. This field is optional. | --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | | [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`consumesApi`, and reverse `apiConsumedBy`](well-known-relations.md#consumesapi-and-apiconsumedby) | +### `spec.dependsOn` [optional] + +An array of [entity references](#string-references) to the components and +resources that the component depends on, e.g. `artists-db`. This field is +optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | +| [`Component`](#kind-component) | Same as this entity, typically `default` | [`dependsOn`, and reverse `dependencyOf`](well-known-relations.md#dependson-and-dependencyof) | +| [`Resource`](#kind-resource) | Same as this entity, typically `default` | [`dependsOn`, and reverse `dependencyOf`](well-known-relations.md#dependson-and-dependencyof) | + ## Kind: Template The following describes the following entity kind: @@ -1014,6 +1025,17 @@ belongs to, e.g. `artist-engagement-portal`. This field is optional. | --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | | [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | +### `spec.dependsOn` [optional] + +An array of [entity references](#string-references) to the components and +resources that the resource depends on, e.g. `artist-lookup`. This field is +optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | +| [`Component`](#kind-component) | Same as this entity, typically `default` | [`dependsOn`, and reverse `dependencyOf`](well-known-relations.md#dependson-and-dependencyof) | +| [`Resource`](#kind-resource) | Same as this entity, typically `default` | [`dependsOn`, and reverse `dependencyOf`](well-known-relations.md#dependson-and-dependencyof) | + ## Kind: System Describes the following entity kind: diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index e278bc7a1a..247324a46c 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -156,8 +156,18 @@ The key points to note are: - Call `emit` any number of times with the results of that process - Finally return `true` -You should now be able to instantiate this class in your backend, and add it to -the `CatalogBuilder` using the `addProcessors` method. +You should now be able to add this class to your backend in +`packages/backend/src/plugins/catalog.ts`: + +```diff ++ import { SystemXReaderProcessor } from '../path/to/class'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const builder = new CatalogBuilder(env); ++ builder.addProcessor(new SystemXReaderProcessor(env.reader)); +``` Start up the backend - it should now start reading from the previously registered location and you'll see your entities start to appear in Backstage. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 8ec4813799..0053c14b79 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -92,12 +92,14 @@ view and edit links need changing. # Example: metadata: annotations: - backstage.io/source-location: github:https://github.com/my-org/my-service + backstage.io/source-location: url:https://github.com/my-org/my-service/ ``` A `Location` reference that points to the source code of the entity (typically a `Component`). Useful when catalog files do not get ingested from the source code -repository itself. +repository itself. If the URL points to a folder, it is important that it is +suffixed with a `'/'` in order for relative path resolution to work +consistently. ### jenkins.io/github-folder diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index e145567666..105b7e5f96 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -189,8 +189,7 @@ public within the enterprise. integrations: github: - host: github.com - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} scaffolder: github: @@ -207,8 +206,7 @@ instance: integrations: gitlab: - host: gitlab.com - token: - $env: GITLAB_TOKEN + token: ${GITLAB_TOKEN} ``` #### Bitbucket @@ -221,8 +219,7 @@ following: integrations: bitbucket: - host: bitbucket.org - token: - $env: BITBUCKET_TOKEN + token: ${BITBUCKET_TOKEN} ``` or @@ -231,10 +228,8 @@ or integrations: bitbucket: - host: bitbucket.org - appPassword: - $env: BITBUCKET_APP_PASSWORD - username: - $env: BITBUCKET_USERNAME + appPassword: ${BITBUCKET_APP_PASSWORD} + username: ${BITBUCKET_USERNAME} ``` #### Azure DevOps @@ -249,8 +244,7 @@ verified. integrations: azure: - host: dev.azure.com - token: - $env: AZURE_TOKEN + token: ${AZURE_TOKEN} ``` ### Running the Backend @@ -265,3 +259,15 @@ GITHUB_TOKEN= yarn start If you've also set up the frontend plugin, so you should be ready to go browse the templates at [localhost:3000/create](http://localhost:3000/create) now! + +### Disabling Docker in Docker situation (Optional) + +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)>) +docker image. + +If you are running backstage from a Docker container and you want to avoid +calling a container inside a container, you can set up Cookiecutter in your own +image, this will use the local installation instead. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 9bf2c25926..c7876f3874 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -65,22 +65,18 @@ techdocs: # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html credentials: - accessKeyId: - $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL - secretAccessKey: - $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + accessKeyId: ${TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL} + secretAccessKey: ${TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL} # (Optional) AWS Region of the bucket. # If not set, AWS_REGION environment variable or aws config file will be used. # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html - region: - $env: AWS_REGION + region: ${AWS_REGION} # (Optional) Endpoint URI to send requests to. # If not set, the default endpoint is built from the configured region. # https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property - endpoint: - $env: AWS_ENDPOINT + endpoint: ${AWS_ENDPOINT} # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. @@ -91,13 +87,11 @@ techdocs: # (Required) An account name is required to write to a storage blob container. # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key credentials: - accountName: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + accountName: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME} # (Optional) An account key is required to write to a storage container. # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used. # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json - accountKey: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY + accountKey: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY} # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index fd8f320859..d5a3a68425 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -95,8 +95,7 @@ techdocs: type: 'googleGcs' googleGcs: bucketName: 'name-of-techdocs-storage-bucket' - credentials: - $env: GOOGLE_APPLICATION_CREDENTIALS + credentials: ${GOOGLE_APPLICATION_CREDENTIALS} ``` **4. That's it!** @@ -179,13 +178,10 @@ techdocs: type: 'awsS3' awsS3: bucketName: 'name-of-techdocs-storage-bucket' - region: - $env: AWS_REGION + region: ${AWS_REGION} credentials: - accessKeyId: - $env: AWS_ACCESS_KEY_ID - secretAccessKey: - $env: AWS_SECRET_ACCESS_KEY + accessKeyId: ${AWS_ACCESS_KEY_ID} + secretAccessKey: ${AWS_SECRET_ACCESS_KEY} ``` Refer to the @@ -202,8 +198,7 @@ techdocs: type: 'awsS3' awsS3: bucketName: 'name-of-techdocs-storage-bucket' - region: - $env: AWS_REGION + region: ${AWS_REGION} credentials: roleArn: arn:aws:iam::123456789012:role/my-backstage-role ``` @@ -276,8 +271,7 @@ techdocs: azureBlobStorage: containerName: 'name-of-techdocs-storage-bucket' credentials: - accountName: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + accountName: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME} ``` **3b. Authentication using app-config.yaml** @@ -297,10 +291,8 @@ techdocs: azureBlobStorage: containerName: 'name-of-techdocs-storage-bucket' credentials: - accountName: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME - accountKey: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY + accountName: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME} + accountKey: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY} ``` **4. That's it!** @@ -361,20 +353,13 @@ techdocs: openStackSwift: containerName: 'name-of-techdocs-storage-bucket' credentials: - userName: - $env: OPENSTACK_SWIFT_STORAGE_USERNAME - password: - $env: OPENSTACK_SWIFT_STORAGE_PASSWORD - authUrl: - $env: OPENSTACK_SWIFT_STORAGE_AUTH_URL - keystoneAuthVersion: - $env: OPENSTACK_SWIFT_STORAGE_AUTH_VERSION - domainId: - $env: OPENSTACK_SWIFT_STORAGE_DOMAIN_ID - domainName: - $env: OPENSTACK_SWIFT_STORAGE_DOMAIN_NAME - region: - $env: OPENSTACK_SWIFT_STORAGE_REGION + userName: ${OPENSTACK_SWIFT_STORAGE_USERNAME} + password: ${OPENSTACK_SWIFT_STORAGE_PASSWORD} + authUrl: ${OPENSTACK_SWIFT_STORAGE_AUTH_URL} + keystoneAuthVersion: ${OPENSTACK_SWIFT_STORAGE_AUTH_VERSION} + domainId: ${OPENSTACK_SWIFT_STORAGE_DOMAIN_ID} + domainName: ${OPENSTACK_SWIFT_STORAGE_DOMAIN_NAME} + region: ${OPENSTACK_SWIFT_STORAGE_REGION} ``` **4. That's it!** diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 345b7ad969..181f541929 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -74,3 +74,64 @@ Note that your list of custom themes overrides the default themes. If you still want to use the default themes, they are exported as `lightTheme` and `darkTheme` from [@backstage/theme](https://www.npmjs.com/package/@backstage/theme). + +## Example of a custom theme + +```ts +const themeOptions = createThemeOptions({ + palette: { + ...lightTheme.palette, + primary: { + main: '#123456', + }, + secondary: { + main: '#123456', + }, + error: { + main: '#123456' + }, + warning: { + main: '#123456', + }, + info: { + main: '#123456', + }, + success: { + main: '#123456', + }, + background: { + default: '#123456', + paper: '#123456', + }, + banner: { + info: '#123456', + error: '#123456' + text: '#123456' + link: '#123456', + }, + errorBackground: '#123456' + warningBackground: '#123456' + infoBackground: '#123456' + navigation: { + background: '#123456', + indicator: '#123456' + color: '#123456' + selectedColor: '#123456', + }, + }, + defaultPageTheme: 'home', + fontFamily: 'Comic Sans', + /* below drives the header colors */ + pageTheme: { + home: genPageTheme(['#123456','#123456'], shapes.wave), + documentation: genPageTheme(['#123456','#123456'], shapes.wave2), + tool: genPageTheme(['#123456','#123456'], shapes.round), + service: genPageTheme(['#123456','#123456'], shapes.wave), + website: genPageTheme(['#123456','#123456'], shapes.wave), + library: genPageTheme(['#123456','#123456'] shapes.wave), + other: genPageTheme(['#123456','#123456'], shapes.wave), + app: genPageTheme(['#123456','#123456'], shapes.wave), + apis: genPageTheme(['#123456','#123456'], shapes.wave), + }, +}); +``` diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index 964987c4a7..b2f3a37d90 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -31,26 +31,33 @@ CircleCI is a frontend UI plugin, it goes in `app` rather than `backend`. ```js // packages/app/src/plugins.ts -export { plugin as Circleci } from '@backstage/plugin-circleci'; +export { plugin as CircleCi } from '@backstage/plugin-circleci'; ``` -3. Register the plugin router: +3. Register the plugin in the entity pages: -```jsx -// packages/app/src/components/catalog/EntityPage.tsx +```diff + // packages/app/src/components/catalog/EntityPage.tsx ++import { ++ EntityCircleCIContent, ++ isCircleCIAvailable, ++} from '@backstage/plugin-circleci'; -import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; - -// Then somewhere inside -} -/>; +... + const cicdContent = ( + + ... ++ ++ ++ ; + + ); ``` -Note that stand-alone plugins that are not "attached" to the Software Catalog -would be added outside the `EntityPage`. +This is just one example, but each Backstage instance may integrate content or +cards to suit their needs on different pages, tabs, etc. Note that stand-alone +plugins that are not "attached" to the Software Catalog would be added outside +the `EntityPage`. 4. [Optional] Add proxy config: @@ -60,17 +67,16 @@ proxy: '/circleci/api': target: https://circleci.com/api/v1.1 headers: - Circle-Token: - $env: CIRCLECI_AUTH_TOKEN + Circle-Token: ${CIRCLECI_AUTH_TOKEN} ``` ### Adding a plugin page to the Sidebar In a standard Backstage app created with [@backstage/create-app](./create-an-app.md), the sidebar is managed inside -`packages/app/src/components/Root.tsx`. The file exports the entire `Sidebar` -element of your app, which you can extend with additional entries by adding new -`SidebarItem` elements. +`packages/app/src/components/Root/Root.tsx`. The file exports the entire +`Sidebar` element of your app, which you can extend with additional entries by +adding new `SidebarItem` elements. For example, if you install the `api-docs` plugin, a matching `SidebarItem` could be something like this: diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 837a27e592..fefb20b095 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -106,6 +106,12 @@ The install process may also fail if no Python installation is available. Python is commonly available in most systems already, but if it isn't you can head for example [here](https://www.python.org/downloads/) to install it. +#### Could not execute command yarn install + +Install yarn on your system with `npm install --global yarn` or for more details +refer to the +[prerequisites](https://backstage.io/docs/getting-started/running-backstage-locally#prerequisites) + ## Run the app When the installation is complete you can open the app folder and start the app. diff --git a/docs/getting-started/deployment-k8s.md b/docs/getting-started/deployment-k8s.md deleted file mode 100644 index bc24894ed1..0000000000 --- a/docs/getting-started/deployment-k8s.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -id: deployment-k8s -title: Kubernetes -description: Documentation on Kubernetes and K8s Deployment ---- - -Backstage itself provides tooling up to the point of building Docker images. -Beyond that point we do not have an opinionated way to deploy Backstage within -Kubernetes, as each cluster has its own unique set of tooling and patterns. - -We do provide examples to help you get started though. Check out -[this example](https://github.com/backstage/backstage/tree/master/contrib/kubernetes/plain_single_backend_deployment/) -for a basic single-deployment setup. diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md deleted file mode 100644 index cc622d00f5..0000000000 --- a/docs/getting-started/deployment-other.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: deployment-other -title: Other -description: Documentation on different ways of Deployment ---- - -## Heroku - -Deploying to Heroku is relatively easy following these steps. - -First, make sure you have the -[Heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log -into it as well as login into Heroku's -[container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). - -```bash -$ heroku login -$ heroku container:login -``` - -You _might_ also need to set your Heroku app's stack to `container`. - -```bash -$ heroku stack:set container -a -``` - -We can now build/push the Docker image to Heroku's container registry and -release it to the `web` worker. - -```bash -$ heroku container:push web -a -$ heroku container:release web -a -``` - -With that, you should have Backstage up and running! diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md index 4ce19bad4e..163674f00a 100644 --- a/docs/integrations/azure/locations.md +++ b/docs/integrations/azure/locations.md @@ -2,8 +2,8 @@ id: locations title: Azure DevOps Locations sidebar_label: Locations -description: - Integrating source code stored in Azure DevOps into the Backstage catalog +# prettier-ignore +description: Integrating source code stored in Azure DevOps into the Backstage catalog --- The Azure integration supports loading catalog entities from Azure DevOps. diff --git a/docs/integrations/bitbucket/discovery.md b/docs/integrations/bitbucket/discovery.md index b31071be7f..b24734abde 100644 --- a/docs/integrations/bitbucket/discovery.md +++ b/docs/integrations/bitbucket/discovery.md @@ -2,8 +2,8 @@ id: discovery title: Bitbucket Discovery sidebar_label: Discovery -description: - Automatically discovering catalog entities from repositories in Bitbucket +# prettier-ignore +description: Automatically discovering catalog entities from repositories in Bitbucket --- The Bitbucket integration has a special discovery processor for discovering diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index 588d8b3bcc..d318eef874 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -2,8 +2,8 @@ id: locations title: Bitbucket Locations sidebar_label: Locations -description: - Integrating source code stored in Bitbucket into the Backstage catalog +# prettier-ignore +description: Integrating source code stored in Bitbucket into the Backstage catalog --- The Bitbucket integration supports loading catalog entities from bitbucket.org @@ -38,3 +38,6 @@ a structure with up to four elements: - `apiBaseUrl` (optional): The URL of the Bitbucket API. For self-hosted installations, it is commonly at `https:///rest/api/1.0`. For bitbucket.org, this configuration is not needed as it can be inferred. + +> Note: If you are using Bitbucket server you MUST set the username as well as +> the token or appPassword. diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index fe52feab6e..a578d0893d 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -2,9 +2,8 @@ id: discovery title: GitHub Discovery sidebar_label: Discovery -description: - Automatically discovering catalog entities from repositories in a GitHub - organization +# prettier-ignore +description: Automatically discovering catalog entities from repositories in a GitHub organization --- The GitHub integration has a special discovery processor for discovering catalog diff --git a/docs/integrations/google-cloud-storage/locations.md b/docs/integrations/google-cloud-storage/locations.md new file mode 100644 index 0000000000..bcdf612b9a --- /dev/null +++ b/docs/integrations/google-cloud-storage/locations.md @@ -0,0 +1,55 @@ +--- +id: locations +sidebar_label: Locations +title: Google Cloud Storage Locations +# prettier-ignore +description: Setting up an integration with Google Cloud Storage +--- + +The Backstage catalog can import entities from a yaml file stored in a GCS +(Google Cloud Storage) bucket. To enable the ingestion of said entities the +`GoogleGcs` integration must be enabled first. + +## Configuration + +To configure the integration add the appropriate credentials to the Backstage +backend. There are two main ways to do this: by explicitly setting a +`clientEmail` and a `privateKey` or by letting the Google Storage SDK discover +the credentials automatically. + +### Explicit credentials + +Explicit credentials can be set in the following format: + +```yaml +integrations: + googleGcs: + clientEmail: ${GCS_CLIENT_EMAIL} + privateKey: ${GCS_PRIVATE_KEY} +``` + +Then make sure the environment variables `GCS_CLIENT_EMAIL` and +`GCS_PRIVATE_KEY` are set when you run Backstage. + +### Automatic discovery of Google credentials + +Since this integration uses the Google Storage SDK, you can also choose to not +provide any explicit credentials and let the SDK discover them automatically. + +One of these discovery methods is to provide an environment variable called +`GOOGLE_APPLICATION_CREDENTIALS` and set it to the file path of your JSON +service account key. + +For more details and methods to provide credentials to the Google Storage SDK +you can check [this documentation page][google gcs docs]. + +## Usage + +To use this integration to import entities from a GCS bucket go to the Google +console and browse the file you would like to import. Then copy the +`Authenticated URL` and paste it into the text box in the `register component` +form. This url should look like +`https://storage.cloud.google.com///catalog-info.yaml`. + +[google gcs docs]: + https://cloud.google.com/docs/authentication/production#auth-cloud-implicit-nodejs diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 6c12aa8666..94f18daa09 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -2,9 +2,8 @@ id: index title: Integrations sidebar_label: Overview -description: - Configuring Backstage to read or publish data with external providers using - integrations +# prettier-ignore +description: Configuring Backstage to read or publish data with external providers using integrations --- Integrations allow Backstage to read or publish data using external providers diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index f2d7d3c12a..50c56af7a6 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -40,8 +40,9 @@ proxy: '/larger-example/v1': target: http://larger.example.com:8080/svc.v1 headers: - Authorization: - $env: EXAMPLE_AUTH_HEADER + Authorization: ${EXAMPLE_AUTH_HEADER} + # ...or interpolating a value into part of a string, + # Authorization: Bearer ${EXAMPLE_AUTH_TOKEN} ``` Each key under the proxy configuration entry is a route to match, below the diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index d322e2ec3c..510b697221 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -79,13 +79,10 @@ auth: providers: github: development: - clientId: - $env: AUTH_GITHUB_CLIENT_ID - clientSecret: - $env: AUTH_GITHUB_CLIENT_SECRET - ## uncomment the following two lines if using enterprise - # enterpriseInstanceUrl: - # $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + clientId: ${AUTH_GITHUB_CLIENT_ID} + clientSecret: ${AUTH_GITHUB_CLIENT_SECRET} + ## uncomment the following line if using enterprise + # enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} ``` ### 2. Generate a GitHub client ID and secret @@ -122,10 +119,8 @@ auth: providers: gitlab: development: - clientId: - $env: AUTH_GITLAB_CLIENT_ID - clientSecret: - $env: AUTH_GITLAB_CLIENT_SECRET + clientId: ${AUTH_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} audience: https://gitlab.com # Or your self-hosted GitLab instance URL ``` @@ -172,10 +167,8 @@ auth: providers: google: development: - clientId: - $env: AUTH_GOOGLE_CLIENT_ID - clientSecret: - $env: AUTH_GOOGLE_CLIENT_SECRET + clientId: ${AUTH_GOOGLE_CLIENT_ID} + clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} ``` ### 2. Generate Google Credentials in Google Cloud console @@ -216,12 +209,9 @@ auth: providers: microsoft: development: - clientId: - $env: AUTH_MICROSOFT_CLIENT_ID - clientSecret: - $env: AUTH_MICROSOFT_CLIENT_SECRET - tenantId: - $env: AUTH_MICROSOFT_TENANT_ID + clientId: ${AUTH_MICROSOFT_CLIENT_ID} + clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET} + tenantId: ${AUTH_MICROSOFT_TENANT_ID} ``` ### 2. Create a Microsoft App Registration in Microsoft Portal @@ -264,12 +254,9 @@ auth: providers: auth0: development: - clientId: - $env: AUTH_AUTH0_CLIENT_ID - clientSecret: - $env: AUTH_AUTH0_CLIENT_SECRET - domain: - $env: AUTH_AUTH0_DOMAIN_ID + clientId: ${AUTH_AUTH0_CLIENT_ID} + clientSecret: ${AUTH_AUTH0_CLIENT_SECRET} + domain: ${AUTH_AUTH0_DOMAIN_ID} ``` ### 2. Create an Auth0 application in the Auth0 management console diff --git a/docs/tutorials/switching-sqlite-postgres.md b/docs/tutorials/switching-sqlite-postgres.md index dfb82ddc51..a759b7dc49 100644 --- a/docs/tutorials/switching-sqlite-postgres.md +++ b/docs/tutorials/switching-sqlite-postgres.md @@ -1,8 +1,8 @@ --- id: switching-sqlite-postgres title: Switching Backstage from SQLite to PostgreSQL -description: - How to get ready for deploying Backstage to production with PostgreSQL +# prettier-ignore +description: How to get ready for deploying Backstage to production with PostgreSQL --- The default `@backstage/create-app` database is SQLite, an in-memory database @@ -38,14 +38,10 @@ backend: + # config options: https://node-postgres.com/api/client + client: pg + connection: -+ host: -+ $env: POSTGRES_HOST -+ port: -+ $env: POSTGRES_PORT -+ user: -+ $env: POSTGRES_USER -+ password: -+ $env: POSTGRES_PASSWORD ++ host: ${POSTGRES_HOST} ++ port: ${POSTGRES_PORT} ++ user: ${POSTGRES_USER} ++ password: ${POSTGRES_PASSWORD} + # https://node-postgres.com/features/ssl + #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + #ca: # if you have a CA file and want to verify it you can uncomment this section @@ -53,9 +49,9 @@ backend: ``` -If you have a `app-config.local.yaml` for local development, a similar update +If you have an `app-config.local.yaml` for local development, a similar update should be made there. You can set the `POSTGRES_` environment variables prior to -launching Backstage, or remove the $env keys and simply set values directly for -development. +launching Backstage, or remove the `${...}` values and simply set actual values +directly for development. The Backstage App is now ready to start up with a PostgreSQL backing database. diff --git a/microsite/package.json b/microsite/package.json index ea16fd8a32..07899fa703 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -15,9 +15,9 @@ "verify:sidebars": "node ./scripts/verify-sidebars" }, "devDependencies": { - "@spotify/prettier-config": "^9.0.0", + "@spotify/prettier-config": "^10.0.0", "docusaurus": "^2.0.0-alpha.70", - "js-yaml": "^4.0.0", + "js-yaml": "^4.1.0", "prettier": "^2.2.1" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 47f9568d9b..a761d6b6e2 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -292,7 +292,7 @@ class Index extends React.Component { Build your own software templates Contribute diff --git a/microsite/sidebars.json b/microsite/sidebars.json index fd21726271..86e94d9fbd 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -22,16 +22,6 @@ "getting-started/app-custom-theme" ] }, - { - "type": "subcategory", - "label": "Deployment", - "ids": [ - "getting-started/deployment-docker", - "getting-started/deployment-k8s", - "getting-started/deployment-helm", - "getting-started/deployment-other" - ] - }, "getting-started/keeping-backstage-updated", "getting-started/concepts", "getting-started/contributors" @@ -142,6 +132,11 @@ "type": "subcategory", "label": "LDAP", "ids": ["integrations/ldap/org"] + }, + { + "type": "subcategory", + "label": "Google GCS", + "ids": ["integrations/google-cloud-storage/locations"] } ], "Plugins": [ @@ -186,7 +181,6 @@ ], "Auth and identity": [ "auth/index", - "auth/add-auth-provider", { "type": "subcategory", "label": "Included providers", @@ -196,13 +190,22 @@ "auth/github/provider", "auth/gitlab/provider", "auth/google/provider", - "auth/okta/provider" + "auth/okta/provider", + "auth/onelogin/provider" ] }, + "auth/add-auth-provider", + "auth/using-auth", "auth/auth-backend", "auth/oauth", - "auth/glossary", - "auth/auth-backend-classes" + "auth/auth-backend-classes", + "auth/glossary" + ], + "Deployment": [ + "deployment/index", + "deployment/k8s", + "deployment/helm", + "deployment/heroku" ], "Designing for Backstage": [ "dls/design", diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 898fd69ad2..bed7897abc 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -909,10 +909,10 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@spotify/prettier-config@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-9.0.0.tgz#7b562d56573c6fc0094446fbc92b22bc318945dc" - integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== +"@spotify/prettier-config@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-10.0.0.tgz#fa076d98d2e7e6c53dd3d86a696307a7010bd056" + integrity sha512-VYOdo8P7lIScAkl02nB9KpUAuOYMManryBIBuKJkAw5D3aVtLobfmdIKvdV6MqEmGMEQPbn7w/UpnjJYhUH+IA== "@types/cheerio@^0.22.8": version "0.22.23" @@ -3862,10 +3862,10 @@ js-yaml@^3.13.1, js-yaml@^3.8.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" - integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" diff --git a/package.json b/package.json index fcabe5b3e5..d87b98fbdb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "resolutions": { "**/@roadiehq/**/@backstage/core": "*", "**/@roadiehq/**/@backstage/plugin-catalog": "*", - "**/@roadiehq/**/@backstage/catalog-model": "*" + "**/@roadiehq/**/@backstage/catalog-model": "*", + "graphql-language-service-interface": "2.8.2", + "graphql-language-service-parser": "1.9.0" }, "version": "1.0.0", "devDependencies": { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 00cf81a903..81a7f66f4c 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,54 @@ # example-app +## 0.2.24 + +### Patch Changes + +- Updated dependencies [a360f9478] +- Updated dependencies [bb5055aee] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [12390778e] +- Updated dependencies [fef852ecd] +- Updated dependencies [18f7345a6] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [e27cb6c45] +- Updated dependencies [184b02bef] +- Updated dependencies [60ce64aa2] + - @backstage/plugin-scaffolder@0.9.0 + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + - @backstage/plugin-catalog@0.5.4 + - @backstage/plugin-api-docs@0.4.11 + - @backstage/plugin-techdocs@0.7.2 + - @backstage/plugin-tech-radar@0.3.9 + - @backstage/cli@0.6.8 + +## 0.2.23 + +### Patch Changes + +- Updated dependencies [d0b4ebf22] +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [3f96a9d5a] +- Updated dependencies [b051e770c] +- Updated dependencies [f9c75f7a9] +- Updated dependencies [98dd5da71] +- Updated dependencies [97d53f686] +- Updated dependencies [64d2ce700] + - @backstage/plugin-badges@0.2.0 + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + - @backstage/plugin-scaffolder@0.8.2 + - @backstage/plugin-catalog-import@0.5.2 + - @backstage/plugin-catalog@0.5.3 + - @backstage/plugin-org@0.3.12 + - @backstage/plugin-github-deployments@0.1.2 + - @backstage/cli@0.6.7 + ## 0.2.21 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index e5dbbaa5ff..6f58bdb82a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,17 +1,17 @@ { "name": "example-app", - "version": "0.2.21", + "version": "0.2.24", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.7.4", - "@backstage/cli": "^0.6.6", - "@backstage/core": "^0.7.3", + "@backstage/catalog-model": "^0.7.7", + "@backstage/cli": "^0.6.8", + "@backstage/core": "^0.7.5", "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-api-docs": "^0.4.9", - "@backstage/plugin-badges": "^0.1.2", - "@backstage/plugin-catalog": "^0.5.1", - "@backstage/plugin-catalog-import": "^0.5.0", + "@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-react": "^0.1.3", "@backstage/plugin-circleci": "^0.2.12", "@backstage/plugin-cloudbuild": "^0.2.13", @@ -19,7 +19,7 @@ "@backstage/plugin-explore": "^0.3.2", "@backstage/plugin-gcp-projects": "^0.2.5", "@backstage/plugin-github-actions": "^0.4.2", - "@backstage/plugin-github-deployments": "^0.1.1", + "@backstage/plugin-github-deployments": "^0.1.2", "@backstage/plugin-gitops-profiles": "^0.2.6", "@backstage/plugin-graphiql": "^0.2.9", "@backstage/plugin-jenkins": "^0.4.1", @@ -27,25 +27,25 @@ "@backstage/plugin-kubernetes": "^0.4.2", "@backstage/plugin-lighthouse": "^0.2.14", "@backstage/plugin-newrelic": "^0.2.6", - "@backstage/plugin-org": "^0.3.10", + "@backstage/plugin-org": "^0.3.12", "@backstage/plugin-pagerduty": "0.3.2", "@backstage/plugin-register-component": "^0.2.12", "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.8.0", + "@backstage/plugin-scaffolder": "^0.9.0", "@backstage/plugin-search": "^0.3.4", "@backstage/plugin-sentry": "^0.3.8", - "@backstage/plugin-tech-radar": "^0.3.8", - "@backstage/plugin-techdocs": "^0.7.0", + "@backstage/plugin-tech-radar": "^0.3.9", + "@backstage/plugin-techdocs": "^0.7.2", "@backstage/plugin-todo": "^0.1.0", "@backstage/plugin-user-settings": "^0.2.8", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.12", - "@roadiehq/backstage-plugin-buildkite": "^0.1.3", - "@roadiehq/backstage-plugin-github-insights": "^0.3.2", - "@roadiehq/backstage-plugin-github-pull-requests": "^0.7.6", - "@roadiehq/backstage-plugin-travis-ci": "^0.4.5", + "@roadiehq/backstage-plugin-buildkite": "^1.0.0", + "@roadiehq/backstage-plugin-github-insights": "^1.0.0", + "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.0", + "@roadiehq/backstage-plugin-travis-ci": "^1.0.0", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", @@ -57,7 +57,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index defa70dbb7..c5e5033973 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -27,7 +27,11 @@ import { CatalogIndexPage, catalogPlugin, } from '@backstage/plugin-catalog'; -import { CatalogImportPage } from '@backstage/plugin-catalog-import'; + +import { + CatalogImportPage, + catalogImportPlugin, +} from '@backstage/plugin-catalog-import'; import { CostInsightsLabelDataflowInstructionsPage, CostInsightsPage, @@ -48,8 +52,8 @@ import React from 'react'; import { hot } from 'react-hot-loader/root'; import { Navigate, Route } from 'react-router'; import { apis } from './apis'; -import { EntityPage } from './components/catalog/EntityPage'; import { Root } from './components/Root'; +import { entityPage } from './components/catalog/EntityPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -82,6 +86,9 @@ const app = createApp({ bind(explorePlugin.externalRoutes, { catalogEntity: catalogPlugin.routes.catalogEntity, }); + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + }); }, }); @@ -96,7 +103,7 @@ const routes = ( path="/catalog/:namespace/:kind/:name" element={} > - + {entityPage} } /> } /> diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 1392f77a6d..9860792ea0 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; -import { CICDSwitcher } from './EntityPage'; -import { UrlPatternDiscovery, ApiProvider, ApiRegistry } from '@backstage/core'; -import { - buildKiteApiRef, - BuildkiteApi, -} from '@roadiehq/backstage-plugin-buildkite'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityLayout } from '@backstage/plugin-catalog'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { cicdContent } from './EntityPage'; +import { githubActionsApiRef } from '@backstage/plugin-github-actions'; describe('EntityPage Test', () => { const entity = { @@ -29,7 +29,7 @@ describe('EntityPage Test', () => { metadata: { name: 'ExampleComponent', annotations: { - 'buildkite.com/project-slug': 'exampleProject/examplePipeline', + 'github.com/project-slug': 'example/project', }, }, spec: { @@ -39,24 +39,36 @@ describe('EntityPage Test', () => { }, }; - const discoveryApi = UrlPatternDiscovery.compile('http://exampleapi.com'); + const mockedApi = { + listWorkflowRuns: jest.fn().mockResolvedValue([]), + getWorkflow: jest.fn(), + getWorkflowRun: jest.fn(), + reRunWorkflow: jest.fn(), + downloadJobLogsForWorkflowRun: jest.fn(), + } as jest.Mocked; - const apis = ApiRegistry.from([ - [buildKiteApiRef, new BuildkiteApi({ discoveryApi })], - ]); + const apis = ApiRegistry.with(githubActionsApiRef, mockedApi); - describe('CICDSwitcher Test', () => { - it('Should render Buildkite View', async () => { - const renderedComponent = await renderWithEffects( - wrapInTestApp( - - - , - ), + describe('cicdContent', () => { + it('Should render GitHub Actions View', async () => { + const rendered = await renderInTestApp( + + + + + {cicdContent} + + + + , ); - expect( - renderedComponent.getByText(/exampleProject\/examplePipeline/), - ).toBeInTheDocument(); + + expect(rendered.getByText('ExampleComponent')).toBeInTheDocument(); + + await expect( + rendered.findByText('No Workflow Data'), + ).resolves.toBeInTheDocument(); + expect(rendered.getByText('Create new Workflow')).toBeInTheDocument(); }); }); }); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e161022059..d6f91957a9 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -14,175 +14,96 @@ * limitations under the License. */ -import { - ApiEntity, - DomainEntity, - Entity, - GroupEntity, - SystemEntity, - UserEntity, -} from '@backstage/catalog-model'; +import React, { ReactNode, useMemo, useState } from 'react'; +import BadgeIcon from '@material-ui/icons/CallToAction'; import { EmptyState } from '@backstage/core'; import { - ApiDefinitionCard, - ConsumedApisCard, - ConsumingComponentsCard, + EntityApiDefinitionCard, + EntityConsumingComponentsCard, EntityHasApisCard, - ProvidedApisCard, - ProvidingComponentsCard, + EntityProvidingComponentsCard, + EntityProvidedApisCard, + EntityConsumedApisCard, } from '@backstage/plugin-api-docs'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { - AboutCard, + EntityAboutCard, EntityHasComponentsCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, + EntityLayout, EntityLinksCard, - EntityPageLayout, EntitySystemDiagramCard, + EntitySwitch, + isComponentType, + isKind, } from '@backstage/plugin-catalog'; -import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react'; import { - isPluginApplicableToEntity as isCircleCIAvailable, - Router as CircleCIRouter, + EntityCircleCIContent, + isCircleCIAvailable, } from '@backstage/plugin-circleci'; import { - isPluginApplicableToEntity as isCloudbuildAvailable, - Router as CloudbuildRouter, + EntityCloudbuildContent, + isCloudbuildAvailable, } from '@backstage/plugin-cloudbuild'; import { - isPluginApplicableToEntity as isGitHubActionsAvailable, - RecentWorkflowRunsCard, - Router as GitHubActionsRouter, + EntityGithubActionsContent, + EntityRecentGithubActionsRunsCard, + isGithubActionsAvailable, } from '@backstage/plugin-github-actions'; import { - isPluginApplicableToEntity as isJenkinsAvailable, - LatestRunCard as JenkinsLatestRunCard, - Router as JenkinsRouter, + EntityJenkinsContent, + EntityLatestJenkinsRunCard, + isJenkinsAvailable, } from '@backstage/plugin-jenkins'; -import { Router as KafkaRouter } from '@backstage/plugin-kafka'; -import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; +import { EntityKafkaContent } from '@backstage/plugin-kafka'; +import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; import { - EmbeddedRouter as LighthouseRouter, - isPluginApplicableToEntity as isLighthouseAvailable, - LastLighthouseAuditCard, + EntityLastLighthouseAuditCard, + EntityLighthouseContent, + isLighthouseAvailable, } from '@backstage/plugin-lighthouse'; import { - GroupProfileCard, - MembersListCard, - OwnershipCard, - UserProfileCard, + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, + EntityUserProfileCard, } from '@backstage/plugin-org'; import { - isPluginApplicableToEntity as isPagerDutyAvailable, - PagerDutyCard, + EntityPagerDutyCard, + isPagerDutyAvailable, } from '@backstage/plugin-pagerduty'; import { + EntityRollbarContent, isRollbarAvailable, - Router as RollbarRouter, } from '@backstage/plugin-rollbar'; -import { Router as SentryRouter } from '@backstage/plugin-sentry'; -import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { EntitySentryContent } from '@backstage/plugin-sentry'; +import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; import { EntityTodoContent } from '@backstage/plugin-todo'; import { Button, Grid } from '@material-ui/core'; import { - isPluginApplicableToEntity as isBuildkiteAvailable, - Router as BuildkiteRouter, + EntityBuildkiteContent, + isBuildkiteAvailable, } from '@roadiehq/backstage-plugin-buildkite'; import { - isPluginApplicableToEntity as isGitHubAvailable, - LanguagesCard, - ReadMeCard, - ReleasesCard, - Router as GitHubInsightsRouter, + EntityGithubInsightsContent, + EntityGithubInsightsLanguagesCard, + EntityGithubInsightsReadmeCard, + EntityGithubInsightsReleasesCard, + isGithubInsightsAvailable, } from '@roadiehq/backstage-plugin-github-insights'; import { - isPluginApplicableToEntity as isPullRequestsAvailable, - PullRequestsStatsCard, - Router as PullRequestsRouter, + EntityGithubPullRequestsContent, + EntityGithubPullRequestsOverviewCard, + isGithubPullRequestsAvailable, } from '@roadiehq/backstage-plugin-github-pull-requests'; import { - isPluginApplicableToEntity as isTravisCIAvailable, - RecentTravisCIBuildsWidget, - Router as TravisCIRouter, + EntityTravisCIContent, + EntityTravisCIOverviewCard, + isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; -import React, { ReactNode, useMemo, useState } from 'react'; -import BadgeIcon from '@material-ui/icons/CallToAction'; -import { EntityGithubDeploymentsCard } from '@backstage/plugin-github-deployments'; -export const CICDSwitcher = ({ entity }: { entity: Entity }) => { - // This component is just an example of how you can implement your company's logic in entity page. - // You can for example enforce that all components of type 'service' should use GitHubActions - switch (true) { - case isJenkinsAvailable(entity): - return ; - case isBuildkiteAvailable(entity): - return ; - case isCircleCIAvailable(entity): - return ; - case isCloudbuildAvailable(entity): - return ; - case isTravisCIAvailable(entity): - return ; - case isGitHubActionsAvailable(entity): - return ; - default: - return ( - - Read more - - } - /> - ); - } -}; - -const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { - let content: ReactNode; - switch (true) { - case isJenkinsAvailable(entity): - content = ; - break; - case isTravisCIAvailable(entity): - content = ; - break; - case isGitHubActionsAvailable(entity): - content = ( - - ); - break; - default: - content = null; - } - if (!content) { - return null; - } - return ( - - {content} - - ); -}; - -export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => { - switch (true) { - case isRollbarAvailable(entity): - return ; - default: - return ; - } -}; - -const EntityPageLayoutWrapper = (props: { children?: React.ReactNode }) => { +const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); const extraMenuItems = useMemo(() => { @@ -197,9 +118,9 @@ const EntityPageLayoutWrapper = (props: { children?: React.ReactNode }) => { return ( <> - + {props.children} - + setBadgesDialogOpen(false)} @@ -208,353 +129,367 @@ const EntityPageLayoutWrapper = (props: { children?: React.ReactNode }) => { ); }; -const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( +export const cicdContent = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + Read more + + } + /> + + +); + +const cicdCard = ( + + + + + + + + + + + + + + + + + + + +); + +const errorsContent = ( + + + + + + + + + +); + +const overviewContent = ( - - + + - {isPagerDutyAvailable(entity) && ( - - - - - - )} - - + + + + + + + + + + + - - {isGitHubAvailable(entity) && ( - <> - - - + + {cicdCard} + + + Boolean(isGithubInsightsAvailable(e))}> + + + - - + + - - )} - {isLighthouseAvailable(entity) && ( - - - - )} - {isPullRequestsAvailable(entity) && ( - - - - )} - + + + + + + + + + + + + + Boolean(isGithubPullRequestsAvailable(e))}> + + + + + + + - - - ); -const ComponentApisContent = ({ entity }: { entity: Entity }) => ( - - - - - - - - -); +const serviceEntityPage = ( + + + {overviewContent} + -const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - -); + + {cicdContent} + -const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - -); + + {errorsContent} + -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - } - /> - -); - -export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { - switch (entity?.spec?.type) { - case 'service': - return ; - case 'website': - return ; - default: - return ; - } -}; - -const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const websiteEntityPage = ( + + + {overviewContent} + + + + {cicdContent} + + + + + + + + {errorsContent} + + + + + + + + + + + + + + + + + + + + + + +); + +const defaultEntityPage = ( + + + {overviewContent} + + + + + + + + + + +); + +const componentPage = ( + + + {serviceEntityPage} + + + + {websiteEntityPage} + + + {defaultEntityPage} + +); + +const apiPage = ( + + + + + + + + + + + + + + - - + + + + + + + + + + ); -const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( - - - - - +const userPage = ( + + + + + + + + + + + + ); -const ApiEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - +const groupPage = ( + + + + + + + + + + + + + + + + + + + ); -const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( - - - - - - - - +const systemPage = ( + + + + + + + + + + + + + + + ); -const UserEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - +const domainPage = ( + + + + + + + + + + + + ); -const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( - - - - - - - - - - - +export const entityPage = ( + + + + + + + + + {defaultEntityPage} + ); - -const GroupEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); - -const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( - - - - - - - - - - - -); - -const SystemEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - -); - -const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( - - - - - - - - -); - -const DomainEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); - -export const EntityPage = () => { - const { entity } = useEntity(); - - switch (entity?.kind?.toLocaleLowerCase('en-US')) { - case 'component': - return ; - case 'api': - return ; - case 'group': - return ; - case 'user': - return ; - case 'system': - return ; - case 'domain': - return ; - case 'location': - case 'resource': - case 'template': - default: - return ; - } -}; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index ccc7e2a45c..e08bfd6abe 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -27,19 +27,19 @@ export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; export { plugin as GithubActions } from '@backstage/plugin-github-actions'; export { plugin as Rollbar } from '@backstage/plugin-rollbar'; export { plugin as Newrelic } from '@backstage/plugin-newrelic'; -export { plugin as TravisCI } from '@roadiehq/backstage-plugin-travis-ci'; +export { travisciPlugin } from '@roadiehq/backstage-plugin-travis-ci'; export { plugin as Jenkins } from '@backstage/plugin-jenkins'; export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; -export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests'; +export { githubPullRequestsPlugin } from '@roadiehq/backstage-plugin-github-pull-requests'; export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects'; export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild'; export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; -export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights'; +export { githubInsightsPlugin } from '@roadiehq/backstage-plugin-github-insights'; export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; export { plugin as UserSettings } from '@backstage/plugin-user-settings'; export { plugin as PagerDuty } from '@backstage/plugin-pagerduty'; -export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite'; +export { buildkitePlugin } from '@roadiehq/backstage-plugin-buildkite'; export { plugin as Search } from '@backstage/plugin-search'; export { plugin as Org } from '@backstage/plugin-org'; export { plugin as Kafka } from '@backstage/plugin-kafka'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index dfe057da53..c194f7e666 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/backend-common +## 0.6.2 + +### Patch Changes + +- b779b5fee: Add UrlReader for Google Cloud Storage +- Updated dependencies [82c66b8cd] + - @backstage/config-loader@0.6.0 + ## 0.6.1 ### Patch Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index b860336d31..41fa657818 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.1", + "version": "0.6.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,9 +31,10 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.5.1", + "@backstage/config-loader": "^0.6.0", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", + "@google-cloud/storage": "^5.8.0", "@octokit/rest": "^18.0.12", "@types/cors": "^2.8.6", "@types/dockerode": "^3.2.1", @@ -56,6 +57,7 @@ "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", + "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", "tar": "^6.0.5", @@ -71,8 +73,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.5", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.7", + "@backstage/test-utils": "^0.1.10", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts new file mode 100644 index 0000000000..e201361a91 --- /dev/null +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { ConfigReader, JsonObject } from '@backstage/config'; +import { getVoidLogger } from '../logging'; +import { ReadTreeResponseFactory } from './tree'; +import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; +import { UrlReaderPredicateTuple } from './types'; + +describe('GcsUrlReader', () => { + const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => { + return GoogleGcsUrlReader.factory({ + config: new ConfigReader(config), + logger: getVoidLogger(), + treeResponseFactory: ReadTreeResponseFactory.create({ + config: new ConfigReader({}), + }), + }); + }; + + it('does not create a reader without the googleGcs field', () => { + const entries = createReader({ + integrations: {}, + }); + expect(entries).toHaveLength(0); + }); + + it('creates a reader with credentials correctly configured', () => { + const entries = createReader({ + integrations: { + googleGcs: { + privateKey: '--- BEGIN KEY ---- fakekey --- END KEY ---', + clientEmail: 'someone@example.com', + }, + }, + }); + expect(entries).toHaveLength(1); + }); + + it('creates a reader with default credentials provider', () => { + const entries = createReader({ + integrations: { + googleGcs: {}, + }, + }); + expect(entries).toHaveLength(1); + }); + + describe('predicates', () => { + const readers = createReader({ + integrations: { + googleGcs: {}, + }, + }); + const predicate = readers[0].predicate; + + it('returns true for the correct google cloud storage host', () => { + expect(predicate(new URL('https://storage.cloud.google.com'))).toBe(true); + }); + it('returns true for a url with the full path and the correct host', () => { + expect( + predicate( + new URL( + 'https://storage.cloud.google.com/team1/service1/catalog-info.yaml', + ), + ), + ).toBe(true); + }); + it('returns false for the wrong hostname under cloud.google.com', () => { + expect(predicate(new URL('https://storage2.cloud.google.com'))).toBe( + false, + ); + }); + it('returns false for a partially correct host', () => { + expect(predicate(new URL('https://cloud.google.com'))).toBe(false); + }); + it('returns false for a completely different host', () => { + expect(predicate(new URL('https://a.example.com/test'))).toBe(false); + }); + }); +}); diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts new file mode 100644 index 0000000000..4ea24fa22f --- /dev/null +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts @@ -0,0 +1,105 @@ +/* + * 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 { Storage } from '@google-cloud/storage'; +import { + ReaderFactory, + ReadTreeResponse, + SearchResponse, + UrlReader, +} from './types'; +import getRawBody from 'raw-body'; +import { + GoogleGcsIntegrationConfig, + readGoogleGcsIntegrationConfig, +} from '@backstage/integration'; + +const GOOGLE_GCS_HOST = 'storage.cloud.google.com'; + +const parseURL = ( + url: string, +): { host: string; bucket: string; key: string } => { + const { host, pathname } = new URL(url); + + if (host !== GOOGLE_GCS_HOST) { + throw new Error(`not a valid GCS URL: ${url}`); + } + + const [, bucket, ...key] = pathname.split('/'); + return { + host: host, + bucket, + key: key.join('/'), + }; +}; + +export class GoogleGcsUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config, logger }) => { + if (!config.has('integrations.googleGcs')) { + return []; + } + const gcsConfig = readGoogleGcsIntegrationConfig( + config.getConfig('integrations.googleGcs'), + ); + let storage: Storage; + if (!gcsConfig.clientEmail || !gcsConfig.privateKey) { + logger.info( + 'googleGcs credentials not found in config. Using default credentials provider.', + ); + storage = new Storage(); + } else { + storage = new Storage({ + credentials: { + client_email: gcsConfig.clientEmail || undefined, + private_key: gcsConfig.privateKey || undefined, + }, + }); + } + const reader = new GoogleGcsUrlReader(gcsConfig, storage); + const predicate = (url: URL) => url.host === GOOGLE_GCS_HOST; + return [{ reader, predicate }]; + }; + + constructor( + private readonly integration: GoogleGcsIntegrationConfig, + private readonly storage: Storage, + ) {} + + async read(url: string): Promise { + try { + const { bucket, key } = parseURL(url); + + return await getRawBody( + this.storage.bucket(bucket).file(key).createReadStream(), + ); + } catch (error) { + throw new Error(`unable to read gcs file from ${url}, ${error}`); + } + } + + async readTree(): Promise { + throw new Error('GcsUrlReader does not implement readTree'); + } + + async search(): Promise { + throw new Error('GcsUrlReader does not implement search'); + } + + toString() { + const key = this.integration.privateKey; + return `googleGcs{host=${GOOGLE_GCS_HOST},authed=${Boolean(key)}}`; + } +} diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 2f233463bb..06c6fdcdcf 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -24,6 +24,7 @@ import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; import { ReadTreeResponseFactory } from './tree'; import { FetchUrlReader } from './FetchUrlReader'; +import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; type CreateOptions = { /** Root config object */ @@ -70,6 +71,7 @@ export class UrlReaders { BitbucketUrlReader.factory, GithubUrlReader.factory, GitlabUrlReader.factory, + GoogleGcsUrlReader.factory, FetchUrlReader.factory, ]), }); diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index acef405c8a..02aab45609 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -16,11 +16,13 @@ WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +COPY yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +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 ./ +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz CMD ["node", "packages/backend", "--config", "app-config.yaml"] diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 9883f2afbc..d0387cd15d 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -48,6 +48,9 @@ export default async function createPlugin({ discovery, }); + // checks if the publisher is working and logs the result + await publisher.getReadiness(); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 8364560625..0076202c64 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/catalog-client +## 0.3.10 + +### Patch Changes + +- 442f34b87: Make sure the `CatalogClient` escapes URL parameters correctly. +- Updated dependencies [bb5055aee] +- Updated dependencies [5d0740563] + - @backstage/catalog-model@0.7.7 + ## 0.3.9 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index ea61f3eded..91845e56ab 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,13 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.8", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 7426aabaac..3de25e9808 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -42,10 +42,14 @@ export class CatalogClient implements CatalogApi { } async getLocationById( - id: String, + id: string, options?: CatalogRequestOptions, ): Promise { - return await this.requestOptional('GET', `/locations/${id}`, options); + return await this.requestOptional( + 'GET', + `/locations/${encodeURIComponent(id)}`, + options, + ); } async getEntities( @@ -86,7 +90,9 @@ export class CatalogClient implements CatalogApi { const { kind, namespace = 'default', name } = compoundName; return this.requestOptional( 'GET', - `/entities/by-name/${kind}/${namespace}/${name}`, + `/entities/by-name/${encodeURIComponent(kind)}/${encodeURIComponent( + namespace, + )}/${encodeURIComponent(name)}`, options, ); } @@ -171,14 +177,22 @@ export class CatalogClient implements CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise { - await this.requestIgnored('DELETE', `/locations/${id}`, options); + await this.requestIgnored( + 'DELETE', + `/locations/${encodeURIComponent(id)}`, + options, + ); } async removeEntityByUid( uid: string, options?: CatalogRequestOptions, ): Promise { - await this.requestIgnored('DELETE', `/entities/by-uid/${uid}`, options); + await this.requestIgnored( + 'DELETE', + `/entities/by-uid/${encodeURIComponent(uid)}`, + options, + ); } // diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts index 04cbb2b689..0d25bf7483 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types.ts @@ -46,7 +46,7 @@ export interface CatalogApi { // Locations getLocationById( - id: String, + id: string, options?: CatalogRequestOptions, ): Promise; getOriginLocationByEntity( diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 0ad5d6993f..445da3afab 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/catalog-model +## 0.7.7 + +### Patch Changes + +- bb5055aee: Add getEntitySourceLocation helper +- 5d0740563: Implemented missing support for the dependsOn/dependencyOf relationships + between `Component` and `Resource` catalog model objects. + + Added support for generating the relevant relationships to the + `BuiltinKindsEntityProcessor`, and added simple support for fetching + relationships between `Components` and `Resources` for rendering in the + system diagram. All catalog-model changes backwards compatible. + +## 0.7.6 + +### Patch Changes + +- 97b60de98: Added `stringifyEntityRef`, which always creates a string representation of an entity reference. Also deprecated `serializeEntityRef`, as `stringifyEntityRef` should be used instead. +- 98dd5da71: Add support for multiple links to post-scaffold task summary page + ## 0.7.5 ### Patch Changes diff --git a/packages/catalog-model/examples/apis/spotify-api.yaml b/packages/catalog-model/examples/apis/spotify-api.yaml index 0b8e80b86e..1110627473 100644 --- a/packages/catalog-model/examples/apis/spotify-api.yaml +++ b/packages/catalog-model/examples/apis/spotify-api.yaml @@ -14,4 +14,4 @@ spec: lifecycle: production owner: team-a definition: - $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml + $text: https://github.com/APIs-guru/openapi-directory/blob/dab6854d4d599aafb0eb36e6c7ae1fe0c37509b7/APIs/spotify.com/2021.4.2/openapi.yaml diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 1e5239cc23..64550b35d1 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.7.5", + "version": "0.7.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -39,7 +39,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.8", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 9284a5d5b1..358e7b6526 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -36,6 +36,7 @@ describe('ComponentV1alpha1Validator', () => { subcomponentOf: 'monolith', providesApis: ['api-0'], consumesApis: ['api-0'], + dependsOn: ['resource:resource-0', 'component:component-0'], system: 'system', }, }; @@ -160,6 +161,26 @@ describe('ComponentV1alpha1Validator', () => { await expect(validator.check(entity)).resolves.toBe(true); }); + it('accepts missing dependsOn', async () => { + delete (entity as any).spec.dependsOn; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty dependsOn', async () => { + (entity as any).spec.dependsOn = ['']; + await expect(validator.check(entity)).rejects.toThrow(/dependsOn/); + }); + + it('rejects undefined dependsOn', async () => { + (entity as any).spec.dependsOn = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/dependsOn/); + }); + + it('accepts no dependsOn', async () => { + (entity as any).spec.dependsOn = []; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('accepts missing system', async () => { delete (entity as any).spec.system; await expect(validator.check(entity)).resolves.toBe(true); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 3006a4288c..489aa8b1bd 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -34,6 +34,7 @@ export interface ComponentEntityV1alpha1 extends Entity { subcomponentOf?: string; providesApis?: string[]; consumesApis?: string[]; + dependsOn?: string[]; system?: string; }; } diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts index ad8ea5cdf3..953ec5889a 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts @@ -32,6 +32,7 @@ describe('ResourceV1alpha1Validator', () => { spec: { type: 'database', owner: 'me', + dependsOn: ['component:component-0', 'resource:resource-0'], system: 'system', }, }; @@ -86,6 +87,26 @@ describe('ResourceV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/owner/); }); + it('accepts missing dependsOn', async () => { + delete (entity as any).spec.dependsOn; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty dependsOn', async () => { + (entity as any).spec.dependsOn = ['']; + await expect(validator.check(entity)).rejects.toThrow(/dependsOn/); + }); + + it('rejects undefined dependsOn', async () => { + (entity as any).spec.dependsOn = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/dependsOn/); + }); + + it('accepts no dependsOn', async () => { + (entity as any).spec.dependsOn = []; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('accepts missing system', async () => { delete (entity as any).spec.system; await expect(validator.check(entity)).resolves.toBe(true); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index 520a39f02c..fd71500f40 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -30,6 +30,7 @@ export interface ResourceEntityV1alpha1 extends Entity { spec: { type: string; owner: string; + dependsOn?: string[]; system?: string; }; } diff --git a/packages/catalog-model/src/location/helpers.test.ts b/packages/catalog-model/src/location/helpers.test.ts index 888c79b328..3b5994b956 100644 --- a/packages/catalog-model/src/location/helpers.test.ts +++ b/packages/catalog-model/src/location/helpers.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { parseLocationReference, stringifyLocationReference } from './helpers'; +import { + getEntitySourceLocation, + parseLocationReference, + stringifyLocationReference, +} from './helpers'; describe('parseLocationReference', () => { it('works for the simple case', () => { @@ -68,3 +72,48 @@ describe('stringifyLocationReference', () => { ).toThrow('Unable to stringify location reference, empty target'); }); }); + +describe('getEntitySourceLocation', () => { + it('returns the source-location', () => { + expect( + getEntitySourceLocation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'test', + namespace: 'default', + annotations: { + 'backstage.io/source-location': 'url:https://backstage.io/foo.yaml', + 'backstage.io/managed-by-location': 'url:https://spotify.com', + }, + }, + }), + ).toEqual({ target: 'https://backstage.io/foo.yaml', type: 'url' }); + }); + + it('returns the managed-by-location', () => { + expect( + getEntitySourceLocation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'test', + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': 'url:https://spotify.com', + }, + }, + }), + ).toEqual({ target: 'https://spotify.com', type: 'url' }); + }); + + it('rejects missing location annotation', () => { + expect(() => + getEntitySourceLocation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { name: 'test', namespace: 'default' }, + }), + ).toThrow(`Entity 'location:default/test' is missing location`); + }); +}); diff --git a/packages/catalog-model/src/location/helpers.ts b/packages/catalog-model/src/location/helpers.ts index fb1be5abad..5eff598c87 100644 --- a/packages/catalog-model/src/location/helpers.ts +++ b/packages/catalog-model/src/location/helpers.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { Entity, stringifyEntityRef } from '../entity'; +import { LOCATION_ANNOTATION, SOURCE_LOCATION_ANNOTATION } from './annotation'; + /** * Parses a string form location reference. * @@ -80,3 +83,26 @@ export function stringifyLocationReference(ref: { return `${type}:${target}`; } + +/** + * Returns the source code location of the Entity, to the extent that one exists. + * + * If the returned location type is of type 'url', the target should be readable at least + * using the UrlReader from @backstage/backend-common. If it is not of type 'url', the caller + * needs to have explicit handling of each location type or signal that it is not supported. + */ +export function getEntitySourceLocation( + entity: Entity, +): { type: string; target: string } { + const locationRef = + entity.metadata?.annotations?.[SOURCE_LOCATION_ANNOTATION] ?? + entity.metadata?.annotations?.[LOCATION_ANNOTATION]; + + if (!locationRef) { + throw new Error( + `Entity '${stringifyEntityRef(entity)}' is missing location`, + ); + } + + return parseLocationReference(locationRef); +} diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index fddc8bde37..751172c6ba 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -19,7 +19,11 @@ export { ORIGIN_LOCATION_ANNOTATION, SOURCE_LOCATION_ANNOTATION, } from './annotation'; -export { parseLocationReference, stringifyLocationReference } from './helpers'; +export { + parseLocationReference, + stringifyLocationReference, + getEntitySourceLocation, +} from './helpers'; export type { Location, LocationSpec } from './types'; export { analyzeLocationSchema, diff --git a/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json index 822059a0ea..34fc48d285 100644 --- a/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json @@ -84,6 +84,14 @@ "type": "string", "minLength": 1 } + }, + "dependsOn": { + "type": "array", + "description": "An array of references to other entities that the component depends on to function.", + "items": { + "type": "string", + "minLength": 1 + } } } } diff --git a/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json index b426dd7f94..4958afc18c 100644 --- a/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json @@ -47,6 +47,14 @@ "examples": ["artist-relations-team", "user:john.johnson"], "minLength": 1 }, + "dependsOn": { + "type": "array", + "description": "An array of references to other entities that the resource depends on to function.", + "items": { + "type": "string", + "minLength": 1 + } + }, "system": { "type": "string", "description": "An entity reference to the system that the resource belongs to.", 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 bd9999f8bd..ba3ccb9d1d 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -135,6 +135,36 @@ "output": { "type": "object", "description": "A templated object describing the outputs of the scaffolding task.", + "properties": { + "links": { + "type": "array", + "description": "A list of external hyperlinks, typically pointing to resources created or updated by the template", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "A url in a standard uri format.", + "examples": ["https://github.com/my-org/my-new-repo"], + "minLength": 1 + }, + "title": { + "type": "string", + "description": "A user friendly display name for the link.", + "examples": ["View new repo"], + "minLength": 1 + }, + "icon": { + "type": "string", + "description": "A key representing a visual icon to be displayed in the UI.", + "examples": ["dashboard"], + "minLength": 1 + } + } + } + } + }, "additionalProperties": { "type": "string" } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index c29669f31c..a1381495ff 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/cli +## 0.6.8 + +### Patch Changes + +- 60ce64aa2: Disable hot reloading in CI environments. + +## 0.6.7 + +### Patch Changes + +- Updated dependencies [82c66b8cd] + - @backstage/config-loader@0.6.0 + ## 0.6.6 ### Patch Changes diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index a55b215f74..f6b4f65edd 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -11,7 +11,7 @@ "incremental": true, "isolatedModules": true, "jsx": "react", - "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"], + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020"], "module": "ESNext", "moduleResolution": "node", "noEmit": false, diff --git a/packages/cli/package.json b/packages/cli/package.json index 01306eba2a..0a8561fa16 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.6", + "version": "0.6.8", "private": false, "publishConfig": { "access": "public" @@ -32,7 +32,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.5.1", + "@backstage/config-loader": "^0.6.0", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", @@ -116,11 +116,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.2", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@backstage/theme": "^0.2.5", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 5fc3a7b5de..24a24d25d2 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -43,7 +43,7 @@ export async function serveBundle(options: ServeOptions) { const compiler = webpack(config); const server = new WebpackDevServer(compiler, { - hot: true, + hot: !process.env.CI, contentBase: paths.targetPublic, contentBasePublicPath: config.output?.publicPath, publicPath: config.output?.publicPath, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 4f097343c2..6107485d78 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -74,7 +74,7 @@ export const transforms = (options: TransformOptions): Transforms => { /\.jpe?g$/, /\.png$/, /\.frag/, - { test: /\.svg/, not: [/\.icon\.svg/] }, + { and: [/\.svg/, { not: [/\.icon\.svg/] }] }, /\.xml/, ], loader: require.resolve('url-loader'), diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index cbc6473286..cb8ef123ce 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/config-loader +## 0.6.0 + +### Minor Changes + +- 82c66b8cd: Fix bug where `${...}` was not being escaped to `${...}` + + Add support for environment variable substitution in `$include`, `$file` and + `$env` transform values. + + - This change allows for including dynamic paths, such as environment specific + secrets by using the same environment variable substitution (`${..}`) already + supported outside of the various include transforms. + - If you are currently using the syntax `${...}` in your include transform values, + you will need to escape the substitution by using `${...}` instead to maintain + the same behavior. + ## 0.5.1 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index a54975c6ff..0a0a76c2d6 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.5.1", + "version": "0.6.0", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index f8f38cbf59..609940b8db 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/core-api +## 0.2.16 + +### Patch Changes + +- 1279a3325: Introduce a `load-chunk` step in the `BootErrorPage` to show make chunk loading + errors visible to the user. +- 4a4681b1b: Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error. +- b051e770c: Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail. + ## 0.2.15 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 411fa44c0d..33e5ac146d 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.15", + "version": "0.2.16", "private": false, "publishConfig": { "access": "public", @@ -42,8 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.6", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.7", + "@backstage/test-utils": "^0.1.10", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 5bdf51837d..cf3aaac931 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -24,7 +24,7 @@ import { AppConfig } from '@backstage/config'; import { SubRouteRef } from '../routing/types'; export type BootErrorPageProps = { - step: 'load-config'; + step: 'load-config' | 'load-chunk'; error: Error; }; diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 9463e437e3..8ff4055e61 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -15,9 +15,10 @@ */ import React, { lazy, Suspense } from 'react'; +import { useApp } from '../app'; +import { BackstagePlugin, Extension } from '../plugin/types'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; -import { Extension, BackstagePlugin } from '../plugin/types'; type ComponentLoader = | { @@ -40,30 +41,41 @@ export function createRoutableExtension< return createReactExtension({ component: { lazy: () => - component().then(InnerComponent => { - const RoutableExtensionWrapper: any = (props: any) => { - // Validate that the routing is wired up correctly in the App.tsx - try { - useRouteRef(mountPoint); - } catch { - throw new Error( - `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + - 'Routable extension components may not be rendered by other components and must be ' + - 'directly available as an element within the App provider component.', - ); - } - return ; - }; + component().then( + InnerComponent => { + const RoutableExtensionWrapper: any = (props: any) => { + // Validate that the routing is wired up correctly in the App.tsx + try { + useRouteRef(mountPoint); + } catch { + throw new Error( + `Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + return ; + }; - const componentName = - (InnerComponent as { displayName?: string }).displayName || - InnerComponent.name || - 'LazyComponent'; + const componentName = + (InnerComponent as { displayName?: string }).displayName || + InnerComponent.name || + 'LazyComponent'; - RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`; + RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`; - return RoutableExtensionWrapper as T; - }), + return RoutableExtensionWrapper as T; + }, + error => { + const RoutableExtensionWrapper: any = (_: any) => { + const app = useApp(); + const { BootErrorPage } = app.getComponents(); + + return ; + }; + return RoutableExtensionWrapper; + }, + ), }, data: { 'core.mountPoint': mountPoint, diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx index e638131a28..fd3d0dcdab 100644 --- a/packages/core-api/src/icons/icons.tsx +++ b/packages/core-api/src/icons/icons.tsx @@ -15,6 +15,7 @@ */ import { SvgIconProps } from '@material-ui/core'; +import MuiMenuBookIcon from '@material-ui/icons/MenuBook'; import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; import MuiChatIcon from '@material-ui/icons/Chat'; import MuiDashboardIcon from '@material-ui/icons/Dashboard'; @@ -32,6 +33,8 @@ import { IconComponent, IconComponentMap, SystemIconKey } from './types'; export const defaultSystemIcons: IconComponentMap = { brokenImage: MuiBrokenImageIcon, + // To be confirmed: see https://github.com/backstage/backstage/issues/4970 + catalog: MuiMenuBookIcon, chat: MuiChatIcon, dashboard: MuiDashboardIcon, email: MuiEmailIcon, diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 45f26804a5..7f691a2787 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/core +## 0.7.5 + +### Patch Changes + +- d0d1c2f7b: Pass `inverse` prop to Gauge from GaugeCard +- 5cafcf452: add debounce time attribute for apis-docs for search, giving more time to the users when they are typing. +- 86a95ba67: exposes undocumented `PageTheme` +- e27cb6c45: Don't use a drag & drop cursor when clicking on disabled `IconLinkVertical`. + +## 0.7.4 + +### Patch Changes + +- 1279a3325: Introduce a `load-chunk` step in the `BootErrorPage` to show make chunk loading + errors visible to the user. +- 4a4681b1b: Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error. +- b051e770c: Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail. +- 98dd5da71: Add support for multiple links to post-scaffold task summary page +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [b051e770c] + - @backstage/core-api@0.2.16 + ## 0.7.3 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 051bacc4c2..7b1898a19d 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.3", + "version": "0.7.5", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core-api": "^0.2.15", + "@backstage/core-api": "^0.2.16", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -69,8 +69,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.6", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.8", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx index 31f584b68c..7eaec32b86 100644 --- a/packages/core/src/api-wrappers/createApp.test.tsx +++ b/packages/core/src/api-wrappers/createApp.test.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ -import { defaultConfigLoader } from './createApp'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { defaultConfigLoader, OptionallyWrapInRouter } from './createApp'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; @@ -92,3 +95,23 @@ describe('defaultConfigLoader', () => { ]); }); }); + +describe('OptionallyWrapInRouter', () => { + it('should wrap with router if not yet inside a router', async () => { + const { getByText } = render( + Test, + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); + + it('should not wrap with router if already inside a router', async () => { + const { getByText } = render( + + Test + , + ); + + expect(getByText('Test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index db8d0d0636..e79cdf509f 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import privateExports, { AppOptions, defaultSystemIcons, BootErrorPageProps, AppConfigLoader, } from '@backstage/core-api'; -import { BrowserRouter, MemoryRouter } from 'react-router-dom'; +import { + BrowserRouter, + MemoryRouter, + useInRouterContext, +} from 'react-router-dom'; import LightIcon from '@material-ui/icons/WbSunny'; import DarkIcon from '@material-ui/icons/Brightness2'; import { ErrorPage } from '../layout/ErrorPage'; @@ -89,6 +93,13 @@ export const defaultConfigLoader: AppConfigLoader = async ( // The actual implementation of the app class still lives in core-api, // as it needs to be used by dev- and test-utils. +export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) { + if (useInRouterContext()) { + return <>{children}; + } + return {children}; +} + /** * Creates a new Backstage App. */ @@ -100,12 +111,14 @@ export function createApp(options?: AppOptions) { let message = ''; if (step === 'load-config') { message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; + } else if (step === 'load-chunk') { + message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`; } // TODO: figure out a nicer way to handle routing on the error page, when it can be done. return ( - + - + ); }; diff --git a/packages/core/src/components/Dialog/Dialog.stories.tsx b/packages/core/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000000..c9388dcbec --- /dev/null +++ b/packages/core/src/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,127 @@ +/* + * 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 { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Typography, +} from '@material-ui/core'; +import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; +import CloseIcon from '@material-ui/icons/Close'; +import React, { useState } from 'react'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }), +); + +export default { + title: 'Layout/Dialog', + component: Dialog, +}; + +export const Default = () => { + const [open, setOpen] = useState(false); + const classes = useStyles(); + + const openDialog = () => { + setOpen(true); + }; + + const closeDialog = () => { + setOpen(false); + }; + + const dialogContent = () => { + return ( + <> + + This is an example of how to use the Dialog component. + + + This component is used whenever confirmation of some sort is needed, + such as: + +
    +
  • + + Consent to sensitive matters like GDPR, access, etc; + +
  • +
  • + + Save, submit, cancel after a form is completed; + +
  • +
  • + Alert message; +
  • +
  • + Buttons are optional. +
  • +
+ + The color for the secondary button is the same as the primary. + +
color="primary"
+ + ); + }; + + return ( + <> + + + + Dialog Box Title + + + + + {dialogContent()} + + + + + + + ); +}; diff --git a/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx index b3e665820a..425bed3490 100644 --- a/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx +++ b/packages/core/src/components/HeaderIconLinkRow/IconLinkVertical.tsx @@ -35,12 +35,10 @@ const useIconStyles = makeStyles(theme => ({ justifyItems: 'center', gridGap: 4, textAlign: 'center', - '&:active': { - cursor: 'grabbing', - }, }, disabled: { color: 'gray', + cursor: 'default', }, primary: { color: theme.palette.primary.main, diff --git a/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx index 982999f8d6..5d54e5eda6 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx @@ -45,6 +45,9 @@ export const Default = () => ( + + + ); @@ -67,6 +70,14 @@ export const Subhead = () => ( progress={0.89} />
+ + + ); @@ -81,5 +92,8 @@ export const LinkInFooter = () => ( + + + ); diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index 2154619b01..8dcec129c5 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -26,6 +26,7 @@ type Props = { variant?: InfoCardVariants; /** Progress in % specified as decimal, e.g. "0.23" */ progress: number; + inverse?: boolean; deepLink?: BottomLinkProps; }; @@ -38,7 +39,7 @@ const useStyles = makeStyles({ export const GaugeCard = (props: Props) => { const classes = useStyles(props); - const { title, subheader, progress, deepLink, variant } = props; + const { title, subheader, progress, inverse, deepLink, variant } = props; return (
@@ -48,7 +49,7 @@ export const GaugeCard = (props: Props) => { deepLink={deepLink} variant={variant} > - +
); diff --git a/packages/core/src/hooks/useQueryParamState.ts b/packages/core/src/hooks/useQueryParamState.ts index dc2279cb53..da303234f5 100644 --- a/packages/core/src/hooks/useQueryParamState.ts +++ b/packages/core/src/hooks/useQueryParamState.ts @@ -58,6 +58,7 @@ type SetQueryParams = (params: T) => void; export function useQueryParamState( stateName: string, + debounceTime: number = 100, ): [T | undefined, SetQueryParams] { const navigate = useNavigate(); const location = useLocation(); @@ -85,7 +86,7 @@ export function useQueryParamState( navigate({ ...location, search: `?${queryString}` }, { replace: true }); } }, - 100, + debounceTime, [queryParamState], ); diff --git a/packages/core/src/layout/Header/Header.stories.tsx b/packages/core/src/layout/Header/Header.stories.tsx index badd63a5ad..db4ddac8ec 100644 --- a/packages/core/src/layout/Header/Header.stories.tsx +++ b/packages/core/src/layout/Header/Header.stories.tsx @@ -45,6 +45,14 @@ export const HomeWithSubtitle = () => ( ); +export const Apis = () => ( + +
+ {labels} +
+
+); + export const Tool = () => (
diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index c3e5b75bb7..daefe40c73 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -22,7 +22,7 @@ import { ContentHeader } from '../ContentHeader/ContentHeader'; import { Grid, Button, Typography } from '@material-ui/core'; import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders, SignInConfig } from './types'; +import { IdentityProviders, SignInProviderConfig } from './types'; import { Progress } from '../../components/Progress'; import { GridItem, useStyles } from './styles'; import { InfoCard } from '../InfoCard'; @@ -34,7 +34,7 @@ type MultiSignInPageProps = SignInPageProps & { }; type SingleSignInPageProps = SignInPageProps & { - provider: SignInConfig; + provider: SignInProviderConfig; auto?: boolean; }; diff --git a/packages/core/src/layout/SignInPage/commonProvider.tsx b/packages/core/src/layout/SignInPage/commonProvider.tsx index b7480afa49..591efe9ece 100644 --- a/packages/core/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core/src/layout/SignInPage/commonProvider.tsx @@ -21,13 +21,13 @@ import { ProviderComponent, ProviderLoader, SignInProvider, - SignInConfig, + SignInProviderConfig, } from './types'; import { useApi, errorApiRef } from '@backstage/core-api'; import { GridItem } from './styles'; const Component: ProviderComponent = ({ config, onResult }) => { - const { apiRef, title, message } = config as SignInConfig; + const { apiRef, title, message } = config as SignInProviderConfig; const authApi = useApi(apiRef); const errorApi = useApi(errorApiRef); diff --git a/packages/core/src/layout/SignInPage/index.ts b/packages/core/src/layout/SignInPage/index.ts index 49f55aefc5..b10ea7ae33 100644 --- a/packages/core/src/layout/SignInPage/index.ts +++ b/packages/core/src/layout/SignInPage/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export type { SignInProviderConfig } from './types'; export { SignInPage } from './SignInPage'; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index e2c17ab80d..1ea32220b0 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -22,7 +22,11 @@ import { useApiHolder, errorApiRef, } from '@backstage/core-api'; -import { SignInConfig, IdentityProviders, SignInProvider } from './types'; +import { + IdentityProviders, + SignInProvider, + SignInProviderConfig, +} from './types'; import { commonProvider } from './commonProvider'; import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; @@ -33,7 +37,7 @@ export type SignInProviderType = { [key: string]: { components: SignInProvider; id: string; - config?: SignInConfig; + config?: SignInProviderConfig; }; }; @@ -62,7 +66,7 @@ export function getSignInProviders( return acc; } - const { id } = config as SignInConfig; + const { id } = config as SignInProviderConfig; validateIDs(id, acc); acc[id] = { components: signInProviders.common, id, config }; diff --git a/packages/core/src/layout/SignInPage/types.ts b/packages/core/src/layout/SignInPage/types.ts index 48945e6988..0c78549d0c 100644 --- a/packages/core/src/layout/SignInPage/types.ts +++ b/packages/core/src/layout/SignInPage/types.ts @@ -25,17 +25,17 @@ import { SessionApi, } from '@backstage/core-api'; -export type SignInConfig = { +export type SignInProviderConfig = { id: string; title: string; message: string; apiRef: ApiRef; }; -export type IdentityProviders = ('guest' | 'custom' | SignInConfig)[]; +export type IdentityProviders = ('guest' | 'custom' | SignInProviderConfig)[]; export type ProviderComponent = ComponentType< - SignInPageProps & { config: SignInConfig } + SignInPageProps & { config: SignInProviderConfig } >; export type ProviderLoader = ( diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 762cfbc0d5..5975b78e06 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,339 @@ # @backstage/create-app +## 0.3.18 + +### Patch Changes + +- b49a525ab: Fixing dependency resolution for problematic library `graphql-language-service-interface`. + + This change might not have to be applied to your local installation, however if you run into this error: + + ``` + Error: Failed to compile. + /tmp/backstage-e2e-uMeycm/test-app/node_modules/graphql-language-service-interface/esm/GraphQLLanguageService.js 100:23 + Module parse failed: Unexpected token (100:23) + You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders + | } + | let customRules = null; + > if (extensions?.customValidationRules && + | typeof extensions.customValidationRules === 'function') { + | customRules = extensions.customValidationRules(this._graphQLConfig); + ``` + + You can fix it by adding the following to the root `package.json`. + + ```json + ... + "resolutions": { + "graphql-language-service-interface": "2.8.2", + "graphql-language-service-parser": "1.9.0" + }, + ... + ``` + +- a360f9478: Expose the catalog-import route as an external route from the scaffolder. + + This will make it possible to hide the "Register Existing Component" button + when you for example are running backstage with `catalog.readonly=true`. + + As a consequence of this change you need add a new binding to your createApp call to + keep the button visible. However, if you instead want to hide the button you can safely + ignore the following example. + + To bind the external route from the catalog-import plugin to the scaffolder template + index page, make sure you have the appropriate imports and add the following + to the createApp call: + + ```typescript + import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; + + const app = createApp({ + // ... + bindRoutes({ bind }) { + // ... + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + }); + }, + }); + ``` + +- f1952337c: Due to a change in the techdocs publishers, they don't check if they are able to reach e.g. the configured S3 bucket anymore. + This can be added again by the following change. Note that the backend process will no longer exit when it is not reachable but will only emit an error log message. + You should include the check when your backend to get early feedback about a potential misconfiguration: + + ```diff + // packages/backend/src/plugins/techdocs.ts + + export default async function createPlugin({ + logger, + config, + discovery, + reader, + }: PluginEnvironment): Promise { + // ... + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }) + + + // checks if the publisher is working and logs the result + + await publisher.getReadiness(); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + + // ... + } + ``` + +- Updated dependencies [d8ffec739] +- Updated dependencies [7abec4dbc] +- Updated dependencies [017192ee8] +- Updated dependencies [a360f9478] +- Updated dependencies [bb5055aee] +- Updated dependencies [d840d30bc] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [b25846562] +- Updated dependencies [12390778e] +- Updated dependencies [cba5944fc] +- Updated dependencies [a376e3ee8] +- Updated dependencies [fef852ecd] +- Updated dependencies [18f7345a6] +- Updated dependencies [5cafcf452] +- Updated dependencies [423a514c3] +- Updated dependencies [86a95ba67] +- Updated dependencies [442f34b87] +- Updated dependencies [e27cb6c45] +- Updated dependencies [184b02bef] +- Updated dependencies [0b7fd7a9d] +- Updated dependencies [60ce64aa2] + - @backstage/plugin-scaffolder-backend@0.9.6 + - @backstage/plugin-catalog-backend@0.7.1 + - @backstage/plugin-scaffolder@0.9.0 + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + - @backstage/plugin-catalog@0.5.4 + - @backstage/plugin-api-docs@0.4.11 + - @backstage/plugin-techdocs-backend@0.7.1 + - @backstage/plugin-techdocs@0.7.2 + - @backstage/catalog-client@0.3.10 + - @backstage/plugin-tech-radar@0.3.9 + - @backstage/cli@0.6.8 + +## 0.3.17 + +### Patch Changes + +- 3e7de08af: **Fully migrated the template to the new composability API** + + The `create-app` template is now fully migrated to the new composability API, see [Composability System Migration Documentation](https://backstage.io/docs/plugins/composability) for explanations and more details. The final change which is now done was to migrate the `EntityPage` from being a component built on top of the `EntityPageLayout` and several more custom components, to an element tree built with `EntitySwitch` and `EntityLayout`. + + To apply this change to an existing plugin, it is important that all plugins that you are using have already been migrated. In this case the most crucial piece is that no entity page cards of contents may require the `entity` prop, and they must instead consume the entity from context using `useEntity`. + + Since this change is large with a lot of repeated changes, we'll describe a couple of common cases rather than the entire change. If your entity pages are unchanged from the `create-app` template, you can also just bring in the latest version directly from the [template itself](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx). + + The first step of the change is to change the `packages/app/src/components/catalog/EntityPage.tsx` export to `entityPage` rather than `EntityPage`. This will require an update to `App.tsx`, which is the only change we need to do outside of `EntityPage.tsx`: + + ```diff + -import { EntityPage } from './components/catalog/EntityPage'; + +import { entityPage } from './components/catalog/EntityPage'; + + } + > + - + + {entityPage} + + ``` + + The rest of the changes happen within `EntityPage.tsx`, and can be split into two broad categories, updating page components, and updating switch components. + + #### Migrating Page Components + + Let's start with an example of migrating a user page component. The following is the old code in the template: + + ```tsx + const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( + + + + + + + + + ); + + const UserEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + + ); + ``` + + There's the main `UserEntityPage` component, and the `UserOverviewContent` component. Let's start with migrating the page contents, which we do by rendering an element rather than creating a component, as well as replace the cards with their new composability compatible variants. The new cards and content components can be identified by the `Entity` prefix. + + ```tsx + const userOverviewContent = ( + + + + + + + + + ); + ``` + + Now let's migrate the page component, again by converting it into a rendered element instead of a component, as well as replacing the use of `EntityPageLayout` with `EntityLayout`. + + ```tsx + const userPage = ( + + + {userOverviewContent} + + + ); + ``` + + At this point the `userPage` is quite small, so throughout this migration we have inlined the page contents for all pages. This is an optional step, but may help reduce noise. The final page now looks like this: + + ```tsx + const userPage = ( + + + + + + + + + + + + + ); + ``` + + #### Migrating Switch Components + + Switch components were used to select what entity page components or cards to render, based on for example the kind of entity. For this example we'll focus on the root `EntityPage` switch component, but the process is the same for example for the CI/CD switcher. + + The old `EntityPage` looked like this: + + ```tsx + export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLocaleLowerCase('en-US')) { + case 'component': + return ; + case 'api': + return ; + case 'group': + return ; + case 'user': + return ; + case 'system': + return ; + case 'domain': + return ; + case 'location': + case 'resource': + case 'template': + default: + return ; + } + }; + ``` + + In order to migrate to the composability API, we need to make this an element instead of a component, which means we're unable to keep the switch statement as is. To help with this, the catalog plugin provides an `EntitySwitch` component, which functions similar to a regular `switch` statement, which the first match being the one that is rendered. The catalog plugin also provides a number of built-in filter functions to use, such as `isKind` and `isComponentType`. + + To migrate the `EntityPage`, we convert the `switch` statement into an `EntitySwitch` element, and each `case` statement into an `EntitySwitch.Case` element. We also move over to use our new element version of the page components, with the result looking like this: + + ```tsx + export const entityPage = ( + + + + + + + + + {defaultEntityPage} + + ); + ``` + + Another example is the `ComponentEntityPage`, which is migrated from this: + + ```tsx + export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { + switch (entity?.spec?.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } + }; + ``` + + To this: + + ```tsx + const componentPage = ( + + + {serviceEntityPage} + + + + {websiteEntityPage} + + + {defaultEntityPage} + + ); + ``` + + Note that if you want to conditionally render some piece of content, you can omit the default `EntitySwitch.Case`. If no case is matched in an `EntitySwitch`, nothing will be rendered. + +- Updated dependencies [802b41b65] +- Updated dependencies [2b2b31186] +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [3f96a9d5a] +- Updated dependencies [b051e770c] +- Updated dependencies [f9c75f7a9] +- Updated dependencies [ae6250ce3] +- Updated dependencies [98dd5da71] +- Updated dependencies [b779b5fee] + - @backstage/plugin-scaffolder-backend@0.9.5 + - @backstage/plugin-auth-backend@0.3.8 + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + - @backstage/plugin-scaffolder@0.8.2 + - @backstage/plugin-catalog-import@0.5.2 + - @backstage/test-utils@0.1.10 + - @backstage/plugin-catalog@0.5.3 + - @backstage/backend-common@0.6.2 + - @backstage/cli@0.6.7 + - @backstage/plugin-app-backend@0.3.11 + ## 0.3.16 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index e62a544ba1..ea3d3548b8 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.16", + "version": "0.3.18", "private": false, "publishConfig": { "access": "public" diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index e7e44c9961..442e90944e 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -25,14 +25,10 @@ backend: database: client: pg connection: - host: - $env: POSTGRES_HOST - port: - $env: POSTGRES_PORT - user: - $env: POSTGRES_USER - password: - $env: POSTGRES_PASSWORD + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} # https://node-postgres.com/features/ssl #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) #ca: # if you have a CA file and want to verify it you can uncomment this section @@ -43,13 +39,11 @@ backend: integrations: github: - host: github.com - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} ### Example for how to add your GitHub Enterprise instance using the API: # - host: ghe.example.net # apiBaseUrl: https://ghe.example.net/api/v3 - # token: - # $env: GHE_TOKEN + # token: ${GHE_TOKEN} proxy: '/test': @@ -73,8 +67,7 @@ auth: scaffolder: github: - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} visibility: public # or 'internal' or 'private' catalog: diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index d7f7fbafaf..7ae86c0fbc 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -22,6 +22,10 @@ "create-plugin": "backstage-cli create-plugin --scope internal --no-private", "remove-plugin": "backstage-cli remove-plugin" }, + "resolutions": { + "graphql-language-service-interface": "2.8.2", + "graphql-language-service-parser": "1.9.0" + }, "workspaces": { "packages": [ "packages/*", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 1cd7b77965..1ed7aa030a 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -12,14 +12,14 @@ import { CatalogIndexPage, catalogPlugin, } from '@backstage/plugin-catalog'; -import { CatalogImportPage } from '@backstage/plugin-catalog-import'; +import {CatalogImportPage, catalogImportPlugin} from '@backstage/plugin-catalog-import'; import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; import { apis } from './apis'; -import { EntityPage } from './components/catalog/EntityPage'; +import { entityPage } from './components/catalog/EntityPage'; import { Root } from './components/Root'; import * as plugins from './plugins'; @@ -33,6 +33,9 @@ const app = createApp({ bind(apiDocsPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, }); + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + }); }, }); @@ -47,7 +50,7 @@ const routes = ( path="/catalog/:namespace/:kind/:name" element={} > - + {entityPage} } /> } /> diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index d05f6bfa8d..f1aba46c52 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -15,308 +15,248 @@ */ import React from 'react'; import { Button, Grid } from '@material-ui/core'; -import { - ApiEntity, - DomainEntity, - Entity, - GroupEntity, - SystemEntity, - UserEntity, -} from '@backstage/catalog-model'; import { EmptyState } from '@backstage/core'; import { - ApiDefinitionCard, - ConsumedApisCard, - ConsumingComponentsCard, + EntityApiDefinitionCard, + EntityConsumedApisCard, + EntityConsumingComponentsCard, EntityHasApisCard, - ProvidedApisCard, - ProvidingComponentsCard, + EntityProvidedApisCard, + EntityProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { - AboutCard, + EntityAboutCard, + EntitySystemDiagramCard, EntityHasComponentsCard, EntityHasSystemsCard, - EntityPageLayout, + EntityLayout, + EntitySwitch, + isComponentType, + isKind, } from '@backstage/plugin-catalog'; -import { useEntity } from '@backstage/plugin-catalog-react'; import { - isPluginApplicableToEntity as isGitHubActionsAvailable, - Router as GitHubActionsRouter, + isGithubActionsAvailable, + EntityGithubActionsContent, } from '@backstage/plugin-github-actions'; import { - GroupProfileCard, - MembersListCard, - OwnershipCard, - UserProfileCard, + EntityUserProfileCard, + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, } from '@backstage/plugin-org'; -import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; - -const CICDSwitcher = ({ entity }: { entity: Entity }) => { - // This component is just an example of how you can implement your company's logic in entity page. +const cicdContent = ( + // This is an example of how you can implement your company's logic in entity page. // You can for example enforce that all components of type 'service' should use GitHubActions - switch (true) { - case isGitHubActionsAvailable(entity): - return ; - default: - return ( - - Read more - - } - /> - ); - } -}; + + + + -const OverviewContent = ({ entity }: { entity: Entity }) => ( + + + Read more + + } + /> + + +); + +const overviewContent = ( - + ); -const ComponentApisContent = ({ entity }: { entity: Entity }) => ( - - - - - - - - -); +const serviceEntityPage = ( + + + {overviewContent} + -const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - } - /> - } - /> - -); + + {cicdContent} + -const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - } - /> - -); - -const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - -); - -export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { - switch (entity?.spec?.type) { - case 'service': - return ; - case 'website': - return ; - default: - return ; - } -}; - -const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( - - - - - - - + + + + + + + + - - + + + + + + +); + +const websiteEntityPage = ( + + + {overviewContent} + + + + {cicdContent} + + + + + + +); + +const defaultEntityPage = ( + + + {overviewContent} + + + + + + +); + +const componentPage = ( + + + {serviceEntityPage} + + + + {websiteEntityPage} + + + {defaultEntityPage} + +); + +const apiPage = ( + + + + + + + + + + + + + + - - + + + + + + + + + + ); -const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( - - - - - +const userPage = ( + + + + + + + + + + + + ); -const ApiEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - +const groupPage = ( + + + + + + + + + + + + + + + + + + + ); -const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( - - - - - - - - +const systemPage = ( + + + + + + + + + + + + + + + ); -const UserEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - +const domainPage = ( + + + + + + + + + + + + ); -const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( - - - - - - - - - - - +export const entityPage = ( + + + + + + + + + {defaultEntityPage} + ); - -const GroupEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); - -const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( - - - - - - - - - - - -); - -const SystemEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); - -const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( - - - - - - - - -); - -const DomainEntityPage = ({ entity }: { entity: Entity }) => ( - - } - /> - -); - -export const EntityPage = () => { - const { entity } = useEntity(); - - switch (entity?.kind?.toLocaleLowerCase('en-US')) { - case 'component': - return ; - case 'api': - return ; - case 'group': - return ; - case 'user': - return ; - case 'system': - return ; - case 'domain': - return ; - case 'location': - case 'resource': - case 'template': - default: - return ; - } -}; diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index acef405c8a..31231a3a4a 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -16,11 +16,13 @@ WORKDIR /app # Copy repo skeleton first, to avoid unnecessary docker cache invalidation. # The skeleton contains the package.json of each package in the monorepo, # and along with yarn.lock and the root package.json, that's enough to run yarn install. -ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +COPY yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./ +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"] diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 0ba08cefaf..231a7e7fd7 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -34,6 +34,9 @@ export default async function createPlugin({ discovery, }); + // checks if the publisher is working and logs the result + await publisher.getReadiness(); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. const dockerClient = new Docker(); diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index f0d72b7ceb..ddc84ae011 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -149,5 +149,19 @@ export interface Config { */ baseUrl?: string; }>; + + /** Integration configuration for Google Cloud Storage */ + googleGcs?: { + /** + * Service account email used to authenticate requests. + * @visibility backend + */ + clientEmail?: string; + /** + * Service account private key used to authenticate requests. + * @visibility secret + */ + privateKey?: string; + }; }; } diff --git a/packages/integration/package.json b/packages/integration/package.json index c57c0e78cd..dcfae4ea05 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -37,9 +37,9 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/config-loader": "^0.5.1", - "@backstage/test-utils": "^0.1.7", + "@backstage/cli": "^0.6.7", + "@backstage/config-loader": "^0.6.0", + "@backstage/test-utils": "^0.1.10", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", "msw": "^0.21.2" diff --git a/packages/integration/src/googleGcs/config.test.ts b/packages/integration/src/googleGcs/config.test.ts new file mode 100644 index 0000000000..a924e953b9 --- /dev/null +++ b/packages/integration/src/googleGcs/config.test.ts @@ -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. + */ + +import { Config, ConfigReader } from '@backstage/config'; +import { + GoogleGcsIntegrationConfig, + readGoogleGcsIntegrationConfig, +} from './config'; + +describe('readGoogleGcsIntegrationConfig', () => { + function buildConfig(data: Partial): Config { + return new ConfigReader(data); + } + + it('reads all values', () => { + const output = readGoogleGcsIntegrationConfig( + buildConfig({ + privateKey: 'fake-key', + clientEmail: 'someone@example.com', + }), + ); + expect(output).toEqual({ + privateKey: 'fake-key', + clientEmail: 'someone@example.com', + }); + }); + + it('does not fail when config is not set', () => { + const output = readGoogleGcsIntegrationConfig(buildConfig({})); + expect(output).toEqual({}); + }); +}); diff --git a/packages/integration/src/googleGcs/config.ts b/packages/integration/src/googleGcs/config.ts new file mode 100644 index 0000000000..7924138b5a --- /dev/null +++ b/packages/integration/src/googleGcs/config.ts @@ -0,0 +1,53 @@ +/* + * 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 { Config } from '@backstage/config'; + +/** + * The configuration parameters for a single Google Cloud Storage provider. + */ +export type GoogleGcsIntegrationConfig = { + /** + * Service account email used to authenticate requests. + */ + clientEmail?: string; + /** + * Service account private key used to authenticate requests. + */ + privateKey?: string; +}; + +/** + * Reads a single Google GCS integration config. + * + * @param config The config object of a single integration + */ +export function readGoogleGcsIntegrationConfig( + config: Config, +): GoogleGcsIntegrationConfig { + if (!config) { + return {}; + } + + if (!config.has('clientEmail') && !config.has('privateKey')) { + return {}; + } + + const privateKey = config.getString('privateKey').split('\\n').join('\n'); + + const clientEmail = config.getString('clientEmail'); + return { clientEmail: clientEmail, privateKey: privateKey }; +} diff --git a/plugins/techdocs-backend/src/service/helpers.ts b/packages/integration/src/googleGcs/index.ts similarity index 57% rename from plugins/techdocs-backend/src/service/helpers.ts rename to packages/integration/src/googleGcs/index.ts index fc80ab717d..d9355fd299 100644 --- a/plugins/techdocs-backend/src/service/helpers.ts +++ b/packages/integration/src/googleGcs/index.ts @@ -13,18 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityName } from '@backstage/catalog-model'; -/** - * Using the path of the TechDocs page URL, return a structured EntityName type object with namespace, - * kind and name of the Entity. - * @param {string} path Example: default/Component/documented-component - */ -export const getEntityNameFromUrlPath = (path: string): EntityName => { - const [namespace, kind, name] = path.split('/'); - return { - namespace, - kind, - name, - }; -}; +export { readGoogleGcsIntegrationConfig } from './config'; +export type { GoogleGcsIntegrationConfig } from './config'; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index e89cf6ef92..d15eea0832 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,6 +18,7 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; +export * from './googleGcs'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; export type { diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 84b1be1fd5..9dd6447a14 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,59 @@ # @backstage/techdocs-common +## 0.5.0 + +### Minor Changes + +- bc9d62f4f: Move the sanity checks of the publisher configurations to a dedicated `PublisherBase#getReadiness()` method instead of throwing an error when doing `Publisher.fromConfig(...)`. + You should include the check when your backend to get early feedback about a potential misconfiguration: + + ```diff + // packages/backend/src/plugins/techdocs.ts + + export default async function createPlugin({ + logger, + config, + discovery, + reader, + }: PluginEnvironment): Promise { + // ... + + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }) + + + // checks if the publisher is working and logs the result + + await publisher.getReadiness(); + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + + // ... + } + ``` + + If you want to crash your application on invalid configurations, you can throw an `Error` to preserve the old behavior. + Please be aware that this is not the recommended for the use in a Backstage backend but might be helpful in CLI tools such as the `techdocs-cli`. + + ```ts + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + + const ready = await publisher.getReadiness(); + if (!ready.isAvailable) { + throw new Error('Invalid TechDocs publisher configuration'); + } + ``` + +### Patch Changes + +- Updated dependencies [bb5055aee] +- Updated dependencies [5d0740563] + - @backstage/catalog-model@0.7.7 + ## 0.4.5 ### Patch Changes diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index 264852d31c..f8d0368f27 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -81,10 +81,12 @@ class Bucket { this.bucketName = bucketName; } - getMetadata() { - return new Promise(resolve => { - resolve(''); - }); + async getMetadata() { + if (this.bucketName === 'errorBucket') { + throw Error('Bucket does not exist'); + } + + return ''; } upload(source: string, { destination }) { diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts index 363662c63c..eb15a218ab 100644 --- a/packages/techdocs-common/__mocks__/aws-sdk.ts +++ b/packages/techdocs-common/__mocks__/aws-sdk.ts @@ -80,10 +80,16 @@ export class S3 { }; } - headBucket() { - return new Promise(resolve => { - resolve(''); - }); + headBucket({ Bucket }) { + return { + promise: async () => { + if (Bucket === 'errorBucket') { + throw new Error('Bucket does not exist'); + } + + return {}; + }, + }; } upload({ Key }: { Key: string }) { diff --git a/packages/techdocs-common/__mocks__/pkgcloud.ts b/packages/techdocs-common/__mocks__/pkgcloud.ts index 5d9f81cda0..dca86238e4 100644 --- a/packages/techdocs-common/__mocks__/pkgcloud.ts +++ b/packages/techdocs-common/__mocks__/pkgcloud.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EventEmitter } from 'events'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { EventEmitter } from 'events'; +import { ClientError } from 'pkgcloud'; const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; @@ -37,12 +38,11 @@ class PkgCloudStorageClient { getFile( containerName: string, file: string, - callback: (err: any, file: string) => any, + callback: (err: any, file: any) => any, ) { checkFileExists(file).then(res => { if (!res) { - callback('File does not exist', file); - throw new Error('File does not exist'); + callback('File does not exist', undefined); } else { callback(undefined, 'success'); } @@ -51,13 +51,12 @@ class PkgCloudStorageClient { getContainer( containerName: string, - callback: (err: string, container: string) => any, + callback: (err: ClientError, container: any) => any, ) { if (containerName !== 'mock') { - callback('Container does not exist', containerName); - throw new Error('Container does not exist'); + callback(new Error('Container does not exist'), undefined); } else { - callback('Container does not exist', 'success'); + callback(undefined, 'success'); } } diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index fe13a0fc88..c41dce72b1 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.4.5", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -39,7 +39,7 @@ "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", "@backstage/backend-common": "^0.6.0", - "@backstage/catalog-model": "^0.7.4", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", @@ -62,7 +62,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.8", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^4.0.0", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index d3281c2645..15da3b7cfc 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; -import * as winston from 'winston'; import { AwsS3Publish } from './awsS3'; import { PublisherBase, TechDocsMetadata } from './types'; @@ -59,9 +59,7 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -const logger = winston.createLogger(); -jest.spyOn(logger, 'info').mockReturnValue(logger); -jest.spyOn(logger, 'error').mockReturnValue(logger); +const logger = getVoidLogger(); let publisher: PublisherBase; @@ -87,6 +85,39 @@ beforeEach(() => { }); describe('AwsS3Publish', () => { + describe('getReadiness', () => { + it('should validate correct config', async () => { + expect(await publisher.getReadiness()).toEqual({ + isAvailable: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + // this bucket name will throw an error + bucketName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = AwsS3Publish.fromConfig(mockConfig, logger); + + expect(await errorPublisher.getReadiness()).toEqual({ + isAvailable: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index c4c39898f4..9c6684fdca 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -17,16 +17,21 @@ import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import aws, { Credentials } from 'aws-sdk'; import { ManagedUpload } from 'aws-sdk/clients/s3'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; import createLimiter from 'p-limit'; -import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import path from 'path'; import { Readable } from 'stream'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + PublisherBase, + PublishRequest, + ReadinessResponse, + TechDocsMetadata, +} from './types'; const streamToBuffer = (stream: Readable): Promise => { return new Promise((resolve, reject) => { @@ -81,30 +86,6 @@ export class AwsS3Publish implements PublisherBase { ...(endpoint && { endpoint }), }); - // Check if the defined bucket exists. Being able to connect means the configuration is good - // and the storage client will work. - storageClient.headBucket( - { - Bucket: bucketName, - }, - err => { - if (err) { - logger.error( - `Could not retrieve metadata about the AWS S3 bucket ${bucketName}. ` + - 'Make sure the bucket exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - logger.error(`from AWS client library: ${err.message}`); - throw new Error(); - } else { - logger.info( - `Successfully connected to the AWS S3 bucket ${bucketName}.`, - ); - } - }, - ); - return new AwsS3Publish(storageClient, bucketName, logger); } @@ -149,6 +130,35 @@ export class AwsS3Publish implements PublisherBase { this.logger = logger; } + /** + * Check if the defined bucket exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + async getReadiness(): Promise { + try { + await this.storageClient + .headBucket({ Bucket: this.bucketName }) + .promise(); + + this.logger.info( + `Successfully connected to the AWS S3 bucket ${this.bucketName}.`, + ); + + return { isAvailable: true }; + } catch (error) { + this.logger.error( + `Could not retrieve metadata about the AWS S3 bucket ${this.bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + this.logger.error(`from AWS client library`, error); + return { + isAvailable: false, + }; + } + } + /** * Upload all the files from the generated `directory` to the S3 bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 9442fb0e37..c431e924a8 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -16,8 +16,8 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -59,12 +59,9 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -function createLogger() { - const logger = getVoidLogger(); - jest.spyOn(logger, 'info').mockReturnValue(logger); - jest.spyOn(logger, 'error').mockReturnValue(logger); - return logger; -} +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); let publisher: PublisherBase; beforeEach(async () => { @@ -85,13 +82,51 @@ beforeEach(async () => { }, }); - publisher = await AzureBlobStoragePublish.fromConfig( - mockConfig, - createLogger(), - ); + publisher = AzureBlobStoragePublish.fromConfig(mockConfig, logger); }); describe('publishing with valid credentials', () => { + describe('getReadiness', () => { + it('should validate correct config', async () => { + expect(await publisher.getReadiness()).toEqual({ + isAvailable: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'bad_container', + }, + }, + }, + }); + + const errorPublisher = await AzureBlobStoragePublish.fromConfig( + mockConfig, + logger, + ); + + expect(await errorPublisher.getReadiness()).toEqual({ + isAvailable: false, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, + ), + ); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); @@ -151,6 +186,60 @@ describe('publishing with valid credentials', () => { }); mockFs.restore(); }); + + it('reports an error when bad account credentials', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'failupload', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + }, + }); + + let error; + try { + await publisher.publish({ + entity, + directory: entityRootDir, + }); + } catch (e) { + error = e; + } + + expect(error.message).toContain( + `Unable to upload file(s) to Azure Blob Storage.`, + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join( + entityRootDir, + 'index.html', + )} with status code 500`, + ), + ); + + mockFs.restore(); + }); }); describe('hasDocsBeenGenerated', () => { @@ -243,156 +332,3 @@ describe('publishing with valid credentials', () => { }); }); }); - -describe('error reporting', () => { - it('reports an error when unable to read container properties', async () => { - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - publisher: { - type: 'azureBlobStorage', - azureBlobStorage: { - credentials: { - accountName: 'accountName', - }, - containerName: 'bad_container', - }, - }, - }, - }); - - const logger = createLogger(); - - let error; - try { - publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); - } catch (e) { - error = e; - } - - expect(error).toBeInstanceOf(Error); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, - ), - ); - }); - - it('reports an error when bad account credentials', async () => { - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - publisher: { - type: 'azureBlobStorage', - azureBlobStorage: { - credentials: { - accountName: 'failupload', - accountKey: 'accountKey', - }, - containerName: 'containerName', - }, - }, - }, - }); - - const logger = createLogger(); - - publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); - - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - }, - }); - - let error; - try { - await publisher.publish({ - entity, - directory: entityRootDir, - }); - } catch (e) { - error = e; - } - - expect(error.message).toContain( - `Unable to upload file(s) to Azure Blob Storage.`, - ); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - `Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join( - entityRootDir, - 'index.html', - )} with status code 500`, - ), - ); - - mockFs.restore(); - }); - - describe('fetchTechDocsMetadata', () => { - it('should return tech docs metadata', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', - }, - }); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); - }); - - it('should return tech docs metadata when json encoded with single quotes', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, - }, - }); - - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); - }); - - it('should return an error if the techdocs_metadata.json file is not present', async () => { - const entityNameMock = createMockEntityName(); - - let error; - try { - await publisher.fetchTechDocsMetadata(entityNameMock); - } catch (e) { - error = e; - } - - expect(error.message).toEqual( - expect.stringContaining('TechDocs metadata fetch'), - ); - }); - }); -}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 155d14fbb5..1a823c3d19 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -26,16 +26,18 @@ import limiterFactory from 'p-limit'; import { default as path, default as platformPath } from 'path'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + PublisherBase, + PublishRequest, + ReadinessResponse, + TechDocsMetadata, +} from './types'; // The number of batches that may be ongoing at the same time. const BATCH_CONCURRENCY = 3; export class AzureBlobStoragePublish implements PublisherBase { - static async fromConfig( - config: Config, - logger: Logger, - ): Promise { + static fromConfig(config: Config, logger: Logger): PublisherBase { let containerName = ''; try { containerName = config.getString( @@ -78,26 +80,6 @@ export class AzureBlobStoragePublish implements PublisherBase { credential, ); - try { - const response = await storageClient - .getContainerClient(containerName) - .getProperties(); - - if (response._response.status >= 400) { - throw new Error( - `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, - ); - } - } catch (e) { - logger.error( - `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + - 'Make sure that the Azure project and container exist and the access key is setup correctly ' + - 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(`from Azure Blob Storage client library: ${e.message}`); - } - return new AzureBlobStoragePublish(storageClient, containerName, logger); } @@ -111,6 +93,37 @@ export class AzureBlobStoragePublish implements PublisherBase { this.logger = logger; } + async getReadiness(): Promise { + try { + const response = await this.storageClient + .getContainerClient(this.containerName) + .getProperties(); + + if (response._response.status === 200) { + return { + isAvailable: true, + }; + } + + if (response._response.status >= 400) { + this.logger.error( + `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, + ); + } + } catch (e) { + this.logger.error(`from Azure Blob Storage client library: ${e.message}`); + } + + this.logger.error( + `Could not retrieve metadata about the Azure Blob Storage container ${this.containerName}. ` + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + + return { isAvailable: false }; + } + /** * Upload all the files from the generated `directory` to the Azure Blob Storage container. * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 07bc5c06b9..7e580b16bc 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -16,8 +16,8 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -83,6 +83,35 @@ beforeEach(async () => { }); describe('GoogleGCSPublish', () => { + describe('getReadiness', () => { + it('should validate correct config', async () => { + expect(await publisher.getReadiness()).toEqual({ + isAvailable: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = GoogleGCSPublish.fromConfig(mockConfig, logger); + + expect(await errorPublisher.getReadiness()).toEqual({ + isAvailable: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 12ecf45b2d..28fbb42ece 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -26,13 +26,15 @@ import createLimiter from 'p-limit'; import path from 'path'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + PublisherBase, + PublishRequest, + ReadinessResponse, + TechDocsMetadata, +} from './types'; export class GoogleGCSPublish implements PublisherBase { - static async fromConfig( - config: Config, - logger: Logger, - ): Promise { + static fromConfig(config: Config, logger: Logger): PublisherBase { let bucketName = ''; try { bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); @@ -65,21 +67,6 @@ export class GoogleGCSPublish implements PublisherBase { }), }); - // Check if the defined bucket exists. Being able to connect means the configuration is good - // and the storage client will work. - try { - await storageClient.bucket(bucketName).getMetadata(); - logger.info(`Successfully connected to the GCS bucket ${bucketName}.`); - } catch (err) { - logger.error( - `Could not retrieve metadata about the GCS bucket ${bucketName}. ` + - 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + - 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(err.message); - } - return new GoogleGCSPublish(storageClient, bucketName, logger); } @@ -93,6 +80,33 @@ export class GoogleGCSPublish implements PublisherBase { this.logger = logger; } + /** + * Check if the defined bucket exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + async getReadiness(): Promise { + try { + await this.storageClient.bucket(this.bucketName).getMetadata(); + this.logger.info( + `Successfully connected to the GCS bucket ${this.bucketName}.`, + ); + + return { + isAvailable: true, + }; + } catch (err) { + this.logger.error( + `Could not retrieve metadata about the GCS bucket ${this.bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + + 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + this.logger.error(`from GCS client library: ${err.message}`); + + return { isAvailable: false }; + } + } + /** * Upload all the files from the generated `directory` to the GCS bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 3088c1a695..e09473cf60 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -28,6 +28,7 @@ import { PublisherBase, PublishRequest, PublishResponse, + ReadinessResponse, TechDocsMetadata, } from './types'; @@ -65,6 +66,12 @@ export class LocalPublish implements PublisherBase { this.discovery = discovery; } + async getReadiness(): Promise { + return { + isAvailable: true, + }; + } + publish({ entity, directory }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index f3b788916f..6f0aacede0 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; -import * as winston from 'winston'; import { OpenStackSwiftPublish } from './openStackSwift'; import { PublisherBase, TechDocsMetadata } from './types'; @@ -59,9 +59,7 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -const logger = winston.createLogger(); -jest.spyOn(logger, 'info').mockReturnValue(logger); -jest.spyOn(logger, 'error').mockReturnValue(logger); +const logger = getVoidLogger(); let publisher: PublisherBase; @@ -89,6 +87,43 @@ beforeEach(() => { }); describe('OpenStackSwiftPublish', () => { + describe('getReadiness', () => { + it('should validate correct config', async () => { + expect(await publisher.getReadiness()).toEqual({ + isAvailable: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'openStackSwift', + openStackSwift: { + credentials: { + username: 'mockuser', + password: 'verystrongpass', + }, + authUrl: 'mockauthurl', + region: 'mockregion', + containerName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = OpenStackSwiftPublish.fromConfig( + mockConfig, + logger, + ); + + expect(await errorPublisher.getReadiness()).toEqual({ + isAvailable: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 0e60bc5796..bff0055402 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -15,16 +15,21 @@ */ import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { storage } from 'pkgcloud'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; import createLimiter from 'p-limit'; import path from 'path'; +import { storage } from 'pkgcloud'; import { Readable } from 'stream'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + PublisherBase, + PublishRequest, + ReadinessResponse, + TechDocsMetadata, +} from './types'; const streamToBuffer = (stream: Readable): Promise => { return new Promise((resolve, reject) => { @@ -70,25 +75,6 @@ export class OpenStackSwiftPublish implements PublisherBase { region: openStackSwiftConfig.getString('region'), }); - // Check if the defined container exists. Being able to connect means the configuration is good - // and the storage client will work. - storageClient.getContainer(containerName, (err, container) => { - if (container) { - logger.info( - `Successfully connected to the OpenStack Swift container ${containerName}.`, - ); - } else { - logger.error( - `Could not retrieve metadata about the OpenStack Swift container ${containerName}. ` + - 'Make sure the container exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - - logger.error(`from OpenStack client library: ${err.message}`); - } - }); - return new OpenStackSwiftPublish(storageClient, containerName, logger); } @@ -102,6 +88,37 @@ export class OpenStackSwiftPublish implements PublisherBase { this.logger = logger; } + /* + * Check if the defined container exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + getReadiness(): Promise { + return new Promise(resolve => { + this.storageClient.getContainer(this.containerName, (err, container) => { + if (container) { + this.logger.info( + `Successfully connected to the OpenStack Swift container ${this.containerName}.`, + ); + resolve({ + isAvailable: true, + }); + } else { + this.logger.error( + `Could not retrieve metadata about the OpenStack Swift container ${this.containerName}. ` + + 'Make sure the container exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + + this.logger.error(`from OpenStack client library: ${err.message}`); + resolve({ + isAvailable: false, + }); + } + }); + }); + } + /** * Upload all the files from the generated `directory` to the OpenStack Swift container. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index f70a5f7626..909a4092ac 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; -import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; -import { PublisherType, PublisherBase } from './types'; -import { LocalPublish } from './local'; -import { GoogleGCSPublish } from './googleStorage'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; import { AwsS3Publish } from './awsS3'; import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { GoogleGCSPublish } from './googleStorage'; +import { LocalPublish } from './local'; import { OpenStackSwiftPublish } from './openStackSwift'; +import { PublisherBase, PublisherType } from './types'; type factoryOptions = { logger: Logger; @@ -45,7 +45,7 @@ export class Publisher { switch (publisherType) { case 'googleGcs': logger.info('Creating Google Storage Bucket publisher for TechDocs'); - return await GoogleGCSPublish.fromConfig(config, logger); + return GoogleGCSPublish.fromConfig(config, logger); case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 83fee55133..c2849b8e35 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -37,6 +37,14 @@ export type PublishResponse = { remoteUrl?: string; } | void; +/** + * Result for the validation check. + */ +export type ReadinessResponse = { + /** If true, the publisher is able to interact with the backing storage. */ + isAvailable: boolean; +}; + /** * Type to hold metadata found in techdocs_metadata.json and associated with each site * @param etag ETag of the resource used to generate the site. Usually the latest commit sha of the source repository. @@ -53,6 +61,14 @@ export type TechDocsMetadata = { * It also provides APIs to communicate with the storage service. */ export interface PublisherBase { + /** + * Check if the publisher is ready. This check tries to perform certain checks to see if the + * publisher is configured correctly and can be used to publish or read documentations. + * The different implementations might e.g. use the provided service credentials to access the + * target or check if a folder/bucket is available. + */ + getReadiness(): Promise; + /** * Store the generated static files onto a storage service (either local filesystem or external service). * diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index bcdc94abbb..d2c2748449 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/test-utils +## 0.1.10 + +### Patch Changes + +- ae6250ce3: Remove unnecessary wrapping of elements rendered by `wrapInTestApp` and `renderInTestApp`, which was breaking mount discovery. +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [b051e770c] + - @backstage/core-api@0.2.16 + ## 0.1.9 ### Patch Changes diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f3a979fc72..a81455df01 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.9", + "version": "0.1.10", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-api": "^0.2.14", + "@backstage/core-api": "^0.2.16", "@backstage/test-utils-core": "^0.1.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.7", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/theme/src/pageTheme.ts b/packages/theme/src/pageTheme.ts index f86f3a6449..36f49bcdf9 100644 --- a/packages/theme/src/pageTheme.ts +++ b/packages/theme/src/pageTheme.ts @@ -63,4 +63,5 @@ export const pageTheme: Record = { library: genPageTheme(colorVariants.rubyRed, shapes.wave), other: genPageTheme(colorVariants.darkGrey, shapes.wave), app: genPageTheme(colorVariants.toastyOrange, shapes.wave), + apis: genPageTheme(colorVariants.teal, shapes.wave2), }; diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 14247fcea5..a2d5a28e41 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-api-docs +## 0.4.11 + +### Patch Changes + +- 12390778e: chore(deps): bump @asyncapi/react-component from 0.19.2 to 0.22.3 +- 5cafcf452: add debounce time attribute for apis-docs for search, giving more time to the users when they are typing. +- Updated dependencies [bb5055aee] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [e27cb6c45] + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + ## 0.4.10 ### Patch Changes diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 0b8d658f89..4e71e87275 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -1,7 +1,5 @@ # API Documentation -WORK IN PROGRESS - This is an extension for the catalog plugin that provides components to discover and display API entities. APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details. They are defined in machine readable formats and provide a human readable documentation. @@ -23,9 +21,145 @@ Other formats are displayed as plain text, but this can easily be extended. To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api). To link that a component provides or consumes an API, see the [`providesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional) and [`consumesApis`](https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional) properties on the Component kind. -## Implementing OAuth 2 Authorization Code flow with Swagger UI +## Getting Started -### Adding `oauth2-redirect.html` to support OAuth2 `redirect_uri` route +> The plugin is already added when using `npx @backstage/create-app` so you can skip these steps. + +1. Install the API docs plugin + +```bash +# packages/app + +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: + +```tsx +// packages/app/src/App.tsx + +import { ApiExplorerPage } from '@backstage/plugin-api-docs'; + +} />; +``` + +4. Add one of the provided widgets to the EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { + EntityAboutCard, + EntityApiDefinitionCard, + EntityConsumingComponentsCard, + EntityProvidingComponentsCard, +} from '@backstage/plugin-api-docs'; + +const apiPage = ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +// ... + +export const entityPage = ( + + // ... + + // ... + +); +``` + +There are other components to discover in [`./src/components`](./src/components) that are also added by the default app. + +## Customizations + +### Custom API Renderings + +You can add support for additional API types by providing a custom implementation for the `apiDocsConfigRef`. +You can also use this to override the rendering of one of the already supported types. + +This is an example with a made-up renderer for SQL schemas: + +```tsx +// packages/app/src/apis.tsx + +import { ApiEntity } from '@backstage/catalog-model'; +import { + ApiDefinitionWidget, + apiDocsConfigRef, + defaultDefinitionWidgets, +} from '@backstage/plugin-api-docs'; +import { SqlRenderer } from '...'; + +// ... + +export const apis: AnyApiFactory[] = [ + // ... + + createApiFactory({ + api: apiDocsConfigRef, + deps: {}, + factory: () => { + // load the default widgets + const definitionWidgets = defaultDefinitionWidgets(); + return { + getApiDefinitionWidget: (apiEntity: ApiEntity) => { + // custom rendering for sql + if (apiEntity.spec.type === 'sql') { + return { + type: 'sql', + title: 'SQL', + component: definition => , + } as ApiDefinitionWidget; + } + + // fallback to the defaults + return definitionWidgets.find(d => d.type === apiEntity.spec.type); + }, + }; + }, + }), +]; +``` + +### Implementing OAuth 2 Authorization Code flow with Swagger UI + +#### Adding `oauth2-redirect.html` to support OAuth2 `redirect_uri` route The Swagger UI package by expects to have a route to `/oauth2-redirect.html` which processes the redirect callback for the OAuth2 Authorization Code flow, however, this file is not installed @@ -34,7 +168,7 @@ by this plugin. Grab a copy of [oauth2-redirect.html](https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html) and put it in the `app/public/` directory in order to enable Swagger UI to complete this redirection. -### Configuring your OAuth2 Client +#### Configuring your OAuth2 Client You'll need to make sure your OAuth2 client has been registered in your OAuth2 Authentication Server (AS) with the appropriate `redirect_uris`, `scopes` and `grant_types`. For example, if your AS supports @@ -62,7 +196,7 @@ The above `redirect_uris` are: - Local Backstage app development: `http://localhost:3000/oauth2-redirect.html` - Backstage app production: `https:///oauth2-redirect.html` -### Configuring OAuth2 in your OpenAPI 3.0 schema +#### Configuring OAuth2 in your OpenAPI 3.0 schema To configure [OAuth 2 Authorization Code](https://swagger.io/docs/specification/authentication/oauth2/) flow in your OpenAPI 3.0 schema you'll need something like this snippet: diff --git a/plugins/api-docs/docs/api_list.png b/plugins/api-docs/docs/api_list.png index 4ca2f09542..97d38e25a6 100644 Binary files a/plugins/api-docs/docs/api_list.png and b/plugins/api-docs/docs/api_list.png differ diff --git a/plugins/api-docs/docs/entity_tab_api.png b/plugins/api-docs/docs/entity_tab_api.png index 439fac73fa..213f6eb04a 100644 Binary files a/plugins/api-docs/docs/entity_tab_api.png and b/plugins/api-docs/docs/entity_tab_api.png differ diff --git a/plugins/api-docs/docs/openapi_definition.png b/plugins/api-docs/docs/openapi_definition.png index 49d12a08e8..c6a0350914 100644 Binary files a/plugins/api-docs/docs/openapi_definition.png and b/plugins/api-docs/docs/openapi_definition.png differ diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 7f34627f6b..13f27c321b 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.10", + "version": "0.4.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@asyncapi/react-component": "^0.19.2", - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", + "@asyncapi/react-component": "^0.22.3", + "@backstage/catalog-model": "^0.7.7", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.5", "@material-icons/font": "^1.0.2", @@ -49,9 +49,9 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx index add886fead..fb94df8528 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx @@ -26,7 +26,7 @@ export const ApiExplorerLayout = ({ children }: Props) => { configApi.getOptionalString('organization.name') ?? 'Backstage' } API Explorer`; return ( - +
{ const [queryParamState, setQueryParamState] = useQueryParamState( 'apiTable', + 500, ); if (error) { diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index e8c4510d54..85fab4455d 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-app-backend +## 0.3.11 + +### Patch Changes + +- Updated dependencies [82c66b8cd] +- Updated dependencies [b779b5fee] + - @backstage/config-loader@0.6.0 + - @backstage/backend-common@0.6.2 + ## 0.3.10 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 9346c6366d..c6a1dd5a6b 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.10", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", - "@backstage/config-loader": "^0.5.1", + "@backstage/backend-common": "^0.6.2", + "@backstage/config-loader": "^0.6.0", "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@backstage/cli": "^0.6.7", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^6.1.3" diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index bb2c44f7ff..47616b68c7 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-auth-backend +## 0.3.8 + +### Patch Changes + +- 2b2b31186: When using OAuth2 authentication the name is now taken from the name property of the JWT instead of the email property +- Updated dependencies [97b60de98] +- Updated dependencies [ae6250ce3] +- Updated dependencies [98dd5da71] +- Updated dependencies [b779b5fee] + - @backstage/catalog-model@0.7.6 + - @backstage/test-utils@0.1.10 + - @backstage/backend-common@0.6.2 + ## 0.3.7 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 183798c9e1..4af8a1736d 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.3.7", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", + "@backstage/backend-common": "^0.6.2", "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-model": "^0.7.6", "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", @@ -68,7 +68,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.7", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md new file mode 100644 index 0000000000..69785a645f --- /dev/null +++ b/plugins/badges-backend/CHANGELOG.md @@ -0,0 +1,12 @@ +# @backstage/plugin-badges-backend + +## 0.1.2 + +### Patch Changes + +- d0b4ebf22: Support auth in badge plugin +- Updated dependencies [97b60de98] +- Updated dependencies [98dd5da71] +- Updated dependencies [b779b5fee] + - @backstage/catalog-model@0.7.6 + - @backstage/backend-common@0.6.2 diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 6ff83ecac9..eb2a93fe67 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-badges-backend", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", + "@backstage/backend-common": "^0.6.2", "@backstage/catalog-client": "^0.3.6", - "@backstage/catalog-model": "^0.7.3", + "@backstage/catalog-model": "^0.7.6", "@backstage/config": "^0.1.3", "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.7", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 2d6075959d..1a178bfef2 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -114,11 +114,14 @@ describe('createRouter', () => { expect(response.text).toEqual(JSON.stringify([badge], null, 2)); expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByName).toHaveBeenCalledWith({ - namespace: 'default', - kind: 'service', - name: 'test', - }); + expect(catalog.getEntityByName).toHaveBeenCalledWith( + { + namespace: 'default', + kind: 'service', + name: 'test', + }, + { token: undefined }, + ); expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1); @@ -150,11 +153,14 @@ describe('createRouter', () => { expect(response.body).toEqual(Buffer.from(image)); expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByName).toHaveBeenCalledWith({ - namespace: 'default', - kind: 'service', - name: 'test', - }); + expect(catalog.getEntityByName).toHaveBeenCalledWith( + { + namespace: 'default', + kind: 'service', + name: 'test', + }, + { token: undefined }, + ); expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 49b0e36944..9be8cddb5d 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -46,7 +46,12 @@ export async function createRouter( router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { const { namespace, kind, name } = req.params; - const entity = await catalog.getEntityByName({ namespace, kind, name }); + const entity = await catalog.getEntityByName( + { namespace, kind, name }, + { + token: getBearerToken(req.headers.authorization), + }, + ); if (!entity) { throw new NotFoundError( `No ${kind} entity in ${namespace} named "${name}"`, @@ -79,7 +84,12 @@ export async function createRouter( '/entity/:namespace/:kind/:name/badge/:badgeId', async (req, res) => { const { namespace, kind, name, badgeId } = req.params; - const entity = await catalog.getEntityByName({ namespace, kind, name }); + const entity = await catalog.getEntityByName( + { namespace, kind, name }, + { + token: getBearerToken(req.headers.authorization), + }, + ); if (!entity) { throw new NotFoundError( `No ${kind} entity in ${namespace} named "${name}"`, @@ -132,3 +142,7 @@ async function getBadgeUrl( const baseUrl = await options.discovery.getExternalBaseUrl('badges'); return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; } + +function getBearerToken(header?: string): string | undefined { + return header?.match(/Bearer\s+(\S+)/i)?.[1]; +} diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index bc3e64113a..a0efd43ccc 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-badges +## 0.2.0 + +### Minor Changes + +- d0b4ebf22: Support auth in badge plugin + +### Patch Changes + +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + ## 0.1.2 ### Patch Changes diff --git a/plugins/badges/package.json b/plugins/badges/package.json index e438a6807b..0d59e271b0 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-badges", - "version": "0.1.2", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/catalog-model": "^0.7.6", + "@backstage/core": "^0.7.5", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.5", @@ -34,9 +34,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 927a16a7eb..c015a59703 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -15,7 +15,7 @@ */ import { generatePath } from 'react-router'; -import { DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { ResponseError } from '@backstage/errors'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; @@ -23,14 +23,26 @@ import { BadgesApi, BadgeSpec } from './types'; export class BadgesClient implements BadgesApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; } public async getEntityBadgeSpecs(entity: Entity): Promise { const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); - const response = await fetch(entityBadgeSpecsUrl); + const token = await this.identityApi.getIdToken(); + const response = await fetch(entityBadgeSpecsUrl, { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); if (!response.ok) { throw await ResponseError.fromResponse(response); diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 92932aa356..a247c93a4b 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -18,6 +18,7 @@ import { createComponentExtension, createPlugin, discoveryApiRef, + identityApiRef, } from '@backstage/core'; import { badgesApiRef, BadgesClient } from './api'; @@ -26,8 +27,9 @@ export const badgesPlugin = createPlugin({ apis: [ createApiFactory({ api: badgesApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new BadgesClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new BadgesClient({ discoveryApi, identityApi }), }), ], }); diff --git a/plugins/bitrise/README.md b/plugins/bitrise/README.md index 2bff1f0d74..59d85b0a98 100644 --- a/plugins/bitrise/README.md +++ b/plugins/bitrise/README.md @@ -55,8 +55,7 @@ proxy: target: 'https://api.bitrise.io/v0.1' allowedMethods: ['GET'] headers: - Authorization: - $env: BITRISE_AUTH_TOKEN + Authorization: ${BITRISE_AUTH_TOKEN} ``` Learn on https://devcenter.bitrise.io/api/authentication how to create a new Bitrise token. diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index f7f58d99cf..16a02ae42a 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -37,9 +37,9 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 058f75f39a..94e9960e35 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-catalog-backend +## 0.7.1 + +### Patch Changes + +- 017192ee8: Add support for configure an LDAP query filter on multiple lines. +- 5d0740563: Implemented missing support for the dependsOn/dependencyOf relationships + between `Component` and `Resource` catalog model objects. + + Added support for generating the relevant relationships to the + `BuiltinKindsEntityProcessor`, and added simple support for fetching + relationships between `Components` and `Resources` for rendering in the + system diagram. All catalog-model changes backwards compatible. + +- Updated dependencies [bb5055aee] +- Updated dependencies [5d0740563] + - @backstage/catalog-model@0.7.7 + ## 0.7.0 ### Minor Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 531caa9ea5..a34c24f5f8 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.0", + "version": "0.7.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", @@ -62,7 +62,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/test-utils": "^0.1.9", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index 2162272390..cb60c119b6 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -45,13 +45,14 @@ describe('BuiltinKindsEntityProcessor', () => { lifecycle: 'l', providesApis: ['b'], consumesApis: ['c'], + dependsOn: ['Resource:r', 'Component:d'], system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(10); + expect(emit).toBeCalledTimes(14); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -100,6 +101,38 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'API', namespace: 'default', name: 'c' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'dependsOn', + target: { kind: 'Resource', namespace: 'default', name: 'r' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'r' }, + type: 'dependencyOf', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'dependsOn', + target: { kind: 'Component', namespace: 'default', name: 'd' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'd' }, + type: 'dependencyOf', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -134,6 +167,29 @@ describe('BuiltinKindsEntityProcessor', () => { }); }); + it('generates an error for component entities with unspecified dependsOn entity reference kinds', async () => { + const entity: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + subcomponentOf: 's', + lifecycle: 'l', + providesApis: ['b'], + consumesApis: ['c'], + dependsOn: ['r'], + system: 's', + }, + }; + await expect( + processor.postProcessEntity(entity, location, emit), + ).rejects.toThrowError( + 'Entity reference "r" did not specify a kind (e.g. starting with "Component:"), and has no default', + ); + }); + it('generates relations for api entities', async () => { const entity: ApiEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -193,13 +249,14 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 'database', owner: 'o', + dependsOn: ['Component:c', 'Resource:r'], system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledTimes(8); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -216,6 +273,41 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); + + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'dependsOn', + target: { kind: 'Component', namespace: 'default', name: 'c' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'c' }, + type: 'dependencyOf', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'dependsOn', + target: { kind: 'Resource', namespace: 'default', name: 'r' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'r' }, + type: 'dependencyOf', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -234,6 +326,25 @@ describe('BuiltinKindsEntityProcessor', () => { }); }); + it('generates an error for resource entities with unspecified dependsOn entity reference kinds', async () => { + const entity: ResourceEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { name: 'n' }, + spec: { + type: 'database', + owner: 'o', + dependsOn: ['c'], + system: 's', + }, + }; + await expect( + processor.postProcessEntity(entity, location, emit), + ).rejects.toThrowError( + 'Entity reference "c" did not specify a kind (e.g. starting with "Component:"), and has no default', + ); + }); + it('generates relations for system entities', async () => { const entity: SystemEntity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 64a47127f9..404d7fd30d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -32,6 +32,8 @@ import { RELATION_API_PROVIDED_BY, RELATION_CHILD_OF, RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, RELATION_HAS_MEMBER, RELATION_HAS_PART, RELATION_MEMBER_OF, @@ -90,7 +92,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { function doEmit( targets: string | string[] | undefined, - context: { defaultKind: string; defaultNamespace: string }, + context: { defaultKind?: string; defaultNamespace: string }, outgoingRelation: string, incomingRelation: string, ): void { @@ -99,16 +101,29 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { } for (const target of [targets].flat()) { const targetRef = parseEntityRef(target, context); + if (targetRef.kind === undefined) { + throw new Error( + `Entity reference "${target}" did not specify a kind (e.g. starting with "Component:"), and has no default`, + ); + } emit( result.relation({ source: selfRef, type: outgoingRelation, - target: targetRef, + target: { + kind: targetRef.kind, + namespace: targetRef.namespace, + name: targetRef.name, + }, }), ); emit( result.relation({ - source: targetRef, + source: { + kind: targetRef.kind, + namespace: targetRef.namespace, + name: targetRef.name, + }, type: incomingRelation, target: selfRef, }), @@ -146,6 +161,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY, ); + doEmit( + component.spec.dependsOn, + { defaultNamespace: selfRef.namespace }, + RELATION_DEPENDS_ON, + RELATION_DEPENDENCY_OF, + ); doEmit( component.spec.system, { defaultKind: 'System', defaultNamespace: selfRef.namespace }, @@ -186,6 +207,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNED_BY, RELATION_OWNER_OF, ); + doEmit( + resource.spec.dependsOn, + { defaultNamespace: selfRef.namespace }, + RELATION_DEPENDS_ON, + RELATION_DEPENDENCY_OF, + ); doEmit( resource.spec.system, { defaultKind: 'System', defaultNamespace: selfRef.namespace }, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 5e293766aa..aac9a091b8 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -172,4 +172,35 @@ describe('readLdapConfig', () => { ]; expect(actual).toEqual(expected); }); + + it('supports multiline ldap query filter', () => { + const config = { + providers: [ + { + target: 'target', + users: { + dn: 'udn', + options: { + filter: ` + (| + (cn=foo bar) + (cn=bar) + ) + `, + }, + }, + groups: { + dn: 'gdn', + options: { + filter: 'f', + }, + }, + }, + ], + }; + const actual = readLdapConfig(new ConfigReader(config)); + + const expected = '(|(cn=foo bar)(cn=bar))'; + expect(actual[0].users.options.filter).toEqual(expected); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index 7608f247d4..625282ca05 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -182,7 +182,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { } return { scope: c.getOptionalString('scope') as SearchOptions['scope'], - filter: c.getOptionalString('filter'), + filter: formatFilter(c.getOptionalString('filter')), attributes: c.getOptionalStringArray('attributes'), paged: c.getOptionalBoolean('paged'), }; @@ -260,6 +260,11 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { }; } + function formatFilter(filter?: string): string | undefined { + // Remove extra whitespaces between blocks to support multiline filters from the configuration + return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); + } + const providerConfigs = config.getOptionalConfigArray('providers') ?? []; return providerConfigs.map(c => { const newConfig = { diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 1e2124f3df..3ab6d64dc8 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@backstage/cli": "^0.6.5", "@backstage/test-utils": "^0.1.9", - "@graphql-codegen/cli": "^1.17.7", + "@graphql-codegen/cli": "^1.21.3", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", "@types/express": "^4.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 7b5cee247f..5fd93562d6 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog-import +## 0.5.2 + +### Patch Changes + +- f9c75f7a9: When importing components you will now have the ability to use non Unicode characters in the entity owner field +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + ## 0.5.1 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index e98bdfdb97..6af4b02bd0 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.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-model": "^0.7.6", "@backstage/catalog-client": "^0.3.9", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/integration": "^0.5.0", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", @@ -43,6 +43,7 @@ "@octokit/rest": "^18.0.12", "@types/react": "^16.9", "git-url-parse": "^11.4.4", + "js-base64": "^3.6.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.15.4", @@ -52,9 +53,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 687bea39e3..de7055cf25 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -309,7 +309,7 @@ describe('CatalogImportClient', () => { await expect( catalogImportClient.submitPullRequest({ repositoryUrl: 'https://github.com/backstage/backstage', - fileContent: 'some content', + fileContent: 'some content 🤖', title: 'A title/message', body: 'A body', }), @@ -335,7 +335,7 @@ describe('CatalogImportClient', () => { repo: 'backstage', path: 'catalog-info.yaml', message: 'A title/message', - content: 'c29tZSBjb250ZW50', + content: 'c29tZSBjb250ZW50IPCfpJY=', branch: 'backstage-integration', }); expect( diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index f6e8cb69d4..6dd48d2aa7 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -21,6 +21,7 @@ import { GitHubIntegrationConfig, ScmIntegrationRegistry, } from '@backstage/integration'; +import { Base64 } from 'js-base64'; import { Octokit } from '@octokit/rest'; import { PartialEntity } from '../types'; import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi'; @@ -300,7 +301,7 @@ export class CatalogImportClient implements CatalogImportApi { repo, path: fileName, message: title, - content: btoa(fileContent), + content: Base64.encode(fileContent), branch: branchName, }) .catch(e => { diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 31f511fdbf..eccfa953a4 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/plugin-catalog +## 0.5.4 + +### Patch Changes + +- 5d0740563: Implemented missing support for the dependsOn/dependencyOf relationships + between `Component` and `Resource` catalog model objects. + + Added support for generating the relevant relationships to the + `BuiltinKindsEntityProcessor`, and added simple support for fetching + relationships between `Components` and `Resources` for rendering in the + system diagram. All catalog-model changes backwards compatible. + +- Updated dependencies [bb5055aee] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [442f34b87] +- Updated dependencies [e27cb6c45] + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + - @backstage/catalog-client@0.3.10 + +## 0.5.3 + +### Patch Changes + +- 98dd5da71: Add support for multiple links to post-scaffold task summary page +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + ## 0.5.2 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index f86c43430a..8561ae3188 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.2", + "version": "0.5.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", + "@backstage/catalog-client": "^0.3.10", + "@backstage/catalog-model": "^0.7.7", + "@backstage/core": "^0.7.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", @@ -53,9 +53,9 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts index 32dff8a0a1..4966d9c13f 100644 --- a/plugins/catalog/src/CatalogClientWrapper.ts +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -42,7 +42,7 @@ export class CatalogClientWrapper implements CatalogApi { } async getLocationById( - id: String, + id: string, options?: CatalogRequestOptions, ): Promise { return await this.client.getLocationById(id, { diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx index df20e910aa..78288c46e9 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx @@ -16,6 +16,7 @@ import { Entity, + RELATION_DEPENDS_ON, RELATION_PROVIDES_API, RELATION_PART_OF, serializeEntityRef, @@ -124,7 +125,6 @@ export function SystemDiagramCard() { catalogItem, RELATION_PROVIDES_API, ); - catalogItemRelations_providesApi.forEach(foundRelation => systemEdges.push({ from: simplifiedEntityName(catalogItem), @@ -132,6 +132,18 @@ export function SystemDiagramCard() { label: 'provides API', }), ); + + const catalogItemRelations_dependsOn = getEntityRelations( + catalogItem, + RELATION_DEPENDS_ON, + ); + catalogItemRelations_dependsOn.forEach(foundRelation => + systemEdges.push({ + from: simplifiedEntityName(catalogItem), + to: simplifiedEntityName(foundRelation), + label: 'depends on', + }), + ); } } diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 35fed49827..d30e950aa9 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -43,8 +43,7 @@ proxy: '/circleci/api': target: https://circleci.com/api/v1.1 headers: - Circle-Token: - $env: CIRCLECI_AUTH_TOKEN + Circle-Token: ${CIRCLECI_AUTH_TOKEN} ``` 5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 3a3b847e97..5eb91d08bb 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 8718a50a5f..75e877ccb6 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/config-schema/.eslintrc.js b/plugins/config-schema/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/config-schema/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/config-schema/README.md b/plugins/config-schema/README.md new file mode 100644 index 0000000000..7367868e7f --- /dev/null +++ b/plugins/config-schema/README.md @@ -0,0 +1,19 @@ +# config-schema + +The `config-schema` plugin lets you browse a documentation reference of the configuration schema of a particular Backstage installation. It is intended as a tool for integrators rather than something that is useful to end users of Backstage. + +## Usage + +The plugin exports a single full-page extension, the `ConfigSchemaPage`, which you add to an app like a usual top-level tool on a dedicated route. + +It also exports a `configSchemaApiRef` without any default implementation, meaning that an API needs to be registered in the app for the plugin to work. An implementation of the API that is provided out of the box is the `StaticSchemaLoader`, which loads the schema from a URL. It can be added to the app by adding the following to your app's `api.ts`: + +```ts +createApiFactory(configSchemaApiRef, new StaticSchemaLoader()); +``` + +The configuration schema consumed by the `StaticSchemaLoader` can be generated using the following command: + +```shell +yarn --silent backstage-cli config:schema --format=json +``` diff --git a/plugins/config-schema/dev/example-schema.json b/plugins/config-schema/dev/example-schema.json new file mode 100644 index 0000000000..d1e577ff6f --- /dev/null +++ b/plugins/config-schema/dev/example-schema.json @@ -0,0 +1,1659 @@ +{ + "$schema": "https://backstage.io/schema/config-v1", + "title": "Application Configuration Schema", + "type": "object", + "required": ["app", "backend", "costInsights", "sentry", "techdocs"], + "properties": { + "app": { + "type": "object", + "required": ["baseUrl"], + "description": "Generic frontend configuration.", + "properties": { + "baseUrl": { + "type": "string", + "visibility": "frontend", + "description": "The public absolute root URL that the frontend." + }, + "title": { + "type": "string", + "visibility": "frontend", + "description": "The title of the app." + }, + "googleAnalyticsTrackingId": { + "type": "string", + "visibility": "frontend", + "description": "Tracking ID for Google Analytics", + "examples": ["UA-000000-0"] + }, + "listen": { + "type": "object", + "description": "Listening configuration for local development", + "properties": { + "host": { + "type": "string", + "visibility": "frontend", + "description": "The host that the frontend should be bound to. Only used for local development." + }, + "port": { + "type": "number", + "visibility": "frontend", + "description": "The port that the frontend should be bound to. Only used for local development." + } + } + }, + "support": { + "description": "Information about support of this Backstage instance and how to contact the integrator team.", + "type": "object", + "required": ["items", "url"], + "properties": { + "url": { + "description": "The primary support url.", + "visibility": "frontend", + "type": "string" + }, + "items": { + "description": "A list of categorized support item groupings.", + "type": "array", + "items": { + "type": "object", + "required": ["links", "title"], + "properties": { + "title": { + "description": "The title of the support item grouping.", + "visibility": "frontend", + "type": "string" + }, + "icon": { + "description": "An optional icon for the support item grouping.", + "visibility": "frontend", + "type": "string" + }, + "links": { + "description": "A list of support links for the Backstage instance.", + "type": "array", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "visibility": "frontend", + "type": "string" + }, + "title": { + "visibility": "frontend", + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "lighthouse": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "visibility": "frontend" + } + } + }, + "auth": { + "type": "object", + "description": "Configuration that provides information on available authentication providers configured for app", + "properties": { + "providers": { + "type": "object", + "description": "The available auth-provider options and attributes", + "additionalProperties": { + "type": "object", + "visibility": "frontend" + }, + "properties": { + "google": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "github": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "gitlab": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "saml": { + "type": "object", + "required": ["entryPoint", "issuer"], + "properties": { + "entryPoint": { + "type": "string" + }, + "logoutUrl": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "cert": { + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "decryptionPvk": { + "type": "string" + }, + "signatureAlgorithm": { + "enum": ["sha256", "sha512"], + "type": "string" + }, + "digestAlgorithm": { + "type": "string" + } + } + }, + "okta": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "oauth2": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "authorizationUrl", + "clientId", + "clientSecret", + "tokenUrl" + ], + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "authorizationUrl": { + "type": "string" + }, + "tokenUrl": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + } + }, + "oidc": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "auth0": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "microsoft": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "onelogin": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "awsalb": { + "type": "object", + "required": ["region"], + "properties": { + "issuer": { + "type": "string" + }, + "region": { + "type": "string" + } + } + } + } + }, + "environment": { + "description": "The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`.\ndefault value: 'development'\noptional values: 'development' | 'production'", + "visibility": "frontend", + "type": "string" + }, + "session": { + "type": "object", + "properties": { + "secret": { + "description": "The secret attribute of session object.", + "visibility": "secret", + "type": "string" + } + } + } + } + }, + "backend": { + "type": "object", + "required": ["baseUrl", "database", "listen"], + "description": "Generic backend configuration.", + "properties": { + "baseUrl": { + "type": "string", + "description": "The public absolute root URL that the backend is reachable at.", + "visibility": "frontend" + }, + "listen": { + "description": "Address that the backend should listen to.", + "anyOf": [ + { + "type": "object", + "properties": { + "address": { + "description": "Address of the interface that the backend should bind to.", + "type": "string" + }, + "port": { + "description": "Port that the backend should listen to.", + "type": ["string", "number"] + } + } + }, + { + "type": "string" + } + ] + }, + "https": { + "description": "HTTPS configuration for the backend. If omitted the backend will serve HTTP.\n\nSetting this to `true` will cause self-signed certificates to be generated, which\ncan be useful for local development or other non-production scenarios.", + "anyOf": [ + { + "type": "object", + "properties": { + "certificate": { + "description": "Certificate configuration", + "type": "object", + "required": ["cert", "key"], + "properties": { + "cert": { + "description": "PEM encoded certificate. Use $file to load in a file", + "type": "string" + }, + "key": { + "description": "PEM encoded certificate key. Use $file to load in a file.", + "visibility": "secret", + "type": "string" + } + } + } + } + }, + { + "enum": [true], + "type": "boolean" + } + ] + }, + "database": { + "description": "Database connection configuration, select database type using the `client` field", + "anyOf": [ + { + "type": "object", + "properties": { + "client": { + "type": "string", + "enum": ["sqlite3"] + }, + "connection": { + "type": "string" + } + }, + "required": ["client", "connection"] + }, + { + "type": "object", + "properties": { + "client": { + "type": "string", + "enum": ["pg"] + }, + "connection": { + "description": "PostgreSQL connection string or knex configuration object.", + "anyOf": [ + { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + { + "type": "string" + } + ] + } + }, + "required": ["client", "connection"] + } + ] + }, + "cors": { + "type": "object", + "properties": { + "origin": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "methods": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "allowedHeaders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "exposedHeaders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "credentials": { + "type": "boolean" + }, + "maxAge": { + "type": "number" + }, + "preflightContinue": { + "type": "boolean" + }, + "optionsSuccessStatus": { + "type": "number" + } + } + }, + "reading": { + "description": "Configuration related to URL reading, used for example for reading catalog info\nfiles, scaffolder templates, and techdocs content.", + "type": "object", + "properties": { + "allow": { + "description": "A list of targets to allow outgoing requests to. Users will be able to make\nrequests on behalf of the backend to the targets that are allowed by this list.", + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { + "description": "A host to allow outgoing requests to, being either a full host or\na subdomain wildcard pattern with a leading `*`. For example `example.com`\nand `*.example.com` are valid values, `prod.*.example.com` is not.\nThe host may also contain a port, for example `example.com:8080`.", + "type": "string" + } + } + } + } + } + }, + "csp": { + "description": "Content Security Policy options.\n\nThe keys are the plain policy ID, e.g. \"upgrade-insecure-requests\". The\nvalues are on the format that the helmet library expects them, as an\narray of strings. There is also the special value false, which means to\nremove the default value that Backstage puts in place for that policy.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "enum": [false], + "type": "boolean" + } + ] + } + } + } + }, + "organization": { + "description": "Configuration that provides information about the organization that the app is for.", + "type": "object", + "properties": { + "name": { + "description": "The name of the organization that the app belongs to.", + "visibility": "frontend", + "type": "string" + } + } + }, + "homepage": { + "type": "object", + "properties": { + "clocks": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "timezone"], + "properties": { + "label": { + "visibility": "frontend", + "type": "string" + }, + "timezone": { + "visibility": "frontend", + "type": "string" + } + } + } + } + } + }, + "integrations": { + "description": "Configuration for integrations towards various external repository provider systems", + "type": "object", + "properties": { + "azure": { + "description": "Integration configuration for Azure", + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { + "description": "The hostname of the given Azure instance", + "visibility": "frontend", + "type": "string" + }, + "token": { + "description": "Token used to authenticate requests.", + "visibility": "secret", + "type": "string" + } + } + } + }, + "bitbucket": { + "description": "Integration configuration for Bitbucket", + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { + "description": "The hostname of the given Bitbucket instance", + "visibility": "frontend", + "type": "string" + }, + "token": { + "description": "Token used to authenticate requests.", + "visibility": "secret", + "type": "string" + }, + "apiBaseUrl": { + "description": "The base url for the Bitbucket API, for example https://api.bitbucket.org/2.0", + "visibility": "frontend", + "type": "string" + }, + "username": { + "description": "The username to use for authenticated requests.", + "visibility": "secret", + "type": "string" + }, + "appPassword": { + "description": "Bitbucket app password used to authenticate requests.", + "visibility": "secret", + "type": "string" + } + } + } + }, + "github": { + "description": "Integration configuration for GitHub", + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { + "description": "The hostname of the given GitHub instance", + "visibility": "frontend", + "type": "string" + }, + "token": { + "description": "Token used to authenticate requests.", + "visibility": "secret", + "type": "string" + }, + "apiBaseUrl": { + "description": "The base url for the GitHub API, for example https://api.github.com", + "visibility": "frontend", + "type": "string" + }, + "rawBaseUrl": { + "description": "The base url for GitHub raw resources, for example https://raw.githubusercontent.com", + "visibility": "frontend", + "type": "string" + }, + "apps": { + "description": "GitHub Apps configuration", + "visibility": "backend", + "type": "array", + "items": { + "type": "object", + "required": [ + "appId", + "clientId", + "clientSecret", + "privateKey", + "webhookSecret" + ], + "properties": { + "appId": { + "description": "The numeric GitHub App ID", + "type": "number" + }, + "privateKey": { + "description": "The private key to use for auth against the app", + "visibility": "secret", + "type": "string" + }, + "webhookSecret": { + "description": "The secret used for webhooks", + "visibility": "secret", + "type": "string" + }, + "clientId": { + "description": "The client ID to use", + "type": "string" + }, + "clientSecret": { + "description": "The client secret to use", + "visibility": "secret", + "type": "string" + } + } + } + } + } + } + }, + "gitlab": { + "description": "Integration configuration for GitLab", + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { + "description": "The host of the target that this matches on, e.g. \"gitlab.com\".", + "visibility": "frontend", + "type": "string" + }, + "apiBaseUrl": { + "description": "The base URL of the API of this provider, e.g.\n\"https://gitlab.com/api/v4\", with no trailing slash.\n\nMay be omitted specifically for public GitLab; then it will be deduced.", + "visibility": "frontend", + "type": "string" + }, + "token": { + "description": "The authorization token to use for requests to this provider.\n\nIf no token is specified, anonymous access is used.", + "visibility": "secret", + "type": "string" + }, + "baseUrl": { + "description": "The baseUrl of this provider, e.g. \"https://gitlab.com\", which is\npassed into the GitLab client.\n\nIf no baseUrl is provided, it will default to https://${host}.", + "visibility": "frontend", + "type": "string" + } + } + } + } + } + }, + "catalog": { + "description": "Configuration options for the catalog plugin.", + "type": "object", + "properties": { + "rules": { + "description": "Rules to apply to all catalog entities, from any location.\n\nAn undefined list of matchers means match all, an empty list of\nmatchers means match none.\n\nThis is commonly used to put in what amounts to a whitelist of kinds\nthat regular users of Backstage are permitted to register locations\nfor. This can be used to stop them from registering yaml files\ndescribing for example a Group entity called \"admin\" that they make\nthemselves members of, or similar.", + "type": "array", + "items": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { + "description": "Allow entities of these particular kinds.\n\nE.g. [\"Component\", \"API\", \"Template\", \"Location\"]", + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "readonly": { + "description": "Readonly defines whether the catalog allows writes after startup.\n\nSetting 'readonly=false' allows users to register their own components.\nThis is the default value.\n\nSetting 'readonly=true' configures catalog to only allow reads. This can\nbe used in combination with static locations to only serve operator\nprovided locations. Effectively this removes the ability to register new\ncomponents to a running backstage instance.", + "type": "boolean" + }, + "locations": { + "description": "A set of static locations that the catalog shall always keep itself\nup-to-date with. This is commonly used for large, permanent integrations\nthat are defined by the Backstage operators at an organization, rather\nthan individual things that users register dynamically.\n\nThese have (optional) rules of their own. These override what the global\nrules above specify. This way, you can prevent everybody from register\ne.g. User and Group entities, except for one or a few static locations\nthat have those two kinds explicitly allowed.\n\nFor example:\n\n```yaml\nrules:\n - allow: [Component, API, Template, Location]\nlocations:\n - type: url\n target: https://github.com/org/repo/blob/master/users.yaml\n rules:\n - allow: [User, Group]\n - type: url\n target: https://github.com/org/repo/blob/master/systems.yaml\n rules:\n - allow: [System]\n```", + "type": "array", + "items": { + "type": "object", + "required": ["target", "type"], + "properties": { + "type": { + "description": "The type of location, e.g. \"url\".", + "type": "string" + }, + "target": { + "description": "The target URL of the location, e.g.\n\"https://github.com/org/repo/blob/master/users.yaml\".", + "type": "string" + }, + "rules": { + "description": "Optional extra rules that apply to this particular location.\n\nThese override the global rules above.", + "type": "array", + "items": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { + "description": "Allow entities of these particular kinds.\n\nE.g. [\"Group\", \"User\"]", + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "processors": { + "description": "List of processor-specific options and attributes", + "type": "object", + "properties": { + "githubOrg": { + "description": "GithubOrgReaderProcessor configuration", + "type": "object", + "required": ["providers"], + "properties": { + "providers": { + "description": "The configuration parameters for each single GitHub org provider.", + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { + "description": "The prefix of the target that this matches on, e.g.\n\"https://github.com\", with no trailing slash.", + "type": "string" + }, + "apiBaseUrl": { + "description": "The base URL of the API of this provider, e.g.\n\"https://api.github.com\", with no trailing slash.\n\nMay be omitted specifically for GitHub; then it will be deduced.", + "type": "string" + }, + "token": { + "description": "The authorization token to use for requests to this provider.\n\nIf no token is specified, anonymous access is used.", + "visibility": "secret", + "type": "string" + } + } + } + } + } + }, + "ldapOrg": { + "description": "LdapOrgReaderProcessor configuration", + "type": "object", + "required": ["providers"], + "properties": { + "providers": { + "description": "The configuration parameters for each single LDAP provider.", + "type": "array", + "items": { + "type": "object", + "required": ["groups", "target", "users"], + "properties": { + "target": { + "description": "The prefix of the target that this matches on, e.g.\n\"ldaps://ds.example.net\", with no trailing slash.", + "type": "string" + }, + "bind": { + "description": "The settings to use for the bind command. If none are specified,\nthe bind command is not issued.", + "type": "object", + "required": ["dn", "secret"], + "properties": { + "dn": { + "description": "The DN of the user to auth as.\n\nE.g. \"uid=ldap-robot,ou=robots,ou=example,dc=example,dc=net\"", + "type": "string" + }, + "secret": { + "description": "The secret of the user to auth as (its password).", + "visibility": "secret", + "type": "string" + } + } + }, + "users": { + "description": "The settings that govern the reading and interpretation of users.", + "type": "object", + "required": ["dn", "options"], + "properties": { + "dn": { + "description": "The DN under which users are stored.\n\nE.g. \"ou=people,ou=example,dc=example,dc=net\"", + "type": "string" + }, + "options": { + "description": "The search options to use. The default is scope \"one\" and\nattributes \"*\" and \"+\".\n\nIt is common to want to specify a filter, to narrow down the set\nof matching items.", + "type": "object", + "properties": { + "scope": { + "enum": ["base", "one", "sub"], + "type": "string" + }, + "filter": { + "type": "string" + }, + "attributes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "paged": { + "anyOf": [ + { + "type": "object", + "properties": { + "pageSize": { + "type": "number" + }, + "pagePause": { + "type": "boolean" + } + } + }, + { + "type": "boolean" + } + ] + } + } + }, + "set": { + "description": "JSON paths (on a.b.c form) and hard coded values to set on those\npaths.\n\nThis can be useful for example if you want to hard code a\nnamespace or similar on the generated entities.", + "type": "object" + }, + "map": { + "description": "Mappings from well known entity fields, to LDAP attribute names", + "type": "object", + "properties": { + "rdn": { + "description": "The name of the attribute that holds the relative\ndistinguished name of each entry. Defaults to \"uid\".", + "type": "string" + }, + "name": { + "description": "The name of the attribute that shall be used for the value of\nthe metadata.name field of the entity. Defaults to \"uid\".", + "type": "string" + }, + "description": { + "description": "The name of the attribute that shall be used for the value of\nthe metadata.description field of the entity.", + "type": "string" + }, + "displayName": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.displayName field of the entity. Defaults to\n\"cn\".", + "type": "string" + }, + "email": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.email field of the entity. Defaults to\n\"mail\".", + "type": "string" + }, + "picture": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.picture field of the entity.", + "type": "string" + }, + "memberOf": { + "description": "The name of the attribute that shall be used for the values of\nthe spec.memberOf field of the entity. Defaults to \"memberOf\".", + "type": "string" + } + } + } + } + }, + "groups": { + "description": "The settings that govern the reading and interpretation of groups.", + "type": "object", + "required": ["dn", "options"], + "properties": { + "dn": { + "description": "The DN under which groups are stored.\n\nE.g. \"ou=people,ou=example,dc=example,dc=net\"", + "type": "string" + }, + "options": { + "description": "The search options to use. The default is scope \"one\" and\nattributes \"*\" and \"+\".\n\nIt is common to want to specify a filter, to narrow down the set\nof matching items.", + "type": "object", + "properties": { + "scope": { + "enum": ["base", "one", "sub"], + "type": "string" + }, + "filter": { + "type": "string" + }, + "attributes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "paged": { + "anyOf": [ + { + "type": "object", + "properties": { + "pageSize": { + "type": "number" + }, + "pagePause": { + "type": "boolean" + } + } + }, + { + "type": "boolean" + } + ] + } + } + }, + "set": { + "description": "JSON paths (on a.b.c form) and hard coded values to set on those\npaths.\n\nThis can be useful for example if you want to hard code a\nnamespace or similar on the generated entities.", + "type": "object" + }, + "map": { + "description": "Mappings from well known entity fields, to LDAP attribute names", + "type": "object", + "properties": { + "rdn": { + "description": "The name of the attribute that holds the relative\ndistinguished name of each entry. Defaults to \"cn\".", + "type": "string" + }, + "name": { + "description": "The name of the attribute that shall be used for the value of\nthe metadata.name field of the entity. Defaults to \"cn\".", + "type": "string" + }, + "description": { + "description": "The name of the attribute that shall be used for the value of\nthe metadata.description field of the entity. Defaults to\n\"description\".", + "type": "string" + }, + "type": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.type field of the entity. Defaults to \"groupType\".", + "type": "string" + }, + "displayName": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.displayName field of the entity. Defaults to\n\"cn\".", + "type": "string" + }, + "email": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.email field of the entity.", + "type": "string" + }, + "picture": { + "description": "The name of the attribute that shall be used for the value of\nthe spec.profile.picture field of the entity.", + "type": "string" + }, + "memberOf": { + "description": "The name of the attribute that shall be used for the values of\nthe spec.parent field of the entity. Defaults to \"memberOf\".", + "type": "string" + }, + "members": { + "description": "The name of the attribute that shall be used for the values of\nthe spec.children field of the entity. Defaults to \"member\".", + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "awsOrganization": { + "description": "AwsOrganizationCloudAccountProcessor configuration", + "type": "object", + "required": ["provider"], + "properties": { + "provider": { + "type": "object", + "properties": { + "roleArn": { + "description": "The role to be assumed by this processor", + "type": "string" + } + } + } + } + }, + "microsoftGraphOrg": { + "description": "MicrosoftGraphOrgReaderProcessor configuration", + "type": "object", + "required": ["providers"], + "properties": { + "providers": { + "description": "The configuration parameters for each single Microsoft Graph provider.", + "type": "array", + "items": { + "type": "object", + "required": [ + "clientId", + "clientSecret", + "target", + "tenantId" + ], + "properties": { + "target": { + "description": "The prefix of the target that this matches on, e.g.\n\"https://graph.microsoft.com/v1.0\", with no trailing slash.", + "type": "string" + }, + "authority": { + "description": "The auth authority used.\n\nDefault value \"https://login.microsoftonline.com\"", + "type": "string" + }, + "tenantId": { + "description": "The tenant whose org data we are interested in.", + "type": "string" + }, + "clientId": { + "description": "The OAuth client ID to use for authenticating requests.", + "type": "string" + }, + "clientSecret": { + "description": "The OAuth client secret to use for authenticating requests.", + "visibility": "secret", + "type": "string" + }, + "userFilter": { + "description": "The filter to apply to extract users.\n\nE.g. \"accountEnabled eq true and userType eq 'member'\"", + "type": "string" + }, + "groupFilter": { + "description": "The filter to apply to extract groups.\n\nE.g. \"securityEnabled eq false and mailEnabled eq true\"", + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "costInsights": { + "type": "object", + "required": ["engineerCost", "products"], + "properties": { + "engineerCost": { + "visibility": "frontend", + "type": "number" + }, + "products": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "visibility": "frontend", + "type": "string" + }, + "icon": { + "visibility": "frontend", + "enum": [ + "compute", + "data", + "database", + "ml", + "search", + "storage" + ], + "type": "string" + } + } + } + }, + "metrics": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "visibility": "frontend", + "type": "string" + }, + "default": { + "visibility": "frontend", + "type": "boolean" + } + } + } + } + } + }, + "fossa": { + "type": "object", + "required": ["organizationId"], + "properties": { + "organizationId": { + "description": "The organization id in fossa.", + "visibility": "frontend", + "type": "string" + } + } + }, + "kubernetes": { + "type": "object", + "required": ["clusterLocatorMethods", "serviceLocatorMethod"], + "properties": { + "serviceLocatorMethod": { + "type": "object", + "required": ["type"], + "visibility": "frontend", + "properties": { + "type": { + "type": "string", + "enum": ["multiTenant"], + "visibility": "frontend" + } + } + }, + "clusterLocatorMethods": { + "type": "array", + "visibility": "frontend" + }, + "customResources": { + "type": "array", + "visibility": "frontend" + } + } + }, + "kafka": { + "type": "object", + "required": ["clientId", "clusters"], + "properties": { + "clientId": { + "description": "Client ID used to Backstage uses to identify when connecting to the Kafka cluster.", + "type": "string" + }, + "clusters": { + "type": "array", + "items": { + "type": "object", + "required": ["brokers", "name"], + "properties": { + "name": { + "type": "string" + }, + "brokers": { + "description": "List of brokers in the Kafka cluster to connect to.", + "type": "array", + "items": { + "type": "string" + } + }, + "ssl": { + "description": "Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.\nSee https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options", + "anyOf": [ + { + "type": "object", + "properties": { + "ca": { + "type": "array", + "items": { + "type": "string" + } + }, + "key": { + "visibility": "secret", + "type": "string" + }, + "cert": { + "type": "string" + } + }, + "required": ["ca", "cert", "key"] + }, + { + "type": "boolean" + } + ] + }, + "sasl": { + "description": "Optional SASL connection parameters.", + "type": "object", + "required": ["mechanism", "password", "username"], + "properties": { + "mechanism": { + "enum": ["plain", "scram-sha-256", "scram-sha-512"], + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "visibility": "secret", + "type": "string" + } + } + } + } + } + } + } + }, + "rollbar": { + "description": "Configuration options for the rollbar-backend plugin", + "type": "object", + "required": ["accountToken"], + "properties": { + "accountToken": { + "description": "The autentication token for accessing the Rollbar API", + "type": "string" + }, + "organization": { + "description": "The Rollbar organization name. This can be omitted by using the `rollbar.com/project-slug` annotation.", + "visibility": "frontend", + "type": "string" + } + } + }, + "proxy": { + "description": "A list of forwarding-proxies. Each key is a route to match,\nbelow the prefix that the proxy plugin is mounted on. It must\nstart with a '/'.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "target": { + "description": "Target of the proxy. Url string to be parsed with the url module.", + "type": "string" + }, + "headers": { + "description": "Object with extra headers to be added to target requests.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "changeOrigin": { + "description": "Changes the origin of the host header to the target URL. Default: true.", + "type": "boolean" + }, + "pathRewrite": { + "description": "Rewrite target's url path. Object-keys will be used as RegExp to match paths.\nIf pathRewrite is not specified, it is set to a single rewrite that removes the entire prefix and route.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allowedMethods": { + "description": "Limit the forwarded HTTP methods, for example allowedMethods: ['GET'] to enforce read-only access.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowedHeaders": { + "description": "Limit the forwarded HTTP methods. By default, only the headers that are considered safe for CORS\nand headers that are set by the proxy will be forwarded.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["target"] + }, + { + "type": "string" + } + ] + } + }, + "scaffolder": { + "description": "Configuration options for the scaffolder plugin", + "type": "object", + "properties": { + "github": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "properties": { + "visiblity": { + "description": "The visibility to set on created repositories.", + "enum": ["internal", "private", "public"], + "type": "string" + } + } + }, + "gitlab": { + "type": "object", + "required": ["api"], + "properties": { + "api": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "visiblity": { + "description": "The visibility to set on created repositories.", + "enum": ["internal", "private", "public"], + "type": "string" + } + } + }, + "azure": { + "type": "object", + "required": ["api", "baseUrl"], + "properties": { + "baseUrl": { + "type": "string" + }, + "api": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "bitbucket": { + "type": "object", + "required": ["api"], + "properties": { + "api": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "visiblity": { + "description": "The visibility to set on created repositories.", + "enum": ["private", "public"], + "type": "string" + } + } + } + } + }, + "sonarQube": { + "description": "Optional configurations for the SonarQube plugin", + "type": "object", + "required": ["baseUrl"], + "properties": { + "baseUrl": { + "description": "The base url of the sonarqube installation. Defaults to https://sonarcloud.io.", + "visibility": "frontend", + "type": "string" + } + } + }, + "sentry": { + "description": "Configuration options for the sentry plugin", + "type": "object", + "required": ["organization"], + "properties": { + "organization": { + "description": "The 'organization' attribute", + "visibility": "frontend", + "type": "string" + } + } + }, + "techdocs": { + "description": "Configuration options for the techdocs-backend plugin", + "type": "object", + "required": ["builder"], + "properties": { + "builder": { + "description": "Documentation building process depends on the builder attr", + "visibility": "frontend", + "enum": ["external", "local"], + "type": "string" + }, + "generators": { + "description": "Techdocs generator information", + "type": "object", + "required": ["techdocs"], + "properties": { + "techdocs": { + "enum": ["docker", "local"], + "type": "string" + } + } + }, + "publisher": { + "description": "Techdocs publisher information", + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["local"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["awsS3"] + }, + "awsS3": { + "description": "Required when 'type' is set to awsS3", + "type": "object", + "required": ["bucketName"], + "properties": { + "credentials": { + "description": "(Optional) Credentials used to access a storage bucket.\nIf not set, environment variables or aws config file will be used to authenticate.", + "visibility": "secret", + "type": "object", + "required": ["accessKeyId", "secretAccessKey"], + "properties": { + "accessKeyId": { + "description": "User access key id", + "visibility": "secret", + "type": "string" + }, + "secretAccessKey": { + "description": "User secret access key", + "visibility": "secret", + "type": "string" + }, + "roleArn": { + "description": "ARN of role to be assumed", + "visibility": "backend", + "type": "string" + } + } + }, + "bucketName": { + "description": "(Required) Cloud Storage Bucket Name", + "visibility": "backend", + "type": "string" + }, + "region": { + "description": "(Optional) AWS Region.\nIf not set, AWS_REGION environment variable or aws config file will be used.", + "visibility": "secret", + "type": "string" + }, + "endpoint": { + "description": "(Optional) AWS Endpoint.\nThe endpoint URI to send requests to. The default endpoint is built from the configured region.", + "visibility": "secret", + "type": "string" + } + } + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openStackSwift"] + }, + "openStackSwift": { + "description": "Required when 'type' is set to openStackSwift", + "type": "object", + "required": [ + "authUrl", + "containerName", + "credentials", + "domainId", + "domainName", + "keystoneAuthVersion", + "region" + ], + "properties": { + "credentials": { + "description": "(Required) Credentials used to access a storage bucket.", + "visibility": "secret", + "type": "object", + "required": ["password", "username"], + "properties": { + "username": { + "description": "(Required) Root user name", + "visibility": "secret", + "type": "string" + }, + "password": { + "description": "(Required) Root user password", + "visibility": "secret", + "type": "string" + } + } + }, + "containerName": { + "description": "(Required) Cloud Storage Container Name", + "visibility": "backend", + "type": "string" + }, + "authUrl": { + "description": "(Required) Auth url sometimes OpenStack uses different port check your OpenStack apis.", + "visibility": "backend", + "type": "string" + }, + "keystoneAuthVersion": { + "description": "(Optional) Auth version\nIf not set, 'v2.0' will be used.", + "visibility": "backend", + "type": "string" + }, + "domainId": { + "description": "(Required) Domain Id", + "visibility": "backend", + "type": "string" + }, + "domainName": { + "description": "(Required) Domain Name", + "visibility": "backend", + "type": "string" + }, + "region": { + "description": "(Required) Region", + "visibility": "backend", + "type": "string" + } + } + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["azureBlobStorage"] + }, + "azureBlobStorage": { + "description": "Required when 'type' is set to azureBlobStorage", + "type": "object", + "required": ["containerName", "credentials"], + "properties": { + "credentials": { + "description": "(Required) Credentials used to access a storage container.", + "visibility": "secret", + "type": "object", + "required": ["accountName"], + "properties": { + "accountName": { + "description": "Account access name", + "visibility": "secret", + "type": "string" + }, + "accountKey": { + "description": "(Optional) Account secret primary key\nIf not set, environment variables will be used to authenticate.", + "visibility": "secret", + "type": "string" + } + } + }, + "containerName": { + "description": "(Required) Cloud Storage Container Name", + "visibility": "backend", + "type": "string" + } + } + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["googleGcs"] + }, + "googleGcs": { + "description": "Required when 'type' is set to googleGcs", + "type": "object", + "required": ["bucketName"], + "properties": { + "bucketName": { + "description": "(Required) Cloud Storage Bucket Name", + "visibility": "backend", + "type": "string" + }, + "credentials": { + "description": "(Optional) API key used to write to a storage bucket.\nIf not set, environment variables will be used to authenticate.", + "visibility": "secret", + "type": "string" + } + } + } + }, + "required": ["type"] + } + ] + }, + "requestUrl": { + "visibility": "frontend", + "type": "string" + }, + "storageUrl": { + "type": "string" + } + } + }, + "travisci": { + "description": "Configuration options for the travisci plugin", + "type": "object", + "properties": { + "baseUrl": { + "description": "The 'baseUrl' attribute. It should point to the address of the travis portal.\nIf not provided, frontend plugin will use 'https://travis-ci.com/'", + "visibility": "frontend", + "type": "string" + } + } + } + }, + "description": "This is the schema describing the structure of the app-config.yaml configuration file." +} diff --git a/plugins/config-schema/dev/index.tsx b/plugins/config-schema/dev/index.tsx new file mode 100644 index 0000000000..412f0d283e --- /dev/null +++ b/plugins/config-schema/dev/index.tsx @@ -0,0 +1,42 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { Schema } from 'jsonschema'; +import React from 'react'; +import Observable from 'zen-observable'; +import { configSchemaApiRef } from '../src/api'; +import { ConfigSchemaResult } from '../src/api/types'; +import { ConfigSchemaPage, configSchemaPlugin } from '../src/plugin'; +import exampleSchema from './example-schema.json'; + +createDevApp() + .registerPlugin(configSchemaPlugin) + .registerApi({ + api: configSchemaApiRef, + deps: {}, + factory: () => ({ + schema$: () => + new Observable(sub => + sub.next({ schema: (exampleSchema as unknown) as Schema }), + ), + }), + }) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json new file mode 100644 index 0000000000..2db05d549a --- /dev/null +++ b/plugins/config-schema/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/plugin-config-schema", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.5", + "@backstage/errors": "^0.1.1", + "@backstage/theme": "^0.2.5", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "jsonschema": "^1.2.6", + "zen-observable": "^0.8.15", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.8", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.10", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/config-schema/src/api/StaticSchemaLoader.ts b/plugins/config-schema/src/api/StaticSchemaLoader.ts new file mode 100644 index 0000000000..ee61370468 --- /dev/null +++ b/plugins/config-schema/src/api/StaticSchemaLoader.ts @@ -0,0 +1,57 @@ +/* + * 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 ObservableImpl from 'zen-observable'; +import { ResponseError } from '@backstage/errors'; +import { Schema } from 'jsonschema'; +import { ConfigSchemaApi, ConfigSchemaResult } from './types'; + +const DEFAULT_URL = 'config-schema.json'; + +/** + * A ConfigSchemaApi implementation that loads the configuration from a URL. + */ +export class StaticSchemaLoader implements ConfigSchemaApi { + private readonly url: string; + + constructor({ url = DEFAULT_URL }: { url?: string } = {}) { + this.url = url; + } + + schema$(): Observable { + return new ObservableImpl(subscriber => { + this.fetchSchema().then( + schema => subscriber.next({ schema }), + error => subscriber.error(error), + ); + }); + } + + private async fetchSchema(): Promise { + const res = await fetch(this.url); + + if (!res.ok) { + if (res.status === 404) { + return undefined; + } + + throw ResponseError.fromResponse(res); + } + + return await res.json(); + } +} diff --git a/plugins/config-schema/src/api/index.ts b/plugins/config-schema/src/api/index.ts new file mode 100644 index 0000000000..eb705b3fa3 --- /dev/null +++ b/plugins/config-schema/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { configSchemaApiRef } from './types'; +export type { ConfigSchemaApi } from './types'; +export { StaticSchemaLoader } from './StaticSchemaLoader'; diff --git a/plugins/config-schema/src/api/types.ts b/plugins/config-schema/src/api/types.ts new file mode 100644 index 0000000000..025ef76f0c --- /dev/null +++ b/plugins/config-schema/src/api/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { createApiRef, Observable } from '@backstage/core'; +import { Schema } from 'jsonschema'; + +export interface ConfigSchemaResult { + schema?: Schema; +} + +export interface ConfigSchemaApi { + schema$(): Observable; +} + +export const configSchemaApiRef = createApiRef({ + id: 'plugin.config-schema', +}); diff --git a/plugins/config-schema/src/components/ConfigSchemaPage/ConfigSchemaPage.tsx b/plugins/config-schema/src/components/ConfigSchemaPage/ConfigSchemaPage.tsx new file mode 100644 index 0000000000..a2082ef972 --- /dev/null +++ b/plugins/config-schema/src/components/ConfigSchemaPage/ConfigSchemaPage.tsx @@ -0,0 +1,48 @@ +/* + * 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, { useMemo } from 'react'; +import { Header, Page, Content, useApi, Progress } from '@backstage/core'; +import { useObservable } from 'react-use'; +import { configSchemaApiRef } from '../../api'; +import { SchemaViewer } from '../SchemaViewer'; +import { Typography } from '@material-ui/core'; + +export const ConfigSchemaPage = () => { + const configSchemaApi = useApi(configSchemaApiRef); + const schemaResult = useObservable( + useMemo(() => configSchemaApi.schema$(), [configSchemaApi]), + ); + + let content; + if (schemaResult) { + if (schemaResult.schema) { + content = ; + } else { + content = ( + No configuration schema available + ); + } + } else { + content = ; + } + + return ( + +
+ {content} + + ); +}; diff --git a/plugins/config-schema/src/components/ConfigSchemaPage/index.ts b/plugins/config-schema/src/components/ConfigSchemaPage/index.ts new file mode 100644 index 0000000000..e373ae71c6 --- /dev/null +++ b/plugins/config-schema/src/components/ConfigSchemaPage/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { ConfigSchemaPage } from './ConfigSchemaPage'; diff --git a/plugins/config-schema/src/components/SchemaBrowser/SchemaBrowser.tsx b/plugins/config-schema/src/components/SchemaBrowser/SchemaBrowser.tsx new file mode 100644 index 0000000000..52005802b8 --- /dev/null +++ b/plugins/config-schema/src/components/SchemaBrowser/SchemaBrowser.tsx @@ -0,0 +1,196 @@ +/* + * 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 { createStyles, fade, withStyles } from '@material-ui/core'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { TreeItem, TreeItemProps, TreeView } from '@material-ui/lab'; +import { Schema } from 'jsonschema'; +import React, { ReactNode, useMemo, useRef } from 'react'; +import { useScrollTargets } from '../ScrollTargetsContext'; + +const StyledTreeItem = withStyles(theme => + createStyles({ + label: { + userSelect: 'none', + }, + group: { + marginLeft: 7, + paddingLeft: theme.spacing(1), + borderLeft: `1px solid ${fade(theme.palette.text.primary, 0.15)}`, + }, + }), +)((props: TreeItemProps) => ); + +export function createSchemaBrowserItems( + expanded: string[], + schema: Schema, + path: string = '', + depth: number = 0, +): ReactNode { + let matchArr; + if (schema.anyOf) { + matchArr = schema.anyOf; + } else if (schema.oneOf) { + matchArr = schema.oneOf; + } else if (schema.allOf) { + matchArr = schema.allOf; + } + if (matchArr) { + return matchArr.map((childSchema, index) => { + const childPath = `${path}/${index + 1}`; + if (depth > 0) expanded.push(childPath); + return ( + `} + > + {createSchemaBrowserItems( + expanded, + childSchema, + childPath, + depth + 1, + )} + + ); + }); + } + + switch (schema.type) { + case 'array': { + const childPath = `${path}[]`; + if (depth > 0) expanded.push(childPath); + return ( + + {schema.items && + createSchemaBrowserItems( + expanded, + schema.items as Schema, + childPath, + depth + 1, + )} + + ); + } + case 'object': + case undefined: { + const children = []; + + if (schema.properties) { + children.push( + ...Object.entries(schema.properties).map(([name, childSchema]) => { + const childPath = path ? `${path}.${name}` : name; + if (depth > 0) expanded.push(childPath); + return ( + + {createSchemaBrowserItems( + expanded, + childSchema, + childPath, + depth + 1, + )} + + ); + }), + ); + } + + if (schema.patternProperties) { + children.push( + ...Object.entries(schema.patternProperties).map( + ([name, childSchema]) => { + const childPath = `${path}.<${name}>`; + if (depth > 0) expanded.push(childPath); + return ( + `} + > + {createSchemaBrowserItems( + expanded, + childSchema, + childPath, + depth + 1, + )} + + ); + }, + ), + ); + } + + if (schema.additionalProperties && schema.additionalProperties !== true) { + const childPath = `${path}.*`; + if (depth > 0) expanded.push(childPath); + children.push( + + {createSchemaBrowserItems( + expanded, + schema.additionalProperties, + childPath, + depth + 1, + )} + , + ); + } + + return <>{children}; + } + + default: + return null; + } +} + +export function SchemaBrowser({ schema }: { schema: Schema }) { + const scroll = useScrollTargets(); + const expandedRef = useRef([]); + const data = useMemo(() => { + const expanded = new Array(); + + const items = createSchemaBrowserItems(expanded, schema); + + return { items, expanded }; + }, [schema]); + + if (!scroll) { + throw new Error('No scroll handler available'); + } + + const handleToggle = (_event: unknown, expanded: string[]) => { + expandedRef.current = expanded; + }; + + const handleSelect = (_event: unknown, nodeId: string) => { + if (expandedRef.current.includes(nodeId)) { + scroll.scrollTo(nodeId); + } + }; + + return ( + } + defaultExpandIcon={} + onNodeToggle={handleToggle} + onNodeSelect={handleSelect} + > + {data.items} + + ); +} diff --git a/plugins/config-schema/src/components/SchemaBrowser/index.ts b/plugins/config-schema/src/components/SchemaBrowser/index.ts new file mode 100644 index 0000000000..2b3e8fe79a --- /dev/null +++ b/plugins/config-schema/src/components/SchemaBrowser/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 { SchemaBrowser } from './SchemaBrowser'; diff --git a/plugins/config-schema/src/components/SchemaView/ArrayView.tsx b/plugins/config-schema/src/components/SchemaView/ArrayView.tsx new file mode 100644 index 0000000000..15f4dd8800 --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/ArrayView.tsx @@ -0,0 +1,59 @@ +/* + * 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 { Box, Typography } from '@material-ui/core'; +import { Schema } from 'jsonschema'; +import React from 'react'; +import { ChildView } from './ChildView'; +import { MetadataView } from './MetadataView'; +import { SchemaViewProps } from './types'; + +export function ArrayView({ path, depth, schema }: SchemaViewProps) { + const itemDepth = depth + 1; + const itemPath = path ? `${path}[]` : '[]'; + const itemSchema = schema.items; + + return ( + <> + + {schema.description && ( + + {schema.description} + + )} + + + Items + + {schema.additionalItems && schema.additionalItems !== true && ( + <> + Additional Items + + + )} + + ); +} diff --git a/plugins/config-schema/src/components/SchemaView/ChildView.tsx b/plugins/config-schema/src/components/SchemaView/ChildView.tsx new file mode 100644 index 0000000000..ff970d5251 --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/ChildView.tsx @@ -0,0 +1,123 @@ +/* + * 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 { JsonValue } from '@backstage/config'; +import { Box, Chip, Divider, makeStyles, Typography } from '@material-ui/core'; +import { Schema } from 'jsonschema'; +import React, { useEffect, useRef } from 'react'; +import { useScrollTargets } from '../ScrollTargetsContext/ScrollTargetsContext'; +import { SchemaView } from './SchemaView'; + +export interface MetadataViewRowProps { + label: string; + text?: string; + data?: JsonValue; +} + +function titleVariant(depth: number) { + if (depth <= 1) { + return 'h2'; + } else if (depth === 2) { + return 'h3'; + } else if (depth === 3) { + return 'h4'; + } else if (depth === 4) { + return 'h5'; + } + return 'h6'; +} + +const useChildViewStyles = makeStyles(theme => ({ + title: { + marginBottom: 0, + }, + chip: { + marginLeft: theme.spacing(1), + marginRight: 0, + marginBottom: 0, + }, +})); + +export function ChildView({ + path, + depth, + schema, + required, + lastChild, +}: { + path: string; + depth: number; + schema?: Schema; + required?: boolean; + lastChild?: boolean; +}) { + const classes = useChildViewStyles(); + const titleRef = useRef(null); + const scroll = useScrollTargets(); + + useEffect(() => { + return scroll?.setScrollListener(path, () => { + titleRef.current?.scrollIntoView({ behavior: 'smooth' }); + }); + }, [scroll, path]); + + const chips = new Array(); + const chipProps = { size: 'small' as const, classes: { root: classes.chip } }; + + if (required) { + chips.push( + , + ); + } + + const visibility = (schema as { visibility?: string })?.visibility; + if (visibility === 'frontend') { + chips.push( + , + ); + } else if (visibility === 'secret') { + chips.push( + , + ); + } + + return ( + + + + + + {path} + + {chips.length > 0 && } + {chips} + + {schema && ( + + )} + + + ); +} diff --git a/plugins/config-schema/src/components/SchemaView/MatchView.tsx b/plugins/config-schema/src/components/SchemaView/MatchView.tsx new file mode 100644 index 0000000000..db6060b31a --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/MatchView.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Typography } from '@material-ui/core'; +import { Schema } from 'jsonschema'; +import React from 'react'; +import { ChildView } from './ChildView'; + +export function MatchView({ + path, + depth, + schema, + label, +}: { + path: string; + depth: number; + schema: Schema[]; + label: string; +}) { + return ( + <> + {label} + {schema.map((optionSchema, index) => ( + + ))} + + ); +} diff --git a/plugins/config-schema/src/components/SchemaView/MetadataView.tsx b/plugins/config-schema/src/components/SchemaView/MetadataView.tsx new file mode 100644 index 0000000000..41d48149ad --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/MetadataView.tsx @@ -0,0 +1,110 @@ +/* + * 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 { JsonValue } from '@backstage/config'; +import { + Paper, + Table, + TableBody, + TableCell, + TableRow, + Typography, +} from '@material-ui/core'; +import { Schema } from 'jsonschema'; +import React from 'react'; + +export interface MetadataViewRowProps { + label: string; + text?: string; + data?: JsonValue; +} + +export function MetadataViewRow({ label, text, data }: MetadataViewRowProps) { + if (text === undefined && data === undefined) { + return null; + } + return ( + + + + {label} + + + + + {data ? JSON.stringify(data) : text} + + + + ); +} + +export function MetadataView({ schema }: { schema: Schema }) { + return ( + + + + + + {schema.additionalProperties === true && ( + + )} + {schema.additionalItems === true && ( + + )} + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/plugins/config-schema/src/components/SchemaView/ObjectView.tsx b/plugins/config-schema/src/components/SchemaView/ObjectView.tsx new file mode 100644 index 0000000000..8c72e169d3 --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/ObjectView.tsx @@ -0,0 +1,94 @@ +/* + * 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 { Box, Typography } from '@material-ui/core'; +import React from 'react'; +import { ChildView } from './ChildView'; +import { MetadataView } from './MetadataView'; +import { SchemaViewProps } from './types'; + +function isRequired(name: string, required?: boolean | string[]) { + if (required === true) { + return true; + } + if (Array.isArray(required)) { + return required.includes(name); + } + return false; +} + +export function ObjectView({ path, depth, schema }: SchemaViewProps) { + const properties = Object.entries(schema.properties ?? {}); + const patternProperties = Object.entries(schema.patternProperties ?? {}); + + return ( + <> + {depth > 0 && ( + + {schema.description && ( + + {schema.description} + + )} + + + )} + {properties.length > 0 && ( + <> + {depth > 0 && Properties} + {properties.map(([name, propSchema], index) => ( + + ))} + + )} + {patternProperties.length > 0 && ( + <> + {depth > 0 && ( + Pattern Properties + )} + {patternProperties.map(([name, propSchema], index) => ( + ` : name} + depth={depth + 1} + schema={propSchema} + lastChild={index === patternProperties.length - 1} + required={isRequired(name, schema.required)} + /> + ))} + + )} + {schema.additionalProperties && schema.additionalProperties !== true && ( + <> + Additional Properties + + + )} + + ); +} diff --git a/plugins/config-schema/src/components/SchemaView/ScalarView.tsx b/plugins/config-schema/src/components/SchemaView/ScalarView.tsx new file mode 100644 index 0000000000..1349358abd --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/ScalarView.tsx @@ -0,0 +1,33 @@ +/* + * 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 { Box, Typography } from '@material-ui/core'; +import React from 'react'; +import { MetadataView } from './MetadataView'; +import { SchemaViewProps } from './types'; + +export function ScalarView({ schema }: SchemaViewProps) { + return ( + <> + {schema.description && ( + + {schema.description} + + )} + + + ); +} diff --git a/plugins/config-schema/src/components/SchemaView/SchemaView.tsx b/plugins/config-schema/src/components/SchemaView/SchemaView.tsx new file mode 100644 index 0000000000..bf5d7fd18c --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/SchemaView.tsx @@ -0,0 +1,62 @@ +/* + * 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 { ArrayView } from './ArrayView'; +import { MatchView } from './MatchView'; +import { ObjectView } from './ObjectView'; +import { ScalarView } from './ScalarView'; +import { SchemaViewProps } from './types'; + +export function SchemaView(props: SchemaViewProps) { + const { schema } = props; + if (schema.anyOf) { + return ( + + ); + } + if (schema.oneOf) { + return ( + + ); + } + if (schema.allOf) { + return ( + + ); + } + switch (schema.type) { + case 'array': + return ; + case 'object': + case undefined: + return ; + default: + return ; + } +} diff --git a/plugins/config-schema/src/components/SchemaView/index.ts b/plugins/config-schema/src/components/SchemaView/index.ts new file mode 100644 index 0000000000..8840696be6 --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/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 { SchemaView } from './SchemaView'; diff --git a/plugins/config-schema/src/components/SchemaView/types.ts b/plugins/config-schema/src/components/SchemaView/types.ts new file mode 100644 index 0000000000..94b676ec3e --- /dev/null +++ b/plugins/config-schema/src/components/SchemaView/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. + */ + +import { Schema } from 'jsonschema'; + +export interface SchemaViewProps { + path: string; + depth: number; + schema: Schema; +} diff --git a/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.test.tsx b/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.test.tsx new file mode 100644 index 0000000000..48dc92098a --- /dev/null +++ b/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { Schema } from 'jsonschema'; +import React from 'react'; +import { SchemaViewer } from './SchemaViewer'; + +describe('SchemaViewer', () => { + it('should render a simple schema', () => { + const schema = { + type: 'object', + properties: { + a: { + description: 'My A', + type: 'string', + }, + b: { + description: 'My B', + type: 'number', + }, + }, + }; + + render(); + + expect(screen.getAllByText('a').length).toBe(2); + expect(screen.getByText('My A')).toBeInTheDocument(); + + expect(screen.getAllByText('b').length).toBe(2); + expect(screen.getByText('My B')).toBeInTheDocument(); + }); + + it('should render complex schema', () => { + const schema: Schema = { + type: 'object', + properties: { + a: { + description: 'My A', + type: 'object', + patternProperties: { + 'prefix.*': { + description: 'Prefix prop', + type: 'string', + }, + }, + additionalProperties: { + description: 'Additional properties for A', + type: 'number', + minimum: 72, + maximum: 79, + }, + }, + b: { + oneOf: [ + { type: 'string', description: 'B one of 1' }, + { + type: 'array', + description: 'B one of 2', + items: { + anyOf: [ + { type: 'string', description: 'Any of B 1' }, + { type: 'number', description: 'Any of B 2' }, + { + type: 'object', + description: 'Any of B 3', + properties: { + deep: { + allOf: [ + { + type: 'number', + description: 'Impossible 1', + }, + { + type: 'boolean', + description: 'Impossible 2', + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + render(); + + expect(screen.getAllByText('a').length).toBe(2); + expect(screen.getByText('My A')).toBeInTheDocument(); + expect(screen.getByText('a.')).toBeInTheDocument(); + expect(screen.getByText('Prefix prop')).toBeInTheDocument(); + + expect(screen.getByText('a.*')).toBeInTheDocument(); + expect(screen.getByText('Additional properties for A')).toBeInTheDocument(); + expect(screen.getByText('72')).toBeInTheDocument(); + expect(screen.getByText('79')).toBeInTheDocument(); + + expect(screen.getAllByText('b').length).toBe(2); + expect(screen.getByText('b/1')).toBeInTheDocument(); + expect(screen.getByText('B one of 1')).toBeInTheDocument(); + + expect(screen.getByText('b/2')).toBeInTheDocument(); + expect(screen.getByText('B one of 2')).toBeInTheDocument(); + + expect(screen.getByText('b/2[]')).toBeInTheDocument(); + expect(screen.getByText('b/2[]/1')).toBeInTheDocument(); + expect(screen.getByText('Any of B 1')).toBeInTheDocument(); + expect(screen.getByText('b/2[]/2')).toBeInTheDocument(); + expect(screen.getByText('Any of B 2')).toBeInTheDocument(); + expect(screen.getByText('b/2[]/3')).toBeInTheDocument(); + expect(screen.getByText('Any of B 3')).toBeInTheDocument(); + + expect(screen.getByText('b/2[]/3.deep')).toBeInTheDocument(); + expect(screen.getByText('b/2[]/3.deep/1')).toBeInTheDocument(); + expect(screen.getByText('Impossible 1')).toBeInTheDocument(); + expect(screen.getByText('b/2[]/3.deep/2')).toBeInTheDocument(); + expect(screen.getByText('Impossible 2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.tsx b/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.tsx new file mode 100644 index 0000000000..3ed3cca0ee --- /dev/null +++ b/plugins/config-schema/src/components/SchemaViewer/SchemaViewer.tsx @@ -0,0 +1,53 @@ +/* + * 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 { Box, Paper } from '@material-ui/core'; +import { Schema } from 'jsonschema'; +import React from 'react'; +import { SchemaView } from '../SchemaView'; +import { SchemaBrowser } from '../SchemaBrowser'; +import { ScrollTargetsProvider } from '../ScrollTargetsContext/ScrollTargetsContext'; + +export interface SchemaViewerProps { + schema: Schema; +} + +export const SchemaViewer = ({ schema }: SchemaViewerProps) => { + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/config-schema/src/components/SchemaViewer/index.ts b/plugins/config-schema/src/components/SchemaViewer/index.ts new file mode 100644 index 0000000000..4852c4e21d --- /dev/null +++ b/plugins/config-schema/src/components/SchemaViewer/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 { SchemaViewer } from './SchemaViewer'; diff --git a/plugins/config-schema/src/components/ScrollTargetsContext/ScrollTargetsContext.tsx b/plugins/config-schema/src/components/ScrollTargetsContext/ScrollTargetsContext.tsx new file mode 100644 index 0000000000..085b237754 --- /dev/null +++ b/plugins/config-schema/src/components/ScrollTargetsContext/ScrollTargetsContext.tsx @@ -0,0 +1,52 @@ +/* + * 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, { createContext, ReactNode, useContext } from 'react'; + +class ScrollTargetsForwarder { + private readonly listeners = new Map void>(); + + setScrollListener(id: string, listener: () => void): () => void { + this.listeners.set(id, listener); + + return () => { + if (this.listeners.get(id) === listener) { + this.listeners.delete(id); + } + }; + } + + scrollTo(id: string) { + this.listeners.get(id)?.(); + } +} + +const ScrollTargetsContext = createContext( + undefined, +); + +export function ScrollTargetsProvider({ children }: { children: ReactNode }) { + return ( + + ); +} + +export function useScrollTargets() { + return useContext(ScrollTargetsContext); +} diff --git a/plugins/config-schema/src/components/ScrollTargetsContext/index.ts b/plugins/config-schema/src/components/ScrollTargetsContext/index.ts new file mode 100644 index 0000000000..d2d35ec6b1 --- /dev/null +++ b/plugins/config-schema/src/components/ScrollTargetsContext/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { + ScrollTargetsProvider, + useScrollTargets, +} from './ScrollTargetsContext'; diff --git a/plugins/config-schema/src/index.ts b/plugins/config-schema/src/index.ts new file mode 100644 index 0000000000..009b093d50 --- /dev/null +++ b/plugins/config-schema/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './api'; +export { configSchemaPlugin, ConfigSchemaPage } from './plugin'; diff --git a/plugins/techdocs-backend/src/service/helpers.test.ts b/plugins/config-schema/src/plugin.test.ts similarity index 54% rename from plugins/techdocs-backend/src/service/helpers.test.ts rename to plugins/config-schema/src/plugin.test.ts index 507373107d..0f71d800d4 100644 --- a/plugins/techdocs-backend/src/service/helpers.test.ts +++ b/plugins/config-schema/src/plugin.test.ts @@ -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. @@ -13,14 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getEntityNameFromUrlPath } from './helpers'; +import { configSchemaPlugin } from './plugin'; -describe('getEntityNameFromUrlPath', () => { - it('should parse correctly', () => { - const path = 'default/Component/documented-component'; - const parsedEntity = getEntityNameFromUrlPath(path); - expect(parsedEntity).toHaveProperty('namespace', 'default'); - expect(parsedEntity).toHaveProperty('kind', 'Component'); - expect(parsedEntity).toHaveProperty('name', 'documented-component'); +describe('config-schema', () => { + it('should export plugin', () => { + expect(configSchemaPlugin).toBeDefined(); }); }); diff --git a/plugins/config-schema/src/plugin.ts b/plugins/config-schema/src/plugin.ts new file mode 100644 index 0000000000..841f9f7f04 --- /dev/null +++ b/plugins/config-schema/src/plugin.ts @@ -0,0 +1,33 @@ +/* + * 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 { createPlugin, createRoutableExtension } from '@backstage/core'; + +import { rootRouteRef } from './routes'; + +export const configSchemaPlugin = createPlugin({ + id: 'config-schema', + routes: { + root: rootRouteRef, + }, +}); + +export const ConfigSchemaPage = configSchemaPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ConfigSchemaPage').then(m => m.ConfigSchemaPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/config-schema/src/routes.ts b/plugins/config-schema/src/routes.ts new file mode 100644 index 0000000000..8128e1f49f --- /dev/null +++ b/plugins/config-schema/src/routes.ts @@ -0,0 +1,20 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: 'config-schema', +}); diff --git a/plugins/config-schema/src/setupTests.ts b/plugins/config-schema/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/config-schema/src/setupTests.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. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 7aece742fc..bec5db9fe0 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -55,9 +55,9 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/explore/package.json b/plugins/explore/package.json index c640be6f23..7765760906 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/plugin-explore-react": "^0.0.4", "@backstage/theme": "^0.2.5", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 6e53ced0cf..722d8d2cf3 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -50,9 +50,7 @@ proxy: target: https://app.fossa.io/api allowedMethods: ['GET'] headers: - Authorization: - # Content: 'token ' - $env: FOSSA_AUTH_HEADER + Authorization: token ${FOSSA_API_TOKEN} # if you have a fossa organization, configure your id here fossa: diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index d503f76928..fd94c6b906 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -44,9 +44,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 0f05e9d05b..fecf9bdf22 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 8cc9471613..3dba840b8b 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -34,7 +34,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/integration": "^0.5.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md new file mode 100644 index 0000000000..9369ef87f9 --- /dev/null +++ b/plugins/github-deployments/CHANGELOG.md @@ -0,0 +1,14 @@ +# @backstage/plugin-github-deployments + +## 0.1.2 + +### Patch Changes + +- 64d2ce700: Add a button to reload the GitHub Deployments card +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index b8761c1385..c5571b6e1f 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.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.3", + "@backstage/catalog-model": "^0.7.6", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -34,9 +34,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index a82a2c26ed..d3a0d0df66 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,9 +42,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index b9e61b02da..4ebedffd74 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 13ff9b3547..b3bc21b7d5 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -29,15 +29,13 @@ proxy: target: 'http://localhost:8080' # your Jenkins URL changeOrigin: true headers: - Authorization: - $env: JENKINS_BASIC_AUTH_HEADER + Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER} ``` 4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password). Here user is the name of the user created in Jenkins. ```shell -HEADER=$(echo -n user:api-token | base64) -export JENKINS_BASIC_AUTH_HEADER="Basic $HEADER" +export JENKINS_BASIC_AUTH_HEADER=$(echo -n user:api-token | base64) ``` 5. Run app with `yarn start` diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index f580dc2d15..c736eb0b5b 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index fb4ec0db41..644c074b81 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -33,9 +33,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 0f61b71858..379f0dd872 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes-backend +## 0.3.4 + +### Patch Changes + +- 7fd46f26d: Use `string` TypeScript type instead of `String`. +- Updated dependencies [bb5055aee] +- Updated dependencies [5d0740563] + - @backstage/catalog-model@0.7.7 + ## 0.3.3 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index c6f9f02dc2..79864b7b48 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.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@google-cloud/container": "^2.2.0", "@kubernetes/client-node": "^0.14.0", @@ -52,7 +52,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@types/aws4": "^1.5.1", "supertest": "^6.1.3" }, diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index 4ae2aff35a..d222bdbce1 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -21,7 +21,7 @@ import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( - authProvider: String, + authProvider: string, ): KubernetesAuthTranslator { switch (authProvider) { case 'google': { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index e76a114d54..7ec1a317d8 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -34,8 +34,8 @@ const mockFetch = (mock: jest.Mock) => { }; function generateMockResourcesAndErrors( - serviceId: String, - clusterName: String, + serviceId: string, + clusterName: string, ) { if (clusterName === 'empty-cluster') { return { diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 71f11e7a68..df00c0801f 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.4", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.3", "@backstage/plugin-kubernetes-backend": "^0.3.2", "@backstage/theme": "^0.2.5", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 7f5b678afd..eb7d69d77d 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/newrelic/README.md b/plugins/newrelic/README.md index e14acedef2..e7cdd0df4a 100644 --- a/plugins/newrelic/README.md +++ b/plugins/newrelic/README.md @@ -15,8 +15,7 @@ proxy: '/newrelic/apm/api': target: https://api.newrelic.com/v2 headers: - X-Api-Key: - $env: NEW_RELIC_REST_API_KEY + X-Api-Key: ${NEW_RELIC_REST_API_KEY} ``` In your production deployment of Backstage, you would also need to ensure that diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index b62fd82d20..82952c7452 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 9239eabd9b..c62c618ff4 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-org +## 0.3.12 + +### Patch Changes + +- 97d53f686: Optimize data fetched for the `OwnershipCard`. +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/core-api@0.2.16 + - @backstage/catalog-model@0.7.6 + ## 0.3.11 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index d8de1469ee..0a9917cb7d 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.3.11", + "version": "0.3.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", - "@backstage/core-api": "^0.2.14", + "@backstage/catalog-model": "^0.7.6", + "@backstage/core": "^0.7.5", + "@backstage/core-api": "^0.2.16", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -35,9 +35,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 965a719020..9d8023b9fe 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -74,7 +74,7 @@ proxy: Then start the backend passing the token as an environment variable: ```bash -$ PAGERDUTY_TOKEN='Token token=' yarn start +$ PAGERDUTY_TOKEN='' yarn start ``` This will proxy the request by adding `Authorization` header with the provided token. diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index df1078eedd..44e8cb5b7f 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 73d343a205..a8cfcc894a 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.4", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/rollbar-backend/README.md b/plugins/rollbar-backend/README.md index 6cf3b55948..2dcf6495dd 100644 --- a/plugins/rollbar-backend/README.md +++ b/plugins/rollbar-backend/README.md @@ -8,8 +8,7 @@ The following values are read from the configuration file. ```yaml rollbar: - accountToken: - $env: ROLLBAR_ACCOUNT_TOKEN + accountToken: ${ROLLBAR_ACCOUNT_TOKEN} ``` _NOTE: The `ROLLBAR_ACCOUNT_TOKEN` environment variable must be set to a read diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index 6337d45ffe..993b93d3a0 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -45,8 +45,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( rollbar: organization: organization-name # used by rollbar-backend - accountToken: - $env: ROLLBAR_ACCOUNT_TOKEN + accountToken: ${ROLLBAR_ACCOUNT_TOKEN} ``` 6. Annotate entities with the rollbar project slug diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 1421ee5051..6d0e49b1ae 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index a54ec4430d..2c45bdc611 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,77 @@ # @backstage/plugin-scaffolder-backend +## 0.9.6 + +### Patch Changes + +- d8ffec739: Add built-in publish action for creating GitHub pull requests. +- 7abec4dbc: Fix for the `file://` protocol check in the `FilePreparer` being too strict, breaking Windows. +- d840d30bc: Bitbucket server needs username to be set as well as the token or appPassword for the publishing process to work. + + ```yaml + integrations: + bitbucket: + - host: bitbucket.mycompany.com + apiBaseUrl: https://bitbucket.mycompany.com/rest/api/1.0 + token: token + username: username + ``` + +- b25846562: Enable the JSON parsing of the response from templated variables in the `v2beta1` syntax. Previously if template parameters json strings they were left as strings, they are now parsed as JSON objects. + + Before: + + ```yaml + - id: test + name: test-action + action: custom:run + input: + input: '{"hello":"ben"}' + ``` + + Now: + + ```yaml + - id: test + name: test-action + action: custom:run + input: + input: + hello: ben + ``` + + Also added the `parseRepoUrl` and `json` helpers to the parameters syntax. You can now use these helpers to parse work with some `json` or `repoUrl` strings in templates. + + ```yaml + - id: test + name: test-action + action: cookiecutter:fetch + input: + destination: '{{ parseRepoUrl parameters.repoUrl }}' + ``` + + Will produce a parsed version of the `repoUrl` of type `{ repo: string, owner: string, host: string }` that you can use in your actions. Specifically `cookiecutter` with `{{ cookiecutter.destination.owner }}` like the `plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml` example. + +- a376e3ee8: Adds a collaborator field to GitHub publish action for multiple users and access levels +- 423a514c3: Fix execution of the GitHub Pull Request publish action on Windows. +- 0b7fd7a9d: Fix bug in pull request sample template. +- Updated dependencies [bb5055aee] +- Updated dependencies [5d0740563] +- Updated dependencies [442f34b87] + - @backstage/catalog-model@0.7.7 + - @backstage/catalog-client@0.3.10 + +## 0.9.5 + +### Patch Changes + +- 802b41b65: Allow custom directory to be specified for GitHub publish action +- Updated dependencies [97b60de98] +- Updated dependencies [98dd5da71] +- Updated dependencies [b779b5fee] + - @backstage/catalog-model@0.7.6 + - @backstage/backend-common@0.6.2 + ## 0.9.4 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 69aced77b5..2c65d91604 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.4", + "version": "0.9.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", + "@backstage/backend-common": "^0.6.2", + "@backstage/catalog-client": "^0.3.10", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", @@ -55,17 +55,20 @@ "handlebars": "^4.7.6", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "jest-when": "^3.1.0", "jsonschema": "^1.2.6", "knex": "^0.95.1", + "lodash": "^4.17.21", "luxon": "^1.26.0", "morgan": "^1.10.0", + "octokit-plugin-create-pull-request": "^3.9.3", "uuid": "^8.2.0", "winston": "^3.2.1", "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.8", + "@backstage/test-utils": "^0.1.10", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 91f887ed0a..82fde335c5 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -19,6 +19,7 @@ spec: - component_id - use_typescript - description + - collaborators properties: component_id: title: Name @@ -39,4 +40,28 @@ spec: type: boolean description: Use Github Actions default: true - + collaborators: + title: Collaborators + description: Provide users with permissions + type: array + ui:options: + orderable: false + items: + type: object + required: + - username + - access + properties: + access: + type: string + description: The type of access for the user + default: pull + enum: + - push + - pull + - admin + - maintain + - triage + username: + type: string + description: The username or group diff --git a/plugins/scaffolder-backend/sample-templates/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml index b0e16f9dea..76c76bc97a 100644 --- a/plugins/scaffolder-backend/sample-templates/local-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/local-templates.yaml @@ -10,3 +10,4 @@ spec: - ./react-ssr-template/template.yaml - ./springboot-grpc-template/template.yaml - ./v1beta2-demo/template.yaml + - ./pull-request/template.yaml diff --git a/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml b/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml new file mode 100644 index 0000000000..b6de1c29ca --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml @@ -0,0 +1,77 @@ +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: pull-request + title: Pull Request Action template + description: scaffolder v1beta2 template demo publishing to PR on existing git repository +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + description: + title: Description + type: string + description: Description of the component + targetPath: + title: Target Path in repo + type: string + description: Name of the directory to create in the repository + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:cookiecutter + input: + url: ./template + values: + name: '{{parameters.name}}' + + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + + - id: publish + name: Publish + action: publish:github:pull-request + input: + repoUrl: '{{ parameters.repoUrl }}' + title: 'Create new project: {{parameters.name}}' + branchName: 'create-{{parameters.name}}' + description: | + # New project: {{parameters.name}} + + {{#if parameters.description}} + {{parameters.description}} + {{/if}} + targetPath: '{{#if parameters.targetPath}}{{parameters.targetPath}}{{else}}{{parameters.name}}{{/if}}' + + output: + links: + - url: '{{steps.publish.output.remoteUrl}}' + text: 'Go to PR' 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 new file mode 100644 index 0000000000..dd1e0ebd09 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{cookiecutter.name | jsonify}} +spec: + type: website + lifecycle: experimental + owner: guest diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml index e90b43b7f3..1a01f7998c 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml @@ -50,6 +50,7 @@ spec: values: name: '{{ parameters.name }}' owner: '{{ parameters.owner }}' + destination: '{{ parseRepoUrl parameters.repoUrl }}' - id: fetch-docs name: Fetch Docs diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml index 875664d2a8..6519d68402 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml @@ -2,6 +2,7 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: {{cookiecutter.name | jsonify}} + github.com/project-slug: {{cookiecutter.destination.owner + "/" + cookiecutter.destination.repo}} spec: type: website lifecycle: experimental diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 0829a101f2..2d4b8fb4c5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -23,6 +23,7 @@ import { createPublishAzureAction, createPublishBitbucketAction, createPublishGithubAction, + createPublishGithubPullRequestAction, createPublishGitlabAction, } from './publish'; import Docker from 'dockerode'; @@ -57,6 +58,9 @@ export const createBuiltinActions = (options: { createPublishGithubAction({ integrations, }), + createPublishGithubPullRequestAction({ + integrations, + }), createPublishGitlabAction({ integrations, }), 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 0141de3499..e1a064b3e6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -19,7 +19,7 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; import { initRepoAndPush } from '../../../stages/publish/helpers'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; -import { parseRepoUrl } from './util'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; export function createPublishAzureAction(options: { @@ -30,6 +30,7 @@ export function createPublishAzureAction(options: { return createTemplateAction<{ repoUrl: string; description?: string; + sourcePath?: string; }>({ id: 'publish:azure', description: @@ -47,6 +48,11 @@ export function createPublishAzureAction(options: { title: 'Repository Description', type: 'string', }, + 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.', + type: 'string', + }, }, }, output: { @@ -112,7 +118,7 @@ export function createPublishAzureAction(options: { const repoContentsUrl = remoteUrl; await initRepoAndPush({ - dir: ctx.workspacePath, + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, auth: { username: 'notempty', 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 a7370356cc..13f639d745 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -20,7 +20,7 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { initRepoAndPush } from '../../../stages/publish/helpers'; -import { parseRepoUrl } from './util'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; import fetch from 'cross-fetch'; import { createTemplateAction } from '../../createTemplateAction'; @@ -165,6 +165,7 @@ export function createPublishBitbucketAction(options: { repoUrl: string; description: string; repoVisibility: 'private' | 'public'; + sourcePath?: string; }>({ id: 'publish:bitbucket', description: @@ -187,6 +188,11 @@ export function createPublishBitbucketAction(options: { 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.', + type: 'string', + }, }, }, output: { @@ -233,7 +239,7 @@ export function createPublishBitbucketAction(options: { }); await initRepoAndPush({ - dir: ctx.workspacePath, + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, auth: { username: integrationConfig.config.username diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 9880dda621..dc2f602778 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -22,6 +22,7 @@ import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { PassThrough } from 'stream'; import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { when } from 'jest-when'; describe('publish:github', () => { const integrations = ScmIntegrations.fromConfig( @@ -232,6 +233,114 @@ describe('publish:github', () => { }); }); + it('should add multiple collaborators when provided', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + collaborators: [ + { + access: 'pull', + username: 'robot-1', + }, + { + access: 'push', + username: 'robot-2', + }, + ], + }, + }); + + const commonProperties = { + org: 'owner', + owner: 'owner', + repo: 'repo', + }; + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[1], + ).toEqual([ + { + ...commonProperties, + team_slug: 'robot-1', + permission: 'pull', + }, + ]); + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[2], + ).toEqual([ + { + ...commonProperties, + team_slug: 'robot-2', + permission: 'push', + }, + ]); + }); + + it('should ignore failures when adding multiple collaborators', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + when(mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg) + .calledWith({ + org: 'owner', + owner: 'owner', + repo: 'repo', + team_slug: 'robot-1', + permission: 'pull', + }) + .mockRejectedValueOnce(new Error('Something bad happened') as never); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + collaborators: [ + { + access: 'pull', + username: 'robot-1', + }, + { + access: 'push', + username: 'robot-2', + }, + ], + }, + }); + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[2], + ).toEqual([ + { + org: 'owner', + owner: 'owner', + repo: 'repo', + team_slug: 'robot-2', + permission: 'push', + }, + ]); + }); + it('should call output with the remoteUrl and the repoContentsUrl', async () => { mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'User' }, 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 7ade6ffd19..e58f2c68aa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { InputError } from '@backstage/errors'; import { GithubCredentialsProvider, @@ -21,9 +20,12 @@ import { } from '@backstage/integration'; import { Octokit } from '@octokit/rest'; import { initRepoAndPush } from '../../../stages/publish/helpers'; -import { parseRepoUrl } from './util'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; +type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage'; +type Collaborator = { access: Permission; username: string }; + export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; }) { @@ -40,7 +42,9 @@ export function createPublishGithubAction(options: { repoUrl: string; description?: string; access?: string; + sourcePath?: string; repoVisibility: 'private' | 'internal' | 'public'; + collaborators: Collaborator[]; }>({ id: 'publish:github', description: @@ -67,6 +71,31 @@ export function createPublishGithubAction(options: { 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.', + type: 'string', + }, + collaborators: { + title: 'Collaborators', + description: 'Provide users with permissions', + type: 'array', + items: { + type: 'object', + required: ['username', 'access'], + properties: { + access: { + type: 'string', + description: 'The type of access for the user', + enum: ['push', 'pull', 'admin', 'maintain', 'triage'], + }, + username: { + type: 'string', + description: 'The username or group', + }, + }, + }, + }, }, }, output: { @@ -89,6 +118,7 @@ export function createPublishGithubAction(options: { description, access, repoVisibility = 'private', + collaborators, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl); @@ -160,11 +190,32 @@ export function createPublishGithubAction(options: { }); } + if (collaborators) { + for (const { + access: permission, + username: team_slug, + } of collaborators) { + try { + await client.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug, + owner, + repo, + permission, + }); + } catch (e) { + ctx.logger.warn( + `Skipping ${permission} access for ${team_slug}, ${e.message}`, + ); + } + } + } + const remoteUrl = data.clone_url; const repoContentsUrl = `${data.html_url}/blob/master`; await initRepoAndPush({ - dir: ctx.workspacePath, + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, auth: { username: 'x-access-token', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts new file mode 100644 index 0000000000..3b17f46bb3 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts @@ -0,0 +1,239 @@ +/* + * 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 mockFs from 'mock-fs'; +import { Writable } from 'stream'; +import os from 'os'; +import { resolve as resolvePath } from 'path'; +import { + PullRequestCreator, + GithubPullRequestActionInput, + createPublishGithubPullRequestAction, + ClientFactoryInput, +} from './githubPullRequest'; +import { ActionContext, TemplateAction } from '../../types'; +import { getRootLogger } from '@backstage/backend-common'; + +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; + +const root = os.platform() === 'win32' ? 'C:\\root' : '/root'; +const workspacePath = resolvePath(root, 'my-workspace'); + +describe('createPublishGithubPullRequestAction', () => { + let instance: TemplateAction; + let fakeClient: PullRequestCreator; + + let clientFactory: (input: ClientFactoryInput) => Promise; + + beforeEach(() => { + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); + fakeClient = { + createPullRequest: jest.fn(async (_: any) => { + return { + url: 'https://api.github.com/myorg/myrepo/pull/123', + headers: {}, + status: 201, + data: { + html_url: 'https://github.com/myorg/myrepo/pull/123', + }, + }; + }), + }; + clientFactory = jest.fn(async () => fakeClient); + + instance = createPublishGithubPullRequestAction({ + integrations, + clientFactory, + }); + }); + + describe('with no sourcePath', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + }; + + mockFs({ + [workspacePath]: { 'file.txt': 'Hello there!' }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + }); + it('creates a pull request', async () => { + await instance.handler(ctx); + + expect(fakeClient.createPullRequest).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + head: 'new-app', + body: 'This PR is really good', + changes: [ + { + commit: 'Create my new app', + files: { + 'file.txt': 'Hello there!', + }, + }, + ], + }); + }); + + it('creates outputs for the url', async () => { + await instance.handler(ctx); + + expect(ctx.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/myorg/myrepo/pull/123', + ); + }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + }); + + describe('with sourcePath', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + sourcePath: 'source', + }; + + mockFs({ + [workspacePath]: { + source: { 'foo.txt': 'Hello there!' }, + irrelevant: { 'bar.txt': 'Nothing to see here' }, + }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('creates a pull request with only relevant files', async () => { + await instance.handler(ctx); + + expect(fakeClient.createPullRequest).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + head: 'new-app', + body: 'This PR is really good', + changes: [ + { + commit: 'Create my new app', + files: { + 'foo.txt': 'Hello there!', + }, + }, + ], + }); + }); + }); + + describe('with repoUrl', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + repoUrl: 'github.com?owner=myorg&repo=myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + }; + + mockFs({ + [workspacePath]: { 'file.txt': 'Hello there!' }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + }); + it('creates a pull request', async () => { + await instance.handler(ctx); + + expect(fakeClient.createPullRequest).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + head: 'new-app', + body: 'This PR is really good', + changes: [ + { + commit: 'Create my new app', + files: { + 'file.txt': 'Hello there!', + }, + }, + ], + }); + }); + + it('creates outputs for the url', async () => { + await instance.handler(ctx); + + expect(ctx.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/myorg/myrepo/pull/123', + ); + }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts new file mode 100644 index 0000000000..e2e4f17efb --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -0,0 +1,247 @@ +/* + * 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 { readFile } from 'fs-extra'; +import path from 'path'; +import { parseRepoUrl } from './util'; + +import { + GithubCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { zipObject } from 'lodash'; +import { createTemplateAction } from '../../createTemplateAction'; +import { Octokit } from '@octokit/rest'; +import { InputError, CustomErrorBase } from '@backstage/errors'; +import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import globby from 'globby'; + +class GithubResponseError extends CustomErrorBase {} + +type CreatePullRequestResponse = { + data: { html_url: string }; +}; + +export interface PullRequestCreator { + createPullRequest( + options: createPullRequest.Options, + ): Promise; +} + +export type PullRequestCreatorConstructor = ( + octokit: Octokit, +) => PullRequestCreator; + +export type GithubPullRequestActionInput = { + title: string; + branchName: string; + description: string; + owner?: string; + repo?: string; + repoUrl?: string; + host?: string; + targetPath?: string; + sourcePath?: string; +}; + +export type ClientFactoryInput = { + integrations: ScmIntegrationRegistry; + host: string; + owner: string; + repo: string; +}; + +export const defaultClientFactory = async ({ + integrations, + owner, + repo, + host = 'github.com', +}: ClientFactoryInput): Promise => { + const integrationConfig = integrations.github.byHost(host)?.config; + + if (!integrationConfig) { + throw new InputError(`No integration for host ${host}`); + } + + const credentialsProvider = GithubCredentialsProvider.create( + integrationConfig, + ); + + if (!credentialsProvider) { + throw new InputError( + `No matching credentials for host ${host}, please check your integrations config`, + ); + } + + const { token } = await credentialsProvider.getCredentials({ + url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + }); + + if (!token) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repo}`, + ); + } + + const OctokitPR = Octokit.plugin(createPullRequest); + + return new OctokitPR({ + auth: token, + baseUrl: integrationConfig.apiBaseUrl, + }); +}; + +interface CreateGithubPullRequestActionOptions { + integrations: ScmIntegrationRegistry; + clientFactory?: (input: ClientFactoryInput) => Promise; +} + +export const createPublishGithubPullRequestAction = ({ + integrations, + clientFactory = defaultClientFactory, +}: CreateGithubPullRequestActionOptions) => { + return createTemplateAction({ + id: 'publish:github:pull-request', + schema: { + input: { + required: ['owner', 'repo', 'title', 'description', 'branchName'], + type: 'object', + properties: { + owner: { + type: 'string', + title: 'Repository owner', + description: 'The owner of the target repository', + }, + repo: { + type: 'string', + title: 'Repository', + description: 'The github repository to create the file in', + }, + branchName: { + type: 'string', + title: 'Branch Name', + description: 'The name for the branch', + }, + title: { + type: 'string', + title: 'Pull Request Name', + description: 'The name for the pull request', + }, + description: { + type: 'string', + title: 'Pull Request Description', + description: 'The description of the pull request', + }, + sourcePath: { + type: 'string', + title: 'Working Subdirectory', + description: + 'Subdirectory of working directory to copy changes from', + }, + targetPath: { + type: 'string', + title: 'Repository Subdirectory', + description: 'Subdirectory of repository to apply changes to', + }, + }, + }, + output: { + required: ['remoteUrl'], + type: 'object', + properties: { + remoteUrl: { + type: 'string', + title: 'Pull Request URL', + description: 'Link to the pull request in Github', + }, + }, + }, + }, + async handler(ctx) { + let { owner, repo } = ctx.input; + let host = 'github.com'; + const { + repoUrl, + branchName, + title, + description, + targetPath, + sourcePath, + } = ctx.input; + + if (repoUrl) { + const parsed = parseRepoUrl(repoUrl); + host = parsed.host; + owner = parsed.owner; + repo = parsed.repo; + } + + if (!host || !owner || !repo) { + throw new InputError( + 'must provide either valid repo URL or owner and repo as parameters', + ); + } + + const client = await clientFactory({ integrations, host, owner, repo }); + const fileRoot = sourcePath + ? path.resolve(ctx.workspacePath, sourcePath) + : ctx.workspacePath; + + const localFilePaths = await globby(['./**', './**/.*', '!.git'], { + cwd: fileRoot, + gitignore: true, + dot: true, + }); + + const fileContents = await Promise.all( + localFilePaths.map(p => readFile(path.resolve(fileRoot, p))), + ); + + const repoFilePaths = localFilePaths.map(repoFilePath => { + return targetPath ? `${targetPath}/${repoFilePath}` : repoFilePath; + }); + + const changes = [ + { + files: zipObject( + repoFilePaths, + fileContents.map(buf => buf.toString()), + ), + commit: title, + }, + ]; + + try { + const response = await client.createPullRequest({ + owner, + repo, + title, + changes, + body: description, + head: branchName, + }); + + if (!response) { + throw new GithubResponseError('null response from Github'); + } + + ctx.output('remoteUrl', response.data.html_url); + } catch (e) { + throw new GithubResponseError('Pull request creation failed', e); + } + }, + }); +}; 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 12fc90cc94..37074d6f57 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -18,7 +18,7 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from '../../../stages/publish/helpers'; -import { parseRepoUrl } from './util'; +import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; export function createPublishGitlabAction(options: { @@ -29,6 +29,7 @@ export function createPublishGitlabAction(options: { return createTemplateAction<{ repoUrl: string; repoVisibility: 'private' | 'internal' | 'public'; + sourcePath?: string; }>({ id: 'publish:gitlab', description: @@ -47,6 +48,11 @@ export function createPublishGitlabAction(options: { 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.', + type: 'string', + }, }, }, output: { @@ -106,7 +112,7 @@ export function createPublishGitlabAction(options: { const repoContentsUrl = `${remoteUrl}/-/blob/master`; await initRepoAndPush({ - dir: ctx.workspacePath, + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl: http_url_to_repo as string, auth: { username: 'oauth2', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 419922704d..537a2b882d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -15,6 +15,7 @@ */ export { createPublishGithubAction } from './github'; +export { createPublishGithubPullRequestAction } from './githubPullRequest'; export { createPublishAzureAction } from './azure'; export { createPublishGitlabAction } from './gitlab'; export { createPublishBitbucketAction } from './bitbucket'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts index 89bbf24574..acf1351004 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -15,8 +15,29 @@ */ import { InputError } from '@backstage/errors'; +import { join as joinPath, normalize as normalizePath } from 'path'; -export const parseRepoUrl = (repoUrl: string) => { +export const getRepoSourceDirectory = ( + workspacePath: string, + sourcePath: string | undefined, +) => { + if (sourcePath) { + const safeSuffix = normalizePath(sourcePath).replace( + /^(\.\.(\/|\\|$))+/, + '', + ); + return joinPath(workspacePath, safeSuffix); + } + return workspacePath; +}; +export type RepoSpec = { + repo: string; + host: string; + owner: string; + organization?: string; +}; + +export const parseRepoUrl = (repoUrl: string): RepoSpec => { let parsed; try { parsed = new URL(`https://${repoUrl}`); @@ -40,7 +61,7 @@ export const parseRepoUrl = (repoUrl: string) => { ); } - const organization = parsed.searchParams.get('organization'); + const organization = parsed.searchParams.get('organization') ?? undefined; return { host, owner, repo, organization }; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index f6e5600a83..24a3ef4112 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -31,7 +31,7 @@ describe('File preparer', () => { const targetPath = path.resolve(workspacePath, 'template'); await preparer.prepare({ - url: `file:///${root}path/to/template`, + url: `file://${root}path/to/template`, logger, workspacePath, }); @@ -46,12 +46,34 @@ describe('File preparer', () => { await expect( preparer.prepare({ - url: 'file://not/full/path', + url: 'http://not/file/path', logger, workspacePath, }), ).rejects.toThrow( - "Wrong location protocol, should be 'file', file://not/full/path", + "Wrong location protocol, should be 'file', http://not/file/path", ); + + if (os.platform() === 'win32') { + // eslint-disable-next-line jest/no-conditional-expect + await expect( + preparer.prepare({ + url: 'file:///unix/file/path', + logger, + workspacePath, + }), + ).rejects.toThrow('File URL path must be absolute'); + } else { + // eslint-disable-next-line jest/no-conditional-expect + await expect( + preparer.prepare({ + url: 'file://not/full/path', + logger, + workspacePath, + }), + ).rejects.toThrow( + `File URL host must be "localhost" or empty on ${os.platform()}`, + ); + } }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index 9eb380be13..b02c496ff5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -21,15 +21,15 @@ import { PreparerBase, PreparerOptions } from './types'; export class FilePreparer implements PreparerBase { async prepare({ url, workspacePath }: PreparerOptions) { - if (!url.startsWith('file:///')) { + if (!url.startsWith('file://')) { throw new InputError(`Wrong location protocol, should be 'file', ${url}`); } + const templatePath = fileURLToPath(url); + const targetDir = path.join(workspacePath, 'template'); await fs.ensureDir(targetDir); - const templatePath = fileURLToPath(url); - await fs.copy(templatePath, targetDir, { recursive: true, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 4cf5e83ba3..33fa51af1b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -97,6 +97,19 @@ describe('Bitbucket Publisher', () => { }); describe('publish: createRemoteInBitbucketServer', () => { + it('should throw an error if no username present', async () => { + await expect( + BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }, + { repoVisibility: 'private' }, + ), + ).rejects.toThrow( + 'Bitbucket server requires the username to be set in your config', + ); + }); it('should create repo in bitbucket server', async () => { server.use( rest.post( @@ -128,6 +141,7 @@ describe('Bitbucket Publisher', () => { const publisher = await BitbucketPublisher.fromConfig( { host: 'bitbucket.mycompany.com', + username: 'foo', token: 'fake-token', }, { repoVisibility: 'private' }, @@ -151,7 +165,7 @@ describe('Bitbucket Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: resultPath, remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', - auth: { username: 'x-token-auth', password: 'fake-token' }, + auth: { username: 'foo', password: 'fake-token' }, logger: logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index 102a3a7d02..aef0625c79 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -32,6 +32,11 @@ export class BitbucketPublisher implements PublisherBase { config: BitbucketIntegrationConfig, { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, ) { + if (config.host !== 'bitbucket.org' && !config.username) + throw new Error( + 'Bitbucket server requires the username to be set in your config', + ); + return new BitbucketPublisher({ host: config.host, token: config.token, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts index 87bcd1d003..95eb4891dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts @@ -76,7 +76,9 @@ describe('Publishers', () => { const publishers = await Publishers.fromConfig( new ConfigReader({ integrations: { - bitbucket: [{ host: 'bitbucket.com', token: 'blob' }], + bitbucket: [ + { host: 'bitbucket.com', username: 'foo', token: 'blob' }, + ], }, }), { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index b598b91036..edef02064f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -24,6 +24,7 @@ import { ConfigReader, JsonObject } from '@backstage/config'; import { StorageTaskBroker } from './StorageTaskBroker'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { createTemplateAction, TemplateActionRegistry } from '../actions'; +import { RepoSpec } from '../actions/builtin/publish/util'; async function createStore(): Promise { const manager = SingleConnectionDatabaseManager.fromConfig( @@ -41,20 +42,24 @@ async function createStore(): Promise { describe('TaskWorker', () => { let storage: DatabaseTaskStore; + let actionRegistry = new TemplateActionRegistry(); beforeAll(async () => { storage = await createStore(); }); - const logger = getVoidLogger(); - const actionRegistry = new TemplateActionRegistry(); - actionRegistry.register({ - id: 'test-action', - handler: async ctx => { - ctx.output('testOutput', 'winning'); - }, + beforeEach(() => { + actionRegistry = new TemplateActionRegistry(); + actionRegistry.register({ + id: 'test-action', + handler: async ctx => { + ctx.output('testOutput', 'winning'); + }, + }); }); + const logger = getVoidLogger(); + it('should fail when action does not exist', async () => { const broker = new StorageTaskBroker(storage, logger); const taskWorker = new TaskWorker({ @@ -166,4 +171,194 @@ describe('TaskWorker', () => { const event = events.find(e => e.type === 'completion'); expect((event?.body?.output as JsonObject).result).toBe('winning'); }); + + it('should parse strings as objects if possible', async () => { + const inputAction = createTemplateAction<{ + address: { line1: string }; + list: string[]; + address2: string; + }>({ + id: 'test-input', + schema: { + input: { + type: 'object', + required: ['address'], + properties: { + address: { + title: 'address', + description: 'Enter name', + type: 'object', + properties: { + line1: { + type: 'string', + }, + }, + }, + address2: { + type: 'string', + }, + list: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + async handler(ctx) { + if (ctx.input.list.length !== 1) { + throw new Error( + `expected list to have length "1" got ${ctx.input.list.length}`, + ); + } + if (ctx.input.address.line1 !== 'line 1') { + throw new Error( + `expected address.line1 to be "line 1" got ${ctx.input.address.line1}`, + ); + } + + if (ctx.input.address2 !== '{"not valid"}') { + throw new Error( + `expected address2 to be "{"not valid"}" got ${ctx.input.address2}`, + ); + } + ctx.output('address', ctx.input.address.line1); + }, + }); + actionRegistry.register(inputAction); + + const broker = new StorageTaskBroker(storage, logger); + const taskWorker = new TaskWorker({ + logger, + workingDirectory: os.tmpdir(), + actionRegistry, + taskBroker: broker, + }); + + const { taskId } = await broker.dispatch({ + steps: [ + { + id: 'test-input', + name: 'test-input', + action: 'test-input', + input: { + address: JSON.stringify({ line1: 'line 1' }), + list: JSON.stringify(['hey!']), + address2: '{"not valid"}', + }, + }, + ], + output: { + result: '{{ steps.test-input.output.address }}', + }, + values: {}, + }); + + const task = await broker.claim(); + await taskWorker.runOneTask(task); + + const { events } = await storage.listEvents({ taskId }); + const event = events.find(e => e.type === 'completion'); + + expect((event?.body?.output as JsonObject).result).toBe('line 1'); + }); + + // TODO(blam): Can delete this test when we make the helpers a public API + it('should provide a repoUrlParse helper for the templates', async () => { + const inputAction = createTemplateAction<{ + destination: RepoSpec; + }>({ + id: 'test-input', + schema: { + input: { + type: 'object', + required: ['destination'], + properties: { + destination: { + title: 'destination', + type: 'object', + properties: { + repo: { + type: 'string', + }, + host: { + type: 'string', + }, + owner: { + type: 'string', + }, + organization: { + type: 'string', + }, + }, + }, + }, + }, + }, + async handler(ctx) { + ctx.output('host', ctx.input.destination.host); + ctx.output('repo', ctx.input.destination.repo); + ctx.output('owner', ctx.input.destination.owner); + + if (ctx.input.destination.host !== 'github.com') { + throw new Error( + `expected host to be "github.com" got ${ctx.input.destination.host}`, + ); + } + + if (ctx.input.destination.repo !== 'repo') { + throw new Error( + `expected repo to be "repo" got ${ctx.input.destination.repo}`, + ); + } + + if (ctx.input.destination.owner !== 'owner') { + throw new Error( + `expected repo to be "owner" got ${ctx.input.destination.owner}`, + ); + } + }, + }); + actionRegistry.register(inputAction); + + const broker = new StorageTaskBroker(storage, logger); + const taskWorker = new TaskWorker({ + logger, + workingDirectory: os.tmpdir(), + actionRegistry, + taskBroker: broker, + }); + + const { taskId } = await broker.dispatch({ + steps: [ + { + id: 'test-input', + name: 'test-input', + action: 'test-input', + input: { + destination: '{{ parseRepoUrl parameters.repoUrl }}', + }, + }, + ], + output: { + host: '{{ steps.test-input.output.host }}', + repo: '{{ steps.test-input.output.repo }}', + owner: '{{ steps.test-input.output.owner }}', + }, + values: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + + const task = await broker.claim(); + await taskWorker.runOneTask(task); + + const { events } = await storage.listEvents({ taskId }); + const event = events.find(e => e.type === 'completion'); + + expect((event?.body?.output as JsonObject).host).toBe('github.com'); + expect((event?.body?.output as JsonObject).repo).toBe('repo'); + expect((event?.body?.output as JsonObject).owner).toBe('owner'); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 3a846f79a5..14752e1988 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -23,8 +23,9 @@ import { TaskBroker, Task } from './types'; import fs from 'fs-extra'; import path from 'path'; import { TemplateActionRegistry } from '../actions/TemplateActionRegistry'; -import * as handlebars from 'handlebars'; +import * as Handlebars from 'handlebars'; import { InputError } from '@backstage/errors'; +import { parseRepoUrl } from '../actions/builtin/publish/util'; type Options = { logger: Logger; @@ -34,7 +35,20 @@ type Options = { }; export class TaskWorker { - constructor(private readonly options: Options) {} + private readonly handlebars: typeof Handlebars; + + constructor(private readonly options: Options) { + this.handlebars = Handlebars.create(); + + // TODO(blam): this should be a public facing API but it's a little + // scary right now, so we're going to lock it off like the component API is + // in the frontend until we can work out a nice way to do it. + this.handlebars.registerHelper('parseRepoUrl', repoUrl => { + return JSON.stringify(parseRepoUrl(repoUrl)); + }); + + this.handlebars.registerHelper('json', obj => JSON.stringify(obj)); + } start() { (async () => { @@ -102,13 +116,29 @@ export class TaskWorker { step.input && JSON.parse(JSON.stringify(step.input), (_key, value) => { if (typeof value === 'string') { - return handlebars.compile(value, { + const templated = this.handlebars.compile(value, { noEscape: true, strict: true, data: false, preventIndent: true, })(templateCtx); + + // If it smells like a JSON object then give it a parse as an object and if it fails return the string + if ( + (templated.startsWith('{') && templated.endsWith('}')) || + (templated.startsWith('[') && templated.endsWith(']')) + ) { + try { + // Don't recursively JSON parse the values of this string. + // Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else + return JSON.parse(templated); + } catch { + return templated; + } + } + return templated; } + return value; }); @@ -171,7 +201,7 @@ export class TaskWorker { JSON.stringify(task.spec.output), (_key, value) => { if (typeof value === 'string') { - return handlebars.compile(value, { + return this.handlebars.compile(value, { noEscape: true, strict: true, data: false, diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 4823c19816..b7a8cbb89c 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,63 @@ # @backstage/plugin-scaffolder +## 0.9.0 + +### Minor Changes + +- a360f9478: Expose the catalog-import route as an external route from the scaffolder. + + This will make it possible to hide the "Register Existing Component" button + when you for example are running backstage with `catalog.readonly=true`. + + As a consequence of this change you need add a new binding to your createApp call to + keep the button visible. However, if you instead want to hide the button you can safely + ignore the following example. + + To bind the external route from the catalog-import plugin to the scaffolder template + index page, make sure you have the appropriate imports and add the following + to the createApp call: + + ```typescript + import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; + + const app = createApp({ + // ... + bindRoutes({ bind }) { + // ... + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + }); + }, + }); + ``` + +### Patch Changes + +- Updated dependencies [bb5055aee] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [442f34b87] +- Updated dependencies [e27cb6c45] + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + - @backstage/catalog-client@0.3.10 + +## 0.8.2 + +### Patch Changes + +- 3f96a9d5a: Support auth by sending cookies in event stream request +- 98dd5da71: Add support for multiple links to post-scaffold task summary page +- Updated dependencies [1279a3325] +- Updated dependencies [4a4681b1b] +- Updated dependencies [97b60de98] +- Updated dependencies [b051e770c] +- Updated dependencies [98dd5da71] + - @backstage/core@0.7.4 + - @backstage/catalog-model@0.7.6 + ## 0.8.1 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 410e12a97d..10785a4db7 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.8.1", + "version": "0.9.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", + "@backstage/catalog-client": "^0.3.10", + "@backstage/catalog-model": "^0.7.7", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.4", @@ -60,9 +60,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index e266d06a91..6caa2a4446 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -196,7 +196,7 @@ export class ScaffolderClient implements ScaffolderApi { const url = `${baseUrl}/v2/tasks/${encodeURIComponent( taskId, )}/eventstream`; - const eventSource = new EventSource(url); + const eventSource = new EventSource(url, { withCredentials: true }); eventSource.addEventListener('log', (event: any) => { if (event.data) { try { diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 0353dff798..68bf588d01 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -26,6 +26,7 @@ import { Progress, SupportButton, useApi, + useRouteRef, WarningPanel, } from '@backstage/core'; import { useStarredEntities } from '@backstage/plugin-catalog-react'; @@ -39,6 +40,7 @@ import { ScaffolderFilter } from '../ScaffolderFilter'; import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter'; import SearchToolbar from '../SearchToolbar/SearchToolbar'; import { TemplateCard, TemplateCardProps } from '../TemplateCard'; +import { registerComponentRouteRef } from '../../routes'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -108,6 +110,8 @@ export const ScaffolderPageContents = () => { `${metadata.title}`.toLocaleUpperCase('en-US').includes(query) || metadata.tags?.join('').toLocaleUpperCase('en-US').indexOf(query) !== -1; + const registerComponentLink = useRouteRef(registerComponentRouteRef); + useEffect(() => { if (search.length === 0) { return setMatchingEntities(filteredEntities); @@ -132,14 +136,16 @@ export const ScaffolderPageContents = () => { /> - + {registerComponentLink && ( + + )} Create new software components using standard templates. Different templates create different kinds of components (services, websites, diff --git a/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx new file mode 100644 index 0000000000..8caf37e70d --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { IconLink } from './IconLink'; + +describe('IconLink', () => { + it('should render an icon link', () => { + const rendered = render( + + + , + ); + + expect(rendered.queryByText('I am Link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/TaskPage/IconLink.tsx b/plugins/scaffolder/src/components/TaskPage/IconLink.tsx new file mode 100644 index 0000000000..9bede214c6 --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/IconLink.tsx @@ -0,0 +1,58 @@ +/* + * 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 { IconComponent, Link } from '@backstage/core'; +import { Grid, LinkProps, makeStyles, Typography } from '@material-ui/core'; +import LanguageIcon from '@material-ui/icons/Language'; + +const useStyles = makeStyles({ + svgIcon: { + display: 'inline-block', + '& svg': { + display: 'inline-block', + fontSize: 'inherit', + verticalAlign: 'baseline', + }, + }, +}); + +export const IconLink = ( + props: { + href: string; + text?: string; + Icon?: IconComponent; + } & LinkProps, +) => { + const { href, text, Icon, ...linkProps } = props; + + const classes = useStyles(); + + return ( + + + + {Icon ? : } + + + + + {text || href} + + + + ); +}; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 96a857dbb5..cc1149ee61 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -22,28 +22,24 @@ import Step from '@material-ui/core/Step'; import StepLabel from '@material-ui/core/StepLabel'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; -import { generatePath, useParams } from 'react-router'; +import { useParams } from 'react-router'; import { useTaskEventStream } from '../hooks/useEventStream'; import LazyLog from 'react-lazylog/build/LazyLog'; -import { Link } from 'react-router-dom'; import { - Box, - Button, CircularProgress, Paper, StepButton, StepIconProps, } from '@material-ui/core'; -import { Status } from '../../types'; +import { Status, TaskOutput } from '../../types'; import { DateTime, Interval } from 'luxon'; import { useInterval } from 'react-use'; import Check from '@material-ui/icons/Check'; import Cancel from '@material-ui/icons/Cancel'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; -import { entityRoute } from '@backstage/plugin-catalog-react'; -import { parseEntityName } from '@backstage/catalog-model'; import classNames from 'classnames'; import { BackstageTheme } from '@backstage/theme'; +import { TaskPageLinks } from './TaskPageLinks'; // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -211,6 +207,9 @@ const TaskLogger = memo(({ log }: { log: string }) => { ); }); +const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean => + !!(entityRef || remoteUrl || links.length > 0); + export const TaskPage = () => { const [userSelectedStepId, setUserSelectedStepId] = useState< string | undefined @@ -261,8 +260,8 @@ export const TaskPage = () => { taskStream.loading === false && !taskStream.task; - const entityRef = taskStream.output?.entityRef; - const remoteUrl = taskStream.output?.remoteUrl; + const { output } = taskStream; + return (
{ currentStepId={currentStepId} onUserStepChange={setUserSelectedStepId} /> - {(entityRef || remoteUrl) && ( - - {entityRef && ( - - )} - {remoteUrl && ( - - )} - + {output && hasLinks(output) && ( + )} diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx new file mode 100644 index 0000000000..cd51488242 --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 { TaskPageLinks } from './TaskPageLinks'; +import { renderInTestApp } from '@backstage/test-utils'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; + +describe('TaskPageLinks', () => { + beforeEach(() => {}); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders the entityRef link', async () => { + const output = { entityRef: 'Component:default/my-app' }; + const { findByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + }, + }, + ); + + const element = await findByText('Open in catalog'); + + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute( + 'href', + '/catalog/default/Component/my-app', + ); + }); + + it('renders the remoteUrl link', async () => { + const output = { remoteUrl: 'https://remote.url' }; + const { findByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + }, + }, + ); + + const element = await findByText('Repo'); + + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('href', 'https://remote.url'); + }); + + it('renders further links', async () => { + const output = { + links: [ + { url: 'https://first.url', title: 'Cool link 1' }, + { url: 'https://second.url', title: 'Cool link 2' }, + ], + }; + const { findByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + }, + }, + ); + + let element = await findByText('Cool link 1'); + + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('href', 'https://first.url'); + + element = await findByText('Cool link 2'); + + expect(element).toBeInTheDocument(); + expect(element).toHaveAttribute('href', 'https://second.url'); + }); +}); diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx new file mode 100644 index 0000000000..c0b55193c2 --- /dev/null +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx @@ -0,0 +1,69 @@ +/* + * 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 { parseEntityName } from '@backstage/catalog-model'; +import { IconComponent, IconKey, useApp, useRouteRef } from '@backstage/core'; +import { IconLink } from './IconLink'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { Box } from '@material-ui/core'; +import LanguageIcon from '@material-ui/icons/Language'; +import { TaskOutput } from '../../types'; + +type TaskPageLinksProps = { + output: TaskOutput; +}; + +export const TaskPageLinks = ({ output }: TaskPageLinksProps) => { + const { entityRef, remoteUrl } = output; + let { links = [] } = output; + const app = useApp(); + const entityRoute = useRouteRef(entityRouteRef); + + const iconResolver = (key: IconKey | undefined): IconComponent => + key ? app.getSystemIcon(key) ?? LanguageIcon : LanguageIcon; + + if (remoteUrl) { + links = [{ url: remoteUrl, title: 'Repo' }, ...links]; + } + + if (entityRef) { + const entityName = parseEntityName(entityRef); + const target = entityRoute(entityName); + links = [ + { + url: target, + title: 'Open in catalog', + icon: 'catalog', + }, + ...links, + ]; + } + + return ( + + {links.map(({ url, title, icon }, i) => ( + + ))} + + ); +}; diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index 210fd8cb9b..4d28fabd34 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -16,7 +16,7 @@ import { useImmerReducer } from 'use-immer'; import { useEffect } from 'react'; import { scaffolderApiRef, LogEvent } from '../../api'; -import { ScaffolderTask, Status } from '../../types'; +import { ScaffolderTask, Status, TaskOutput } from '../../types'; import { Subscription, useApi } from '@backstage/core'; type Step = { @@ -26,8 +26,6 @@ type Step = { startedAt?: string; }; -type TaskOutput = { entityRef?: string } & { [key in string]: string }; - export type TaskStream = { loading: boolean; error?: Error; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 1aa25d8e18..be47daec46 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -23,7 +23,7 @@ import { } from '@backstage/core'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; -import { rootRouteRef } from './routes'; +import { rootRouteRef, registerComponentRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ id: 'scaffolder', @@ -42,6 +42,9 @@ export const scaffolderPlugin = createPlugin({ routes: { root: rootRouteRef, }, + externalRoutes: { + registerComponent: registerComponentRouteRef, + }, }); export const ScaffolderPage = scaffolderPlugin.provide( diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 413e8f4194..3cca269b84 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRouteRef } from '@backstage/core'; +import { createExternalRouteRef, createRouteRef } from '@backstage/core'; + +export const registerComponentRouteRef = createExternalRouteRef({ + id: 'register-component', + optional: true, +}); export const rootRouteRef = createRouteRef({ title: 'Create new entity', diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 6a2c2eede4..9ba480a441 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -64,3 +64,18 @@ export type ListActionsResponse = Array<{ output?: JSONSchema; }; }>; + +type OutputLink = { + url: string; + title?: string; + icon?: string; +}; + +export type TaskOutput = { + entityRef?: string; + /** @deprecated use the `links` property to link out to relevant resources */ + remoteUrl?: string; + links?: OutputLink[]; +} & { + [key: string]: unknown; +}; diff --git a/plugins/search/package.json b/plugins/search/package.json index d0eab8f808..13019c7f32 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/search-common": "^0.1.1", @@ -44,9 +44,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 5d280de249..52cef9cbe6 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -70,9 +70,7 @@ proxy: target: https://sentry.io/api/ allowedMethods: ['GET'] headers: - Authorization: - # Content: 'Bearer ' - $env: SENTRY_TOKEN + Authorization: Bearer ${SENTRY_TOKEN} sentry: organization: diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 4b086c8e5e..dee05a8c2d 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index c5992c8e0a..0ed86737dd 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-sonarqube +## 0.1.16 + +### Patch Changes + +- db802fafb: Export isSonarQubeAvailable. +- Updated dependencies [bb5055aee] +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5d0740563] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [e27cb6c45] + - @backstage/catalog-model@0.7.7 + - @backstage/core@0.7.5 + ## 0.1.15 ### Patch Changes diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index 7afc9cc04a..7c745edf90 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -10,7 +10,6 @@ The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarclo ```bash # packages/app - yarn add @backstage/plugin-sonarqube ``` @@ -18,70 +17,76 @@ yarn add @backstage/plugin-sonarqube ```js // packages/app/src/plugins.ts - export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; ``` -3. Add the `SonarQubeCard` to the EntityPage: +3. Add the `EntitySonarQubeCard` to the EntityPage: -```jsx -// packages/app/src/components/catalog/EntityPage.tsx +```diff + // packages/app/src/components/catalog/EntityPage.tsx ++ import { EntitySonarQubeCard } from '@backstage/plugin-sonarqube'; -import { SonarQubeCard } from '@backstage/plugin-sonarqube'; + ... -const OverviewContent = ({ entity }: { entity: Entity }) => ( - - // ... - - - - // ... - -); + const overviewContent = ( + + + + ++ ++ ++ + + ); ``` 4. Add the proxy config: - Provide a method for your Backstage backend to get to your SonarQube API end point. + 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. **SonarCloud** ```yaml -# app-config.yaml - proxy: '/sonarqube': target: https://sonarcloud.io/api allowedMethods: ['GET'] headers: - Authorization: - # Content: 'Basic base64(":")' <-- note the trailing ':' - # Example: Basic bXktYXBpLWtleTo= - $env: SONARQUBE_AUTH_HEADER + Authorization: Basic ${SONARQUBE_AUTH} + # Content: 'base64(":")' <-- note the trailing ':' + # Example: bXktYXBpLWtleTo= ``` **SonarQube** ```yaml -# app-config.yaml - proxy: '/sonarqube': target: https://your.sonarqube.instance.com/api allowedMethods: ['GET'] headers: - Authorization: - # Content: 'Basic base64(":")' <-- note the trailing ':' - # Example: Basic bXktYXBpLWtleTo= - $env: SONARQUBE_AUTH_HEADER + Authorization: Basic ${SONARQUBE_AUTH} + # Environmental variable: SONARQUBE_AUTH + # Value: 'base64(":")' + # Encode the ":" string using base64 encoder. + # Note the trailing colon (:) at the end of the token. + # Example environmental config: SONARQUBE_AUTH=bXktYXBpLWtleTo= + # Fetch the sonar-auth-token from https://sonarcloud.io/account/security/ sonarQube: baseUrl: https://your.sonarqube.instance.com ``` -5. Get and provide `SONARQUBE_AUTH_HEADER` as env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) +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/) -6. Add the `sonarqube.org/project-key` annotation to your catalog-info.yaml file: +6. 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. ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 412d2fd905..d48f59febe 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.15", + "version": "0.1.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", + "@backstage/catalog-model": "^0.7.7", "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sonarqube/src/components/index.ts b/plugins/sonarqube/src/components/index.ts index 8f0786bb8e..56b9d05885 100644 --- a/plugins/sonarqube/src/components/index.ts +++ b/plugins/sonarqube/src/components/index.ts @@ -15,3 +15,4 @@ */ export * from './SonarQubeCard'; +export { isSonarQubeAvailable } from './useProjectKey'; diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts index dcff79d972..c0f488b00c 100644 --- a/plugins/sonarqube/src/components/useProjectKey.ts +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -18,6 +18,9 @@ import { Entity } from '@backstage/catalog-model'; export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key'; +export const isSonarQubeAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION]); + export const useProjectKey = (entity: Entity) => { return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? ''; }; diff --git a/plugins/splunk-on-call/README.md b/plugins/splunk-on-call/README.md index 78313d3329..5c1dda33fc 100644 --- a/plugins/splunk-on-call/README.md +++ b/plugins/splunk-on-call/README.md @@ -50,7 +50,7 @@ import { In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide a REST Endpoint. -To enable the REST Endpoint integration you can go on https://portal.victorops.com/ inside Integrations > 3rd Party Integrations > REST – Generic. +To enable the REST Endpoint integration you can go on https://portal.victorops.com/ inside Integrations > 3rd Party Integrations > REST – Generic. You can now copy the URL to notify: `/$routing_key` In `app-config.yaml`: @@ -69,10 +69,8 @@ proxy: '/splunk-on-call': target: https://api.victorops.com/api-public headers: - X-VO-Api-Id: - $env: SPLUNK_ON_CALL_API_ID - X-VO-Api-Key: - $env: SPLUNK_ON_CALL_API_KEY + X-VO-Api-Id: ${SPLUNK_ON_CALL_API_ID} + X-VO-Api-Key: ${SPLUNK_ON_CALL_API_KEY} ``` In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`. diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 6ef9fe22af..990d0acb27 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 52223a95b1..54cefe487f 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-tech-radar +## 0.3.9 + +### Patch Changes + +- 184b02bef: Add markdown support for tech radar entry description +- Updated dependencies [d0d1c2f7b] +- Updated dependencies [5cafcf452] +- Updated dependencies [86a95ba67] +- Updated dependencies [e27cb6c45] + - @backstage/core@0.7.5 + ## 0.3.8 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 5d8da62ca9..e927ed938a 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.3", + "@backstage/core": "^0.7.5", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@backstage/cli": "^0.6.8", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx index 20c489f630..3ac4f153be 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx @@ -19,6 +19,7 @@ import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import { Button, DialogActions, DialogContent } from '@material-ui/core'; import LinkIcon from '@material-ui/icons/Link'; +import { MarkdownContent } from '@backstage/core'; export type Props = { open: boolean; @@ -43,7 +44,9 @@ const RadarDescription = (props: Props): JSX.Element => { {title} - {description} + + + {url && (