Merge branch 'master' into timbonicus/keep-updated

This commit is contained in:
Tim Hansen
2021-03-08 08:06:16 -07:00
224 changed files with 4424 additions and 1595 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kafka': patch
---
Added lag column in the plugin main table
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Add parseLocationReference, stringifyLocationReference
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/catalog-client': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/techdocs-common': patch
---
Make use of parseLocationReference/stringifyLocationReference
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/integration': patch
---
Add `resolveEditUrl` to integrations to resolve a URL that can be used to edit
a file in the web interfaces of an SCM.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Fixes task failures caused by undefined step input
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/backend-common': patch
---
Bump to the latest version of the Knex library.
You will most likely want to bump your own `packages/backend/package.json` as well:
```diff
- "knex": "^0.21.18",
+ "knex": "^0.95.1",
```
Note that the recent versions of the Knex library have some changes that may affect your internal plugins' database migration files. Importantly, they now support `ALTER TABLE` on SQLite, and no longer accidentally remove indices when making some modifications. It now also exports the `Knex` typescript type as a named export.
```ts
import { Knex } from 'knex';
```
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/techdocs-common': patch
'@backstage/plugin-techdocs-backend': patch
'@backstage/plugin-techdocs': patch
---
OpenStack Swift publisher added for tech-docs.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-techdocs-backend': patch
---
Bump to the latest version of the Knex library.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Show a Not Found message when navigating to a nonexistent entity
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-github-actions': minor
---
Show workflow name
+48
View File
@@ -0,0 +1,48 @@
---
'@backstage/plugin-kubernetes': minor
'@backstage/plugin-kubernetes-backend': minor
---
Restructure configuration; Add GKE cluster locator
Config migration
1. `kubernetes.clusters` is now at `kubernetes.clusterLocatorMethods[].clusters` when the `clusterLocatorMethod` is of `type: 'config''`
2. `kubernetes.serviceLocatorMethod` is now an object. `multiTenant` is the only valid `type` currently
Old config example:
```yaml
kubernetes:
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- url: http://127.0.0.2:9999
name: aws-cluster-1
authProvider: 'aws'
```
New config example:
```yaml
kubernetes:
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- type: 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- url: http://127.0.0.2:9999
name: aws-cluster-1
authProvider: 'aws'
```
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
---
Move logic for generating URLs for the view, edit and source links of catalog
entities from the catalog frontend into the backend. This is done using the
existing support for the `backstage.io/view-url`, `backstage.io/edit-url` and
`backstage.io/source-location` annotations that are now filled by the
`AnnotateLocationEntityProcessor`. If these annotations are missing or empty,
the UI disables the related controls.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core': patch
'@backstage/theme': patch
---
Deprecated `ItemCard`. Added `ItemCardGrid` and `ItemCardHeader` instead, that can be used to compose functionality around regular Material-UI `Card` components instead.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-explore': patch
'@backstage/plugin-explore-react': patch
---
Standardize the tool cards in explore some more
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-explore': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-techdocs': patch
---
Make use of the new core `ItemCardGrid` and `ItemCardHeader` instead of the deprecated `ItemCard`.
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': patch
---
- Adds a link to the owner entity
- Corrects the link to the component which includes the namespace
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Add support for most TypeScript 4.1 syntax.
+1
View File
@@ -11,3 +11,4 @@
- [ ] Added or updated documentation
- [ ] Tests for new functionality and regression tests for bug fixes
- [ ] Screenshots attached (for UI changes)
- [ ] All your commits have a `Signed-off-by` line in the message. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#developer-certificate-of-origin))
+4
View File
@@ -19,6 +19,7 @@ Docusaurus
Dominik
Ek
Env
Erdogan
Expedia
Figma
Firekube
@@ -51,6 +52,7 @@ Namespaces
Niklas
OAuth
Okta
Oksuz
Oldsberg
Olle
Onboarding
@@ -63,6 +65,7 @@ Protobuf
Proxying
Raghunandan
Readme
rebase
Recharts
Redash
Repo
@@ -86,6 +89,7 @@ Templaters
Thauer
Tolerations
Tuite
Trendyol
Voi
WWW
Wealthsimple
+24 -23
View File
@@ -1,23 +1,24 @@
| Organization | Contact | Description of Use |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. |
| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks |
| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. |
| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. |
| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. |
| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit |
| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go |
| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling |
| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. |
| Organization | Contact | Description of Use |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. |
| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling |
| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling |
| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks |
| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. |
| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. |
| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. |
| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit |
| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go |
| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling |
| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. |
| [Trendyol](https://trendyol.com) | [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. |
+6
View File
@@ -96,6 +96,12 @@ Awesome commit message
Signed-off-by: Jane Smith jane.smith@example.com
```
- In case you forgot to add it to the most recent commit, use `git commit --amend --signoff`
- In case you forgot to add it to the last N commits in your branch, use `git rebase --signoff HEAD~N` and replace N with the number of new commits you created in your branch.
- If you have a very deep branch with a lot of commits, run `git rebase -i --signoff $(git merge-base -a master HEAD)`, double check to make sense of the commits (keep all lines as `pick`) and save and close the editor. This should bulk sign all the commits in your PR. Do be careful though. If you have a complex flow with a lot of branching and re-merging of work branches and stuff, merge-base may not be the right solution for you.
Note: If you have already pushed you branch to a remote, you might have to force push: `git push -f` after the rebase.
## Creating Changesets
We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. They help us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy, it helps when contributors include changesets with their pull requests.
+1 -1
View File
@@ -46,7 +46,7 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how
## Community
- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project
- [Good First Issues](https://github.com/backstage/backstage/contribute) - Start here if you want to contribute
- [Contributing to Backstage](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) - Start here if you want to contribute
- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the technical direction
- [FAQ](https://backstage.io/docs/FAQ) - Frequently Asked Questions
- [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll
+6 -5
View File
@@ -94,7 +94,7 @@ techdocs:
generators:
techdocs: 'docker' # Alternatives - 'local'
publisher:
type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives.
type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage' or 'openStackSwift'. Read documentation for using alternatives.
sentry:
organization: my-company
@@ -108,10 +108,11 @@ lighthouse:
baseUrl: http://localhost:3003
kubernetes:
serviceLocatorMethod: 'multiTenant'
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters: []
- type: 'config'
clusters: []
kafka:
clientId: backstage
@@ -207,7 +208,7 @@ catalog:
# groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
locations:
# Add a location here to ingest it, for example from an URL:
# Add a location here to ingest it, for example from a URL:
#
# - type: url
# target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml
+60 -19
View File
@@ -17,18 +17,22 @@ The following is a full example entry in `app-config.yaml`:
```yaml
kubernetes:
serviceLocatorMethod: 'multiTenant'
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- url: http://127.0.0.2:9999
name: gke-cluster-1
authProvider: 'google'
- type: 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- url: http://127.0.0.2:9999
name: aws-cluster-1
authProvider: 'aws'
- type: 'gke'
projectId: 'gke-clusters'
region: 'europe-west1'
```
### `serviceLocatorMethod`
@@ -44,26 +48,28 @@ Currently, the only valid value is:
This is an array used to determine where to retrieve cluster configuration from.
Currently, the only valid cluster locator method is:
Valid cluster locator methods are:
- `config` - This cluster locator method will read cluster information from your
app-config (see below).
#### `config`
### `clusters`
This cluster locator method will read cluster information from your app-config
(see below).
##### `clusters`
Used by the `config` cluster locator method to construct Kubernetes clients.
### `clusters.\*.url`
##### `clusters.\*.url`
The base URL to the Kubernetes control plane. Can be found by using the
"Kubernetes master" result from running the `kubectl cluster-info` command.
### `clusters.\*.name`
##### `clusters.\*.name`
A name to represent this cluster, this must be unique within the `clusters`
array. Users will see this value in the Service Catalog Kubernetes plugin.
### `clusters.\*.authProvider`
##### `clusters.\*.authProvider`
This determines how the Kubernetes client authenticates with the Kubernetes
cluster. Valid values are:
@@ -73,7 +79,7 @@ cluster. Valid values are:
| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
### `clusters.\*.serviceAccountToken` (optional)
##### `clusters.\*.serviceAccountToken` (optional)
The service account token to be used when using the `serviceAccount` auth
provider. You could get the service account token with:
@@ -85,6 +91,38 @@ kubectl -n <NAMESPACE> get secret $(kubectl -n <NAMESPACE> get sa <SERVICE_ACCOU
| base64 --decode
```
#### `gke`
This cluster locator is designed to work with Kubernetes clusters running in
[GKE][1]. It will configure the Kubernetes backend plugin to make requests to
clusters running within a Google Cloud project.
This cluster locator method will use the `google` authentication mechanism.
The Google Cloud service account to use can be configured through the
`GOOGLE_APPLICATION_CREDENTIALS` environment variable. Consult the [Google Cloud
docs][2] for more information.
For example:
```yaml
- type: 'gke'
projectId: 'gke-clusters'
region: 'europe-west1'
```
Will configure the Kubernetes plugin to connect to all GKE clusters in the
project `gke-clusters` in the region `europe-west1`.
##### `projectId`
The Google Cloud project to look for Kubernetes clusters in.
##### `region` (optional)
The Google Cloud region to look for Kubernetes clusters in. Defaults to all
regions.
### Role Based Access Control
The current RBAC permissions required are read-only cluster wide, for the
@@ -135,3 +173,6 @@ for more info.
```yaml
'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end'
```
[1]: https://cloud.google.com/kubernetes-engine
[2]: https://cloud.google.com/docs/authentication/production#linux-or-macos
@@ -19,7 +19,7 @@ passed as `input` to the function.
In `packages/backend/src/actions/custom.ts` we can create a new action.
```ts
import { createTemplateAction } from '../../createTemplateAction';
import { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
import fs from 'fs-extra';
export const createNewFileAction = () => {
@@ -35,7 +35,7 @@ export const createNewFileAction = () => {
title: 'Contents',
description: 'The contents of the file',
},
contents: {
filename: {
type: 'string',
title: 'Filename',
description: 'The filename of the file that will be created',
@@ -46,7 +46,7 @@ export const createNewFileAction = () => {
async handler(ctx) {
await fs.outputFile(
`${ctx.workspacePath}/${ctx.input.filename}`,
ctx.input.content,
ctx.input.contents,
);
},
});
+1
View File
@@ -54,6 +54,7 @@ providers are used.
| Google Cloud Storage (GCS) | Yes ✅ |
| Amazon Web Services (AWS) S3 | Yes ✅ |
| Azure Blob Storage | Yes ✅ |
| OpenStack Swift | Yes ✅ |
[Reach out to us](#feedback) if you want to request more platforms.
+76 -1
View File
@@ -257,7 +257,8 @@ techdocs:
**3a. (Recommended) Authentication using environment variable**
Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in
If you do not prefer (3a) and optionally like to use a service account, you can
set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in
your `app-config.yaml` to the your account name.
The storage blob client will automatically use the environment variable
@@ -309,3 +310,77 @@ and read the static generated documentation files. When you start the backend of
the app, you should be able to see
`techdocs info Successfully connected to the Azure Blob Storage container` in
the logs.
## Configuring OpenStack Swift Container with TechDocs
Follow the
[official OpenStack Api documentation](https://docs.openstack.org/api-ref/identity/v3/)
for the latest instructions on the following steps involving OpenStack Storage.
**1. Set `techdocs.publisher.type` config in your `app-config.yaml`**
Set `techdocs.publisher.type` to `'openStackSwift'`.
```yaml
techdocs:
publisher:
type: 'openStackSwift'
```
**2. Create an OpenStack Swift Storage Container**
Create a dedicated container for TechDocs sites.
[Refer to the official documentation](https://docs.openstack.org/mitaka/user-guide/dashboard_manage_containers.html).
TechDocs will publish documentation to this container and will fetch files from
here to serve documentation in Backstage. Note that the container names are
globally unique.
Set the config `techdocs.publisher.openStackSwift.containerName` in your
`app-config.yaml` to the name of the container you just created.
```yaml
techdocs:
publisher:
type: 'openStackSwift'
openStackSwift:
containerName: 'name-of-techdocs-storage-container'
```
**3. Authentication using app-config.yaml**
Set the configs in your `app-config.yaml` to point to your container name.
https://docs.openstack.org/api-ref/identity/v3/?expanded=password-authentication-with-unscoped-authorization-detail#password-authentication-with-unscoped-authorization
for more details.
```yaml
techdocs:
publisher:
type: 'openStackSwift'
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
```
**4. That's it!**
Your Backstage app is now ready to use OpenStack Swift Storage for TechDocs, to
store and read the static generated documentation files. When you start the
backend of the app, you should be able to see
`techdocs info Successfully connected to the OpenStack Swift Storage container`
in the logs.
@@ -4,20 +4,30 @@ title: Configuring App with plugins
description: Documentation on How Configuring App with plugins
---
Backstage plugins customize the app for your needs. There is a
[plugin marketplace](https://backstage.io/plugins) with plugins for many common
infrastructure needs - CI/CD, monitoring, auditing, and more.
## Adding existing plugins to your app
The following steps assume that you have created a new Backstage app and want to
add an existing plugin to it. We are using the
The following steps assume that you have
[created a Backstage app](./create-an-app.md) and want to add an existing plugin
to it. We are using the
[CircleCI](https://github.com/backstage/backstage/blob/master/plugins/circleci/README.md)
plugin in this example.
1. Add the plugin's npm package to the repo:
```bash
yarn add @backstage/plugin-circleci
yarn workspace app add @backstage/plugin-circleci
```
2. Add the plugin itself:
Note the plugin is added to the `app` package, rather than the root
package.json. Backstage Apps are set up as monorepos with
[yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). Since
CircleCI is a frontend UI plugin, it goes in `app` rather than `backend`.
2. Add the plugin itself to the App:
```js
// packages/app/src/plugins.ts
+15 -27
View File
@@ -27,9 +27,9 @@ The required steps in the host build are to install dependencies with
`yarn install`, generate type definitions using `yarn tsc`, and build all
packages with `yarn build`.
> NOTE: Using `yarn build` to build packages and bundle the backend assumes that
> you have migrated to using `backstage-cli backend:bundle` as your build script
> in the backend package.
> NOTE: If you created your app prior to 2021-02-18, follow the
> [migration step](https://github.com/backstage/backstage/releases/tag/release-2021-02-18)
> to move from `backend:build` to `backend:bundle`.
In a CI workflow it might look something like this:
@@ -43,22 +43,10 @@ yarn tsc
yarn build
```
Once the host build is complete, we are ready to build our image. We use the
following `Dockerfile`, which is also included when creating a new app with
`@backstage/create-app`:
Once the host build is complete, we are ready to build our image. The following
`Dockerfile` is included when creating a new app with `@backstage/create-app`:
```Dockerfile
# This dockerfile builds an image for the backend package.
# It should be executed with the root of the repo as docker context.
#
# Before building this image, be sure to have run the following commands in the repo root:
#
# yarn install
# yarn tsc
# yarn build
#
# Once the commands have been run, you can build the image using `yarn build-image`
FROM node:14-buster-slim
WORKDIR /app
@@ -78,15 +66,15 @@ CMD ["node", "packages/backend", "--config", "app-config.yaml"]
For more details on how the `backend:bundle` command and the `skeleton.tar.gz`
file works, see the
[`backend:bundle` command docs](../cli/commands.md#backendbundle)
[`backend:bundle` command docs](../cli/commands.md#backendbundle).
The `Dockerfile` is typically placed at `packages/backend/Dockerfile`, but needs
to be executed with the root of the repo as the build context, in order to get
access to the root `yarn.lock` and `package.json`, along with any other files
that might be needed, such as `.npmrc`.
The `Dockerfile` is located at `packages/backend/Dockerfile`, but needs to be
executed with the root of the repo as the build context, in order to get access
to the root `yarn.lock` and `package.json`, along with any other files that
might be needed, such as `.npmrc`.
In order to speed up the build we can significantly reduce the build context
size using the following `.dockerignore` in the root of the repo:
The `@backstage/create-app` command adds the following `.dockerignore` in the
root of the repo to speed up the build by reducing build context size:
```text
.git
@@ -96,9 +84,9 @@ packages
plugins
```
With the project build and the `.dockerignore` and `Dockerfile` in place, we are
now ready to build the final image. Assuming we're at the root of the repo, we
execute the build like this:
With the project built and the `.dockerignore` and `Dockerfile` in place, we are
now ready to build the final image. From the root of the repo, execute the
build:
```bash
docker image build . -f packages/backend/Dockerfile --tag backstage
+1 -1
View File
@@ -52,7 +52,7 @@
"husky": "^4.2.3",
"lerna": "^4.0.0",
"lint-staged": "^10.1.0",
"prettier": "^2.0.5",
"prettier": "^2.2.1",
"recursive-readdir": "^2.2.2",
"shx": "^0.3.2"
},
+1 -1
View File
@@ -60,7 +60,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/react-dom": "^16.9.8",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
+1 -1
View File
@@ -66,7 +66,7 @@
</script>
<% } %>
</head>
<body style="margin: 0;">
<body style="margin: 0">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
+1 -1
View File
@@ -47,7 +47,7 @@
"git-url-parse": "^11.4.4",
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"knex": "^0.21.6",
"knex": "^0.95.1",
"lodash": "^4.17.15",
"logform": "^2.1.1",
"minimatch": "^3.0.4",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import Knex from 'knex';
import { Knex } from 'knex';
import { Config } from '@backstage/config';
import { createDatabaseClient, ensureDatabaseExists } from './connection';
import { PluginDatabaseManager } from './types';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import knex from 'knex';
import knexFactory, { Knex } from 'knex';
import { Config } from '@backstage/config';
import { mergeDatabaseConfig } from './config';
import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres';
@@ -30,7 +30,7 @@ type DatabaseClient = 'pg' | 'sqlite3' | string;
*/
export function createDatabaseClient(
dbConfig: Config,
overrides?: Partial<knex.Config>,
overrides?: Partial<Knex.Config>,
) {
const client: DatabaseClient = dbConfig.getString('client');
@@ -40,7 +40,7 @@ export function createDatabaseClient(
return createSqliteDatabaseClient(dbConfig);
}
return knex(mergeDatabaseConfig(dbConfig.get(), overrides));
return knexFactory(mergeDatabaseConfig(dbConfig.get(), overrides));
}
/**
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import knex, { PgConnectionConfig } from 'knex';
import knexFactory, { Knex } from 'knex';
import { Config } from '@backstage/config';
import { mergeDatabaseConfig } from './config';
@@ -26,10 +26,10 @@ import { mergeDatabaseConfig } from './config';
*/
export function createPgDatabaseClient(
dbConfig: Config,
overrides?: knex.Config,
overrides?: Knex.Config,
) {
const knexConfig = buildPgDatabaseConfig(dbConfig, overrides);
const database = knex(knexConfig);
const database = knexFactory(knexConfig);
return database;
}
@@ -41,7 +41,7 @@ export function createPgDatabaseClient(
*/
export function buildPgDatabaseConfig(
dbConfig: Config,
overrides?: knex.Config,
overrides?: Knex.Config,
) {
return mergeDatabaseConfig(
dbConfig.get(),
@@ -62,7 +62,7 @@ export function buildPgDatabaseConfig(
export function getPgConnectionConfig(
dbConfig: Config,
parseConnectionString?: boolean,
): PgConnectionConfig | string {
): Knex.PgConnectionConfig | string {
const connection = dbConfig.get('connection') as any;
const isConnectionString =
typeof connection === 'string' || connection instanceof String;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import knex from 'knex';
import knexFactory, { Knex } from 'knex';
import { Config } from '@backstage/config';
import { mergeDatabaseConfig } from './config';
@@ -26,10 +26,10 @@ import { mergeDatabaseConfig } from './config';
*/
export function createSqliteDatabaseClient(
dbConfig: Config,
overrides?: knex.Config,
overrides?: Knex.Config,
) {
const knexConfig = buildSqliteDatabaseConfig(dbConfig, overrides);
const database = knex(knexConfig);
const database = knexFactory(knexConfig);
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
@@ -46,7 +46,7 @@ export function createSqliteDatabaseClient(
*/
export function buildSqliteDatabaseConfig(
dbConfig: Config,
overrides?: knex.Config,
overrides?: Knex.Config,
) {
return mergeDatabaseConfig(
dbConfig.get(),
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import knex from 'knex';
import { Knex } from 'knex';
/**
* The PluginDatabaseManager manages access to databases that Plugins get.
@@ -26,5 +26,5 @@ export interface PluginDatabaseManager {
* The purpose of this method is to allow plugins to get isolated data
* stores so that plugins are discouraged from database integration.
*/
getClient(): Promise<knex>;
getClient(): Promise<Knex>;
}
@@ -78,8 +78,8 @@ function withRetries(count: number, fn: () => Promise<void>) {
}
};
}
describe('UrlReaders', () => {
/* eslint-disable jest/no-disabled-tests */
describe.skip('UrlReaders', () => {
jest.setTimeout(30_000);
it(
+1 -1
View File
@@ -48,7 +48,7 @@
"example-app": "^0.2.18",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.6",
"knex": "^0.95.1",
"pg": "^8.3.0",
"pg-connection-string": "^2.3.0",
"sqlite3": "^5.0.0",
+3 -2
View File
@@ -19,15 +19,16 @@ import {
EntityName,
Location,
LOCATION_ANNOTATION,
stringifyLocationReference,
} from '@backstage/catalog-model';
import fetch from 'cross-fetch';
import {
AddLocationRequest,
AddLocationResponse,
CatalogRequestOptions,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
CatalogRequestOptions,
DiscoveryApi,
} from './types';
@@ -135,7 +136,7 @@ export class CatalogClient implements CatalogApi {
);
return all
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
.find(l => locationCompound === stringifyLocationReference(l));
}
async removeEntityByUid(
@@ -0,0 +1,70 @@
/*
* 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 { parseLocationReference, stringifyLocationReference } from './helpers';
describe('parseLocationReference', () => {
it('works for the simple case', () => {
expect(parseLocationReference('url:https://www.google.com')).toEqual({
type: 'url',
target: 'https://www.google.com',
});
});
it('rejects faulty inputs', () => {
expect(() => parseLocationReference(7 as any)).toThrow(
"Unable to parse location reference '7', unexpected argument number",
);
expect(() => parseLocationReference('')).toThrow(
"Unable to parse location reference '', expected '<type>:<target>', e.g. 'url:https://host/path'",
);
expect(() => parseLocationReference('hello')).toThrow(
"Unable to parse location reference 'hello', expected '<type>:<target>', e.g. 'url:https://host/path'",
);
expect(() => parseLocationReference(':hello')).toThrow(
"Unable to parse location reference ':hello', expected '<type>:<target>', e.g. 'url:https://host/path'",
);
expect(() => parseLocationReference('hello:')).toThrow(
"Unable to parse location reference 'hello:', expected '<type>:<target>', e.g. 'url:https://host/path'",
);
expect(() => parseLocationReference('http://blah')).toThrow(
"Invalid location reference 'http://blah', please prefix it with 'url:', e.g. 'url:http://blah'",
);
expect(() => parseLocationReference('https://bleh')).toThrow(
"Invalid location reference 'https://bleh', please prefix it with 'url:', e.g. 'url:https://bleh'",
);
});
});
describe('stringifyLocationReference', () => {
it('works for the simple case', () => {
expect(
stringifyLocationReference({
type: 'url',
target: 'https://www.google.com',
}),
).toEqual('url:https://www.google.com');
});
it('rejects faulty inputs', () => {
expect(() =>
stringifyLocationReference({ type: '', target: 'hello' }),
).toThrow('Unable to stringify location reference, empty type');
expect(() =>
stringifyLocationReference({ type: 'hello', target: '' }),
).toThrow('Unable to stringify location reference, empty target');
});
});
@@ -0,0 +1,82 @@
/*
* 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.
*/
/**
* Parses a string form location reference.
*
* Note that the return type is not `LocationSpec`, because we do not want to
* conflate the string form with the additional properties of that type.
*
* @param ref A string-form location reference, e.g. 'url:https://host'
* @returns A location reference, e.g. { type: 'url', target: 'https://host' }
*/
export function parseLocationReference(
ref: string,
): { type: string; target: string } {
if (typeof ref !== 'string') {
throw new TypeError(
`Unable to parse location reference '${ref}', unexpected argument ${typeof ref}`,
);
}
const splitIndex = ref.indexOf(':');
if (splitIndex < 0) {
throw new TypeError(
`Unable to parse location reference '${ref}', expected '<type>:<target>', e.g. 'url:https://host/path'`,
);
}
const type = ref.substr(0, splitIndex).trim();
const target = ref.substr(splitIndex + 1).trim();
if (!type || !target) {
throw new TypeError(
`Unable to parse location reference '${ref}', expected '<type>:<target>', e.g. 'url:https://host/path'`,
);
}
if (type === 'http' || type === 'https') {
throw new TypeError(
`Invalid location reference '${ref}', please prefix it with 'url:', e.g. 'url:${ref}'`,
);
}
return { type, target };
}
/**
* Turns a location reference into its string form.
*
* Note that the input type is not `LocationSpec`, because we do not want to
* conflate the string form with the additional properties of that type.
*
* @param ref A location reference, e.g. { type: 'url', target: 'https://host' }
* @returns A string-form location reference, e.g. 'url:https://host'
*/
export function stringifyLocationReference(ref: {
type: string;
target: string;
}): string {
const { type, target } = ref;
if (!type) {
throw new TypeError(`Unable to stringify location reference, empty type`);
} else if (!target) {
throw new TypeError(`Unable to stringify location reference, empty target`);
}
return `${type}:${target}`;
}
+7 -6
View File
@@ -14,14 +14,15 @@
* limitations under the License.
*/
export type { Location, LocationSpec } from './types';
export {
locationSchema,
locationSpecSchema,
analyzeLocationSchema,
} from './validation';
export {
LOCATION_ANNOTATION,
ORIGIN_LOCATION_ANNOTATION,
SOURCE_LOCATION_ANNOTATION,
} from './annotation';
export { parseLocationReference, stringifyLocationReference } from './helpers';
export type { Location, LocationSpec } from './types';
export {
analyzeLocationSchema,
locationSchema,
locationSpecSchema,
} from './validation';
+1 -1
View File
@@ -30,7 +30,7 @@
},
"devDependencies": {
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
"@types/node": "^14.14.32"
},
"files": [
"dist"
+4 -5
View File
@@ -63,7 +63,7 @@
"css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"esbuild": "^0.8.16",
"esbuild": "^0.8.56",
"eslint": "^7.1.0",
"eslint-config-prettier": "^6.0.0",
"eslint-formatter-friendly": "^7.0.0",
@@ -93,16 +93,15 @@
"recursive-readdir": "^2.2.2",
"replace-in-file": "^6.0.0",
"rollup": "2.33.x",
"rollup-plugin-dts": "1.4.13",
"rollup-plugin-dts": "^2.0.1",
"rollup-plugin-esbuild": "2.6.x",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.29.0",
"rollup-pluginutils": "^2.8.2",
"semver": "^7.3.2",
"start-server-webpack-plugin": "^2.2.5",
"style-loader": "^1.2.1",
"sucrase": "^3.16.0",
"sucrase": "^3.17.1",
"tar": "^6.0.1",
"terser-webpack-plugin": "^1.4.3",
"ts-jest": "^26.4.3",
@@ -131,7 +130,7 @@
"@types/inquirer": "^7.3.1",
"@types/mini-css-extract-plugin": "^1.2.2",
"@types/mock-fs": "^4.13.0",
"@types/node": "^13.7.2",
"@types/node": "^14.14.32",
"@types/react-dev-utils": "^9.0.4",
"@types/recursive-readdir": "^2.2.0",
"@types/rollup-plugin-peer-deps-external": "^2.2.0",
@@ -41,7 +41,7 @@
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"msw": "^0.21.2",
"cross-fetch": "^3.0.6"
},
+1 -1
View File
@@ -45,7 +45,7 @@
"@types/jest": "^26.0.7",
"@types/json-schema-merge-allof": "^0.6.0",
"@types/mock-fs": "^4.10.0",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/yup": "^0.29.8",
"mock-fs": "^4.13.0"
},
+1 -1
View File
@@ -34,7 +34,7 @@
},
"devDependencies": {
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
"@types/node": "^14.14.32"
},
"files": [
"dist"
+1 -1
View File
@@ -49,7 +49,7 @@
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.3"
+7 -4
View File
@@ -37,7 +37,8 @@ class RouteRefBase {
}
}
export class RouteRefImpl<Params extends AnyParams> extends RouteRefBase
export class RouteRefImpl<Params extends AnyParams>
extends RouteRefBase
implements RouteRef<Params> {
readonly [routeRefType] = 'absolute';
@@ -91,9 +92,11 @@ export function createRouteRef<
}
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
> extends RouteRefBase implements ExternalRouteRef<Params, Optional> {
Params extends AnyParams,
Optional extends boolean
>
extends RouteRefBase
implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
+1 -1
View File
@@ -79,7 +79,7 @@
"@types/d3-zoom": "^2.0.0",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/react-helmet": "^6.1.0",
"@types/zen-observable": "^0.8.0"
},
@@ -87,8 +87,8 @@ export const Breadcrumbs = ({ children, ...props }: Props) => {
}}
>
<List>
{expandablePages.map(pageLink => (
<ListItem button>
{expandablePages.map((pageLink, index) => (
<ListItem key={index} button>
<StyledBox clone>{pageLink}</StyledBox>
</ListItem>
))}
@@ -13,86 +13,101 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Grid } from '@material-ui/core';
import {
Card,
CardActions,
CardContent,
CardMedia,
makeStyles,
Typography,
} from '@material-ui/core';
import React from 'react';
import { MemoryRouter } from 'react-router';
import { ItemCard } from '.';
import { Button } from '../../components';
import { ItemCardGrid } from './ItemCardGrid';
import { ItemCardHeader } from './ItemCardHeader';
export default {
title: 'Layout/Item Card',
component: ItemCard,
title: 'Layout/Item Cards',
};
const text =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
const useStyles = makeStyles({
grid: {
gridTemplateColumns: 'repeat(auto-fill, 12em)',
},
header: {
color: 'black',
backgroundImage: 'linear-gradient(to bottom right, red, yellow)',
},
});
export const Default = () => (
<Grid container spacing={4}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card"
label="Button"
subtitle="Pretitle"
onClick={() => {}}
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card"
label="Button"
subtitle="Pretitle"
onClick={() => {}}
/>
</Grid>
</Grid>
);
export const Tags = () => (
<Grid container spacing={4}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card with Tags"
tags={['one tag', 'one tag']}
label="Button"
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card with Tags"
tags={['one tag', 'two tag']}
label="Button"
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card without Tags"
label="Button"
/>
</Grid>
</Grid>
);
export const Link = () => (
<MemoryRouter>
<Grid container spacing={4}>
<Grid item xs={12} md={3}>
<ItemCard
title="Backstage"
description="This is the description of an Item Card with link"
label="Read More"
href="https://backstage.io"
/>
</Grid>
<Grid item xs={12} md={3}>
<ItemCard
title="Backstage @ GitHub"
description="This is the description of an Item Card with link"
label="Read More"
href="https://github.com/backstage/backstage"
/>
</Grid>
</Grid>
<Typography paragraph>
The most basic setup is to place a bunch of cards into a large grid,
leaving styling to the defaults. Try to resize the window to see how they
rearrange themselves to fit the viewport.
</Typography>
<ItemCardGrid>
{[...Array(10).keys()].map(index => (
<Card key={index}>
<CardMedia>
<ItemCardHeader title={`Card #${index}`} subtitle="Subtitle" />
</CardMedia>
<CardContent>
{text
.split(' ')
.slice(0, 5 + Math.floor(Math.random() * 30))
.join(' ')}
</CardContent>
<CardActions>
<Button color="primary" to="/catalog">
Go There!
</Button>
</CardActions>
</Card>
))}
</ItemCardGrid>
</MemoryRouter>
);
export const Styling = () => {
const classes = useStyles();
return (
<MemoryRouter>
<Typography paragraph>
Both the grid and the header can be styled, using the{' '}
<Typography variant="caption">classes</Typography> property. This lets
you for example tweak the column sizes and the background of the header.
</Typography>
<ItemCardGrid classes={{ root: classes.grid }}>
{[...Array(10).keys()].map(index => (
<Card key={index}>
<CardMedia>
<ItemCardHeader
title={`Card #${index}`}
subtitle="Subtitle"
classes={{ root: classes.header }}
/>
</CardMedia>
<CardContent>
{text
.split(' ')
.slice(0, 5 + Math.floor(Math.random() * 30))
.join(' ')}
</CardContent>
<CardActions>
<Button color="primary" to="/catalog">
Go There!
</Button>
</CardActions>
</Card>
))}
</ItemCardGrid>
</MemoryRouter>
);
};
+59 -60
View File
@@ -13,33 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core';
import clsx from 'clsx';
import {
Box,
Card,
CardActions,
CardContent,
CardMedia,
Chip,
} from '@material-ui/core';
import React, { ReactNode } from 'react';
import { Link } from '../../components';
const useStyles = makeStyles(theme => ({
header: {
color: theme.palette.common.white,
padding: theme.spacing(2, 2, 6),
backgroundImage: 'linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)',
},
content: {
padding: theme.spacing(2),
},
description: {
height: 175,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
withTags: {
height: 'calc(175px - 32px - 8px)',
},
footer: {
display: 'flex',
flexDirection: 'row-reverse',
},
}));
import { Button } from '../../components';
import { ItemCardHeader } from './ItemCardHeader';
type ItemCardProps = {
description?: string;
@@ -53,6 +37,31 @@ type ItemCardProps = {
href?: string;
};
/**
* This card type has been deprecated. Instead use plain MUI Card and helpers
* where appropriate.
*
* <code>
* <!--
* <Card>
* <CardMedia>
* <ItemCardHeader title="My Card" subtitle="neat!" />
* </CardMedia>
* <CardContent>
* Some text
* </CardContent>
* <CardActions>
* <Button color="primary" to="https://backstage.io">
* Get Started
* </Button>
* </CardActions>
* </Card>
* -->
* </code>
*
* @deprecated Use plain MUI <Card> and composable helpers instead.
* @see https://material-ui.com/components/cards/
*/
export const ItemCard = ({
description,
tags,
@@ -63,43 +72,33 @@ export const ItemCard = ({
onClick,
href,
}: ItemCardProps) => {
const classes = useStyles();
return (
<Card>
<div className={classes.header}>
{(subtitle || type) && (
<Typography variant="subtitle2">{subtitle ?? type}</Typography>
<CardMedia>
<ItemCardHeader title={title} subtitle={subtitle || type} />
</CardMedia>
<CardContent>
{tags?.length ? (
<Box>
{tags.map((tag, i) => (
<Chip size="small" label={tag} key={i} />
))}
</Box>
) : null}
{description}
</CardContent>
<CardActions>
{!href && (
<Button to="#" onClick={onClick} color="primary">
{label}
</Button>
)}
<Typography variant="h6">{title}</Typography>
</div>
<div className={classes.content}>
{tags?.map((tag, i) => (
<Chip label={tag} key={`tag-${i}`} />
))}
<Typography
variant="body2"
paragraph
className={clsx(
classes.description,
tags && tags.length > 0 && classes.withTags,
)}
>
{description}
</Typography>
<div className={classes.footer}>
{!href && (
<Button onClick={onClick} color="primary">
{label}
</Button>
)}
{href && (
<Button component={Link} to={href} color="primary">
{label}
</Button>
)}
</div>
</div>
{href && (
<Button to={href} color="primary">
{label}
</Button>
)}
</CardActions>
</Card>
);
};
@@ -0,0 +1,50 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import { Card } from '@material-ui/core';
import { screen } from '@testing-library/react';
import React from 'react';
import { ItemCardGrid } from './ItemCardGrid';
describe('<ItemCardGrid />', () => {
it('renders default without exploding', async () => {
await renderInTestApp(
<ItemCardGrid>
<Card>Hello!</Card>
</ItemCardGrid>,
);
expect(screen.getByRole('grid')).toBeInTheDocument();
expect(screen.getByText('Hello!')).toBeInTheDocument();
});
it('renders custom styles', async () => {
await renderInTestApp(
<>
<ItemCardGrid>
<Card>Hello!</Card>
</ItemCardGrid>
<ItemCardGrid classes={{ root: 'my-css-class' }}>
<Card>Goodbye!</Card>
</ItemCardGrid>
</>,
);
expect(screen.getAllByRole('grid')[0]).toHaveStyle({
gridTemplateColumns: 'repeat(auto-fill, minmax(22em, 1fr))',
});
expect(screen.getAllByRole('grid')[1]).toHaveClass('my-css-class');
});
});
@@ -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 { createStyles, makeStyles, Theme, WithStyles } from '@material-ui/core';
import React from 'react';
const styles = (theme: Theme) =>
createStyles({
root: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(22em, 1fr))',
gridAutoRows: '1fr',
gridGap: theme.spacing(2),
},
});
const useStyles = makeStyles(styles);
export type ItemCardGridProps = Partial<WithStyles<typeof styles>> & {
/**
* The Card items of the grid.
*/
children?: React.ReactNode;
};
/**
* A default grid to use when arranging "item cards" - cards that let users
* select among several options.
*
* The immediate children are expected to be MUI Card components.
*
* Styles for the grid can be overridden using the `classes` prop, e.g.:
*
* <code>
* <ItemCardGrid title="Hello" classes={{ root: myClassName }} />
* </code>
*
* This can be useful for e.g. overriding gridTemplateColumns to adapt the
* minimum size of the cells to fit the content better.
*/
export const ItemCardGrid = (props: ItemCardGridProps) => {
const { children, ...otherProps } = props;
const classes = useStyles(otherProps);
return (
<div role="grid" className={classes.root} {...otherProps}>
{children}
</div>
);
};
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import { Card, CardMedia } from '@material-ui/core';
import { screen } from '@testing-library/react';
import React from 'react';
import { ItemCardHeader } from './ItemCardHeader';
describe('<ItemCardHeader />', () => {
it('renders default without exploding', async () => {
await renderInTestApp(
<Card>
<CardMedia>
<ItemCardHeader title="My Title" subtitle="My Subtitle" />
</CardMedia>
</Card>,
);
expect(screen.getByText('My Title')).toBeInTheDocument();
expect(screen.getByText('My Subtitle')).toBeInTheDocument();
});
it('renders custom children', async () => {
await renderInTestApp(
<Card>
<CardMedia>
<ItemCardHeader title="My Title">My Custom Text</ItemCardHeader>
</CardMedia>
</Card>,
);
expect(screen.getByText('My Title')).toBeInTheDocument();
expect(screen.getByText('My Custom Text')).toBeInTheDocument();
});
it('renders custom styles', async () => {
await renderInTestApp(
<Card>
<CardMedia>
<ItemCardHeader classes={{ root: 'my-css-class' }}>
My Custom Text
</ItemCardHeader>
</CardMedia>
</Card>,
);
expect(screen.getByText('My Custom Text')).toHaveClass('my-css-class');
});
});
@@ -0,0 +1,85 @@
/*
* 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,
makeStyles,
Theme,
Typography,
WithStyles,
} from '@material-ui/core';
import React from 'react';
const styles = (theme: Theme) =>
createStyles({
root: {
color: theme.palette.common.white,
padding: theme.spacing(2, 2, 3),
backgroundImage: 'linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)',
backgroundPosition: 0,
backgroundSize: 'inherit',
},
});
const useStyles = makeStyles(styles);
export type ItemCardHeaderProps = Partial<WithStyles<typeof styles>> & {
/**
* A large title to show in the header, providing the main heading.
*
* Use this if you want to have the default styling and placement of a title.
*/
title?: React.ReactNode;
/**
* A slightly smaller title to show in the header, providing additional
* details.
*
* Use this if you want to have the default styling and placement of a
* subtitle.
*/
subtitle?: React.ReactNode;
/**
* Custom children to draw in the header.
*
* If the title and/or subtitle were specified, the children are drawn below
* those.
*/
children?: React.ReactNode;
};
/**
* A simple card header, rendering a default look for "item cards" - cards that
* are arranged in a grid for users to select among several options.
*
* This component expects to be placed within a MUI <CardMedia>.
*
* Styles for the header can be overridden using the `classes` prop, e.g.:
*
* <code>
* <ItemCardHeader title="Hello" classes={{ root: myClassName }} />
* </code>
*/
export const ItemCardHeader = (props: ItemCardHeaderProps) => {
const { title, subtitle, children } = props;
const classes = useStyles(props);
return (
<div className={classes.root}>
{subtitle && <Typography variant="subtitle2">{subtitle}</Typography>}
{title && <Typography variant="h6">{title}</Typography>}
{children}
</div>
);
};
@@ -15,3 +15,7 @@
*/
export { ItemCard } from './ItemCard';
export { ItemCardGrid } from './ItemCardGrid';
export type { ItemCardGridProps } from './ItemCardGrid';
export { ItemCardHeader } from './ItemCardHeader';
export type { ItemCardHeaderProps } from './ItemCardHeader';
+4 -3
View File
@@ -133,9 +133,10 @@ export const SidebarIntro = () => {
starredItemsDismissed: false,
recentlyViewedItemsDismissed: false,
};
const [dismissedIntro, setDismissedIntro] = useLocalStorage<
SidebarIntroLocalStorage
>(SIDEBAR_INTRO_LOCAL_STORAGE);
const [
dismissedIntro,
setDismissedIntro,
] = useLocalStorage<SidebarIntroLocalStorage>(SIDEBAR_INTRO_LOCAL_STORAGE);
const { starredItemsDismissed, recentlyViewedItemsDismissed } =
dismissedIntro ?? {};
-1
View File
@@ -56,7 +56,6 @@
"@backstage/plugin-catalog": "*",
"@backstage/plugin-catalog-backend": "*",
"@backstage/plugin-catalog-import": "*",
"@backstage/plugin-circleci": "*",
"@backstage/plugin-explore": "*",
"@backstage/plugin-github-actions": "*",
"@backstage/plugin-lighthouse": "*",
@@ -15,7 +15,6 @@
"@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}",
"@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}",
"@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}",
"@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}",
"@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}",
"@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}",
"@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}",
@@ -34,7 +33,7 @@
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/react-dom": "^16.9.8",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
@@ -17,9 +17,9 @@ import { ApiEntity, Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import {
ApiDefinitionCard,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ConsumedApisCard,
ConsumingComponentsCard,
ProvidedApisCard,
ProvidingComponentsCard
} from '@backstage/plugin-api-docs';
import {
@@ -28,9 +28,6 @@ import {
import {
useEntity
} from '@backstage/plugin-catalog-react';
import {
isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter
} from '@backstage/plugin-circleci';
import {
isPluginApplicableToEntity as isGitHubActionsAvailable, Router as GitHubActionsRouter
} from '@backstage/plugin-github-actions';
@@ -45,8 +42,6 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => {
switch (true) {
case isGitHubActionsAvailable(entity):
return <GitHubActionsRouter entity={entity} />;
case isCircleCIAvailable(entity):
return <CircleCIRouter entity={entity} />;
default:
return (
<WarningPanel title="CI/CD switcher:">
@@ -1,7 +1,6 @@
export { plugin as ApiDocs } from '@backstage/plugin-api-docs';
export { plugin as CatalogPlugin } from '@backstage/plugin-catalog';
export { plugin as CatalogImport } from '@backstage/plugin-catalog-import';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as GithubActions } from '@backstage/plugin-github-actions';
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs';
+1 -1
View File
@@ -49,7 +49,7 @@
"devDependencies": {
"@backstage/cli": "^0.6.3",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
"@types/node": "^14.14.32"
},
"files": [
"dist"
+1 -1
View File
@@ -36,7 +36,7 @@
"devDependencies": {
"@types/fs-extra": "^9.0.1",
"@types/github-slugger": "^1.3.0",
"@types/node": "^13.7.2",
"@types/node": "^14.14.32",
"nodemon": "^2.0.2"
},
"files": [
+1 -1
View File
@@ -26,7 +26,7 @@
"devDependencies": {
"@backstage/cli-common": "^0.1.1",
"@types/fs-extra": "^9.0.1",
"@types/node": "^13.7.2",
"@types/node": "^14.14.32",
"chalk": "^4.0.0",
"commander": "^6.1.0",
"cross-fetch": "^3.0.6",
@@ -88,4 +88,10 @@ describe('ScmIntegrations', () => {
}),
).toBe('https://absolute.com/path');
});
it('can resolveEditUrl using fallback', () => {
expect(i.resolveEditUrl('http://example.com/x/a.yaml')).toBe(
'http://example.com/x/a.yaml',
);
});
});
@@ -91,4 +91,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
return integration.resolveUrl(options);
}
resolveEditUrl(url: string): string {
const integration = this.byUrl(url);
if (!integration) {
return url;
}
return integration.resolveEditUrl(url);
}
}
@@ -99,4 +99,18 @@ describe('AzureIntegration', () => {
).toBe('https://dev.azure.com/organization/project/test');
});
});
it('resolve edit URL', () => {
const integration = new AzureIntegration({ host: 'h.com' } as any);
// TODO: The Azure integration doesn't support resolving an edit URL yet,
// instead we keep the input URL.
expect(
integration.resolveEditUrl(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
);
});
});
@@ -15,7 +15,7 @@
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations } from '../helpers';
import { basicIntegrations, isValidUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
@@ -53,12 +53,8 @@ export class AzureIntegration implements ScmIntegration {
const { url, base } = options;
// If we can parse the url, it is absolute - then return it verbatim
try {
// eslint-disable-next-line no-new
new URL(url);
if (isValidUrl(url)) {
return url;
} catch {
// Ignore intentionally - looks like a relative path
}
const parsed = parseGitUrl(base);
@@ -78,4 +74,10 @@ export class AzureIntegration implements ScmIntegration {
return newUrl.toString();
}
resolveEditUrl(url: string): string {
// TODO: Implement edit URL for Azure, fallback to view url as I don't know
// how azure works.
return url;
}
}
@@ -44,4 +44,16 @@ describe('BitbucketIntegration', () => {
expect(integration.type).toBe('bitbucket');
expect(integration.title).toBe('h.com');
});
it('resolve edit URL', () => {
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
expect(
integration.resolveEditUrl(
'https://bitbucket.org/my-owner/my-project/src/master/README.md',
),
).toBe(
'https://bitbucket.org/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master',
);
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
@@ -51,4 +52,16 @@ export class BitbucketIntegration implements ScmIntegration {
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
resolveEditUrl(url: string): string {
const urlData = parseGitUrl(url);
const editUrl = new URL(url);
editUrl.searchParams.set('mode', 'edit');
// TODO: Not sure what spa=0 does, at least bitbucket.org doesn't support it
// but this is taken over from the initial implementation.
editUrl.searchParams.set('spa', '0');
editUrl.searchParams.set('at', urlData.ref);
return editUrl.toString();
}
}
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitHubIntegration } from './GitHubIntegration';
import { GitHubIntegration, replaceUrlType } from './GitHubIntegration';
describe('GitHubIntegration', () => {
it('has a working factory', () => {
@@ -49,4 +49,60 @@ describe('GitHubIntegration', () => {
expect(integration.title).toBe('h.com');
expect(integration.config.host).toBe('h.com');
});
it('resolveUrl', () => {
const integration = new GitHubIntegration({ host: 'h.com' });
expect(
integration.resolveUrl({
url: '../a.yaml',
base:
'https://github.com/backstage/backstage/blob/master/test/README.md',
}),
).toBe('https://github.com/backstage/backstage/tree/master/a.yaml');
expect(
integration.resolveUrl({
url: './',
base:
'https://github.com/backstage/backstage/blob/master/test/README.md',
}),
).toBe('https://github.com/backstage/backstage/tree/master/test/');
});
it('resolve edit URL', () => {
const integration = new GitHubIntegration({ host: 'h.com' });
expect(
integration.resolveEditUrl(
'https://github.com/backstage/backstage/blob/master/README.md',
),
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
});
});
describe('replaceUrlType', () => {
it('should replace with expected type', () => {
expect(
replaceUrlType(
'https://github.com/backstage/backstage/blob/master/README.md',
'edit',
),
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
expect(
replaceUrlType(
'https://github.com/webmodules/blob/blob/master/test',
'tree',
),
).toBe('https://github.com/webmodules/blob/tree/master/test');
expect(
replaceUrlType('https://github.com/blob/blob/blob/master/test', 'tree'),
).toBe('https://github.com/blob/blob/tree/master/test');
expect(
replaceUrlType(
'https://github.com/backstage/backstage/edit/tree/README.md',
'blob',
),
).toBe('https://github.com/backstage/backstage/blob/tree/README.md');
});
});
@@ -47,6 +47,25 @@ export class GitHubIntegration implements ScmIntegration {
}
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
// GitHub uses blob URLs for files and tree urls for directory listings. But
// there is a redirect from tree to blob for files, so we can always return
// tree urls here.
return replaceUrlType(defaultScmResolveUrl(options), 'tree');
}
resolveEditUrl(url: string): string {
return replaceUrlType(url, 'edit');
}
}
export function replaceUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
return url.replace(
/\/\/([^/]+)\/([^/]+)\/([^/]+)\/(blob|tree|edit)\//,
(_, host, owner, repo) => {
return `//${host}/${owner}/${repo}/${type}/`;
},
);
}
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitLabIntegration } from './GitLabIntegration';
import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
describe('GitLabIntegration', () => {
it('has a working factory', () => {
@@ -43,4 +43,43 @@ describe('GitLabIntegration', () => {
expect(integration.type).toBe('gitlab');
expect(integration.title).toBe('h.com');
});
it('resolve edit URL', () => {
const integration = new GitLabIntegration({ host: 'h.com' } as any);
expect(
integration.resolveEditUrl(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
});
});
describe('replaceUrlType', () => {
it('should replace with expected type', () => {
expect(
replaceUrlType(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
'edit',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
expect(
replaceUrlType(
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
expect(
replaceUrlType(
'https://gitlab.com/blob/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
expect(
replaceUrlType(
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/README.md');
});
});
@@ -49,4 +49,15 @@ export class GitLabIntegration implements ScmIntegration {
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
resolveEditUrl(url: string): string {
return replaceUrlType(url, 'edit');
}
}
export function replaceUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
return url.replace(/\/\-\/(blob|tree|edit)\//, `/-/${type}/`);
}
+26
View File
@@ -51,6 +51,19 @@ export interface ScmIntegration {
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
/**
* Resolves the edit URL for a file within the SCM system.
*
* Most SCM systems have a web interface that allows viewing and editing files
* in the repository. The returned URL directly jumps into the edit mode for
* the file.
* If this is not possible, the integration can fall back to a URL to view
* the file in the web interface.
*
* @param url The absolute URL to the file that should be edited.
*/
resolveEditUrl(url: string): string;
}
/**
@@ -103,6 +116,19 @@ export interface ScmIntegrationRegistry
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
/**
* Resolves the edit URL for a file within the SCM system.
*
* Most SCM systems have a web interface that allows viewing and editing files
* in the repository. The returned URL directly jumps into the edit mode for
* the file.
* If this is not possible, the integration can fall back to a URL to view
* the file in the web interface.
*
* @param url The absolute URL to the file that should be edited.
*/
resolveEditUrl(url: string): string;
}
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {
@@ -0,0 +1,109 @@
/*
* 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 fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { EventEmitter } from 'events';
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const checkFileExists = async (Key: string): Promise<boolean> => {
// Key will always have / as file separator irrespective of OS since cloud providers expects /.
// Normalize Key to OS specific path before checking if file exists.
const filePath = path.join(rootDir, Key);
try {
fs.accessSync(filePath, fs.constants.F_OK);
return true;
} catch (err) {
return false;
}
};
class PkgCloudStorageClient {
getFile(
containerName: string,
file: string,
callback: (err: any, file: string) => any,
) {
checkFileExists(file).then(res => {
if (!res) {
callback('File does not exist', file);
throw new Error('File does not exist');
} else {
callback(undefined, 'success');
}
});
}
getContainer(
containerName: string,
callback: (err: string, container: string) => any,
) {
if (containerName !== 'mock') {
callback('Container does not exist', containerName);
throw new Error('Container does not exist');
} else {
callback('Container does not exist', 'success');
}
}
upload({ remote }: { remote: string }) {
const filePath = path.join(rootDir, remote);
const emitter = new EventEmitter();
process.nextTick(() => {
if (fs.existsSync(filePath)) {
emitter.emit('success');
(emitter as any).end = () => true;
} else {
emitter.emit(
'error',
new Error(`The file ${filePath} does not exist !`),
);
}
});
return emitter;
}
download({ remote }: { remote: string }) {
const filePath = path.join(rootDir, remote);
const emitter = new EventEmitter();
process.nextTick(() => {
if (fs.existsSync(filePath)) {
emitter.emit('data', Buffer.from(fs.readFileSync(filePath)));
emitter.emit('end');
} else {
emitter.emit(
'error',
new Error(`The file ${filePath} does not exist !`),
);
}
});
return emitter;
}
}
export class storage {
static createClient() {
return new PkgCloudStorageClient();
}
}
+2
View File
@@ -56,6 +56,7 @@
"mime-types": "^2.1.27",
"mock-fs": "^4.13.0",
"p-limit": "^3.1.0",
"pkgcloud": "^2.2.0",
"recursive-readdir": "^2.2.2",
"winston": "^3.2.1"
},
@@ -66,6 +67,7 @@
"@types/js-yaml": "^3.12.5",
"@types/mime-types": "^2.1.0",
"@types/mock-fs": "^4.13.0",
"@types/pkgcloud": "^1.7.4",
"@types/recursive-readdir": "^2.2.0"
},
"jest": {
+1 -1
View File
@@ -105,7 +105,7 @@ describe('parseReferenceAnnotation', () => {
'backstage.io/techdocs-ref',
mockEntityWithBadAnnotation,
);
}).toThrow(/Failure to parse/);
}).toThrow(/Unable to parse/);
});
});
+6 -18
View File
@@ -15,7 +15,7 @@
*/
import { Git, InputError, UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Entity, parseLocationReference } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import fs from 'fs-extra';
import parseGitUrl from 'git-url-parse';
@@ -36,28 +36,15 @@ export const parseReferenceAnnotation = (
entity: Entity,
): ParsedLocationAnnotation => {
const annotation = entity.metadata.annotations?.[annotationName];
if (!annotation) {
throw new InputError(
`No location annotation provided in entity: ${entity.metadata.name}`,
);
}
// split on the first colon for the protocol and the rest after the first split
// is the location.
const [type, target] = annotation.split(/:(.+)/) as [
RemoteProtocol?,
string?,
];
if (!type || !target) {
throw new InputError(
`Failure to parse either protocol or location for entity: ${entity.metadata.name}`,
);
}
const { type, target } = parseLocationReference(annotation);
return {
type,
type: type as RemoteProtocol,
target,
};
};
@@ -77,8 +64,9 @@ export const getLocationForEntity = (
case 'url':
return { type, target };
case 'dir':
if (path.isAbsolute(target)) return { type, target };
if (path.isAbsolute(target)) {
return { type, target };
}
return parseReferenceAnnotation(
'backstage.io/managed-by-location',
entity,
@@ -0,0 +1,248 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
} 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';
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock pkgcloud client library
const createMockEntity = (annotations = {}): Entity => {
return {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'test-component-name',
namespace: 'test-namespace',
annotations: {
...annotations,
},
},
};
};
const createMockEntityName = (): EntityName => ({
kind: 'TestKind',
name: 'test-component-name',
namespace: 'test-namespace',
});
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const getEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = 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);
let publisher: PublisherBase;
beforeEach(() => {
mockFs.restore();
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'openStackSwift',
openStackSwift: {
credentials: {
username: 'mockuser',
password: 'verystrongpass',
},
authUrl: 'mockauthurl',
region: 'mockregion',
containerName: 'mock',
},
},
},
});
publisher = OpenStackSwiftPublish.fromConfig(mockConfig, logger);
});
describe('OpenStackSwiftPublish', () => {
describe('publish', () => {
beforeEach(() => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'index.html': '',
'404.html': '',
assets: {
'main.css': '',
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should publish a directory', async () => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
expect(
await publisher.publish({
entity,
directory: entityRootDir,
}),
).toBeUndefined();
});
it('should fail to publish a directory', async () => {
expect.assertions(3);
const wrongPathToGeneratedDirectory = path.join(
rootDir,
'wrong',
'path',
'to',
'generatedDirectory',
);
const entity = createMockEntity();
await expect(
publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
}),
).rejects.toThrowError();
await publisher
.publish({
entity,
directory: wrongPathToGeneratedDirectory,
})
.catch(error => {
expect(error.message).toEqual(
// Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error
// Issue reported https://github.com/tschaub/mock-fs/issues/118
expect.stringContaining(
`Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`,
),
);
expect(error.message).toEqual(
expect.stringContaining(wrongPathToGeneratedDirectory),
);
});
mockFs.restore();
});
});
describe('hasDocsBeenGenerated', () => {
it('should return true if docs has been generated', async () => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'index.html': 'file-content',
},
});
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
mockFs.restore();
});
it('should return false if docs has not been generated', async () => {
const entity = createMockEntity();
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false);
});
});
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"}',
},
});
const expectedMetadata: TechDocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
};
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'}`,
},
});
const expectedMetadata: TechDocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
};
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();
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
await publisher
.fetchTechDocsMetadata(entityNameMock)
.catch(error =>
expect(error).toEqual(
new Error(
`TechDocs metadata fetch failed, The file ${path.join(
entityRootDir,
'techdocs_metadata.json',
)} does not exist !`,
),
),
);
});
});
});
@@ -0,0 +1,263 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, 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 { Readable } from 'stream';
import { Logger } from 'winston';
import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers';
import { PublisherBase, PublishRequest, TechDocsMetadata } from './types';
const streamToBuffer = (stream: Readable): Promise<Buffer> => {
return new Promise((resolve, reject) => {
try {
const chunks: any[] = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
} catch (e) {
throw new Error(`Unable to parse the response data ${e.message}`);
}
});
};
export class OpenStackSwiftPublish implements PublisherBase {
static fromConfig(config: Config, logger: Logger): PublisherBase {
let containerName = '';
try {
containerName = config.getString(
'techdocs.publisher.openStackSwift.containerName',
);
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'openStackSwift' in your app config, " +
'techdocs.publisher.openStackSwift.containerName is required.',
);
}
const openStackSwiftConfig = config.getConfig(
'techdocs.publisher.openStackSwift',
);
const storageClient = storage.createClient({
provider: 'openstack',
username: openStackSwiftConfig.getString('credentials.username'),
password: openStackSwiftConfig.getString('credentials.password'),
authUrl: openStackSwiftConfig.getString('authUrl'),
keystoneAuthVersion:
openStackSwiftConfig.getOptionalString('keystoneAuthVersion') || 'v3',
domainId: openStackSwiftConfig.getOptionalString('domainId') || 'default',
domainName:
openStackSwiftConfig.getOptionalString('domainName') || 'Default',
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);
}
constructor(
private readonly storageClient: storage.Client,
private readonly containerName: string,
private readonly logger: Logger,
) {
this.storageClient = storageClient;
this.containerName = containerName;
this.logger = logger;
}
/**
* 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
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
try {
// Note: OpenStack Swift manages creation of parent directories if they do not exist.
// So collecting path of only the files is good enough.
const allFilesToUpload = await getFileTreeRecursively(directory);
const limiter = createLimiter(10);
const uploadPromises: Array<Promise<unknown>> = [];
for (const filePath of allFilesToUpload) {
// Remove the absolute path prefix of the source directory
// Path of all files to upload, relative to the root of the source directory
// e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png']
const relativeFilePath = path.relative(directory, filePath);
// Convert destination file path to a POSIX path for uploading.
// Swift expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://docs.openstack.org/python-openstackclient/pike/cli/man/openstack.html
const relativeFilePathPosix = relativeFilePath
.split(path.sep)
.join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Swift container file relative path
const params = {
container: this.containerName,
remote: destination,
};
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
const uploadFile = limiter(
() =>
new Promise((res, rej) => {
const readStream = fs.createReadStream(filePath, 'utf8');
const writeStream = this.storageClient.upload(params);
writeStream.on('error', rej);
writeStream.on('success', res);
readStream.pipe(writeStream);
}),
);
uploadPromises.push(uploadFile);
}
await Promise.all(uploadPromises);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
);
return;
} catch (e) {
const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
}
async fetchTechDocsMetadata(
entityName: EntityName,
): Promise<TechDocsMetadata> {
try {
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const stream = this.storageClient.download({
container: this.containerName,
remote: `${entityRootDir}/techdocs_metadata.json`,
});
try {
const techdocsMetadataJson = await streamToBuffer(stream);
if (!techdocsMetadataJson) {
throw new Error(
`Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`,
);
}
const techdocsMetadata = JSON5.parse(
techdocsMetadataJson.toString('utf-8'),
);
resolve(techdocsMetadata);
} catch (err) {
this.logger.error(err.message);
reject(new Error(err.message));
}
});
} catch (e) {
throw new Error(`TechDocs metadata fetch failed, ${e.message}`);
}
}
/**
* Express route middleware to serve static files on a route in techdocs-backend.
*/
docsRouter(): express.Handler {
return async (req, res) => {
// Trim the leading forward slash
// filePath example - /default/Component/documented-component/index.html
const filePath = req.path.replace(/^\//, '');
// Files with different extensions (CSS, HTML) need to be served with different headers
const fileExtension = path.extname(filePath);
const responseHeaders = getHeadersForFileExtension(fileExtension);
const stream = this.storageClient.download({
container: this.containerName,
remote: filePath,
});
try {
// Inject response headers
for (const [headerKey, headerValue] of Object.entries(
responseHeaders,
)) {
res.setHeader(headerKey, headerValue);
}
res.send(await streamToBuffer(stream));
} catch (err) {
this.logger.warn(err.message);
res.status(404).send(err.message);
}
};
}
/**
* A helper function which checks if index.html of an Entity's docs site is available. This
* can be used to verify if there are any pre-generated docs available to serve.
*/
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
try {
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
return new Promise(res => {
this.storageClient.getFile(
this.containerName,
`${entityRootDir}/index.html`,
(err, file) => {
if (!err && file) {
res(true);
} else {
res(false);
this.logger.warn(err.message);
}
},
);
});
} catch (e) {
return Promise.resolve(false);
}
}
}
@@ -23,6 +23,7 @@ import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
import { AzureBlobStoragePublish } from './azureBlobStorage';
import { OpenStackSwiftPublish } from './openStackSwift';
const logger = getVoidLogger();
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
@@ -161,4 +162,30 @@ describe('Publisher', () => {
});
expect(publisher).toBeInstanceOf(AzureBlobStoragePublish);
});
it('should create Open Stack Swift publisher from 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: 'mock',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(OpenStackSwiftPublish);
});
});
@@ -22,6 +22,7 @@ import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
import { AzureBlobStoragePublish } from './azureBlobStorage';
import { OpenStackSwiftPublish } from './openStackSwift';
type factoryOptions = {
logger: Logger;
@@ -53,6 +54,11 @@ export class Publisher {
'Creating Azure Blob Storage Container publisher for TechDocs',
);
return AzureBlobStoragePublish.fromConfig(config, logger);
case 'openStackSwift':
logger.info(
'Creating OpenStack Swift Container publisher for TechDocs',
);
return OpenStackSwiftPublish.fromConfig(config, logger);
case 'local':
logger.info('Creating Local publisher for TechDocs');
return new LocalPublish(config, logger, discovery);
@@ -23,7 +23,8 @@ export type PublisherType =
| 'local'
| 'googleGcs'
| 'awsS3'
| 'azureBlobStorage';
| 'azureBlobStorage'
| 'openStackSwift';
export type PublishRequest = {
entity: Entity;
+1 -1
View File
@@ -37,7 +37,7 @@
},
"devDependencies": {
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
"@types/node": "^14.14.32"
},
"files": [
"dist"
+1 -1
View File
@@ -47,7 +47,7 @@
"devDependencies": {
"@backstage/cli": "^0.6.2",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
"@types/node": "^14.14.32"
},
"files": [
"dist"
+19
View File
@@ -234,12 +234,31 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
margin: `0 ${theme.spacing(0.5)}px 0 -${theme.spacing(0.5)}px`,
},
},
MuiCard: {
root: {
// When cards have a forced size, such as when they are arranged in a
// CSS grid, the content needs to flex such that the actions (buttons
// etc) end up at the bottom of the card instead of just below the body
// contents.
display: 'flex',
flexDirection: 'column',
},
},
MuiCardHeader: {
root: {
// Reduce padding between header and content
paddingBottom: 0,
},
},
MuiCardContent: {
root: {
// When cards have a forced size, such as when they are arranged in a
// CSS grid, the content needs to flex such that the actions (buttons
// etc) end up at the bottom of the card instead of just below the body
// contents.
flexGrow: 1,
},
},
MuiCardActions: {
root: {
// We default to putting the card actions at the end
+1 -1
View File
@@ -56,7 +56,7 @@
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/node": "^14.14.32",
"@types/swagger-ui-react": "^3.23.3",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
@@ -17,7 +17,7 @@
// @ts-check
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('signing_keys', table => {
@@ -39,7 +39,7 @@ exports.up = async function up(knex) {
};
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('auth_keystore');
+1 -1
View File
@@ -48,7 +48,7 @@
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jwt-decode": "^3.1.0",
"knex": "^0.21.6",
"knex": "^0.95.1",
"luxon": "^1.25.0",
"morgan": "^1.10.0",
"node-cache": "^5.1.2",
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import Knex from 'knex';
import { resolvePackagePath } from '@backstage/backend-common';
import { AnyJWK, KeyStore, StoredKey } from './types';
import { Knex } from 'knex';
import { DateTime } from 'luxon';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
@@ -17,7 +17,7 @@
// @ts-check
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
return (
@@ -120,7 +120,7 @@ exports.up = async function up(knex) {
};
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
return knex.schema
@@ -17,7 +17,7 @@
// @ts-check
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('location_update_log', table => {
@@ -36,7 +36,7 @@ exports.up = async function up(knex) {
};
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTableIfExists('location_update_log');
@@ -17,7 +17,7 @@
// @ts-check
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// Get list sorted by created_at timestamp in descending order
@@ -25,7 +25,7 @@ exports.up = async function up(knex) {
return knex.schema.raw(`
CREATE VIEW location_update_log_latest AS
SELECT t1.* FROM location_update_log t1
JOIN
JOIN
(
SELECT location_id, MAX(created_at) AS MAXDATE
FROM location_update_log
@@ -38,7 +38,7 @@ exports.up = async function up(knex) {
};
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
@@ -17,7 +17,7 @@
// @ts-check
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// SQLite does not support FK and PK
@@ -126,7 +126,7 @@ exports.up = async function up(knex) {
};
/**
* @param {import('knex')} knex
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
// SQLite does not support FK and PK
@@ -13,11 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = function up(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`).raw(`
CREATE VIEW location_update_log_latest AS
SELECT t1.* FROM location_update_log t1
JOIN
JOIN
(
SELECT location_id, MAX(created_at) AS MAXDATE
FROM location_update_log
@@ -30,6 +36,9 @@ exports.up = function up(knex) {
`);
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = function down(knex) {
knex.schema.raw(`DROP VIEW location_update_log_latest;`);
};

Some files were not shown because too many files have changed in this diff Show More