merge master into samiram/add-emptyState
This commit is contained in:
@@ -34,7 +34,6 @@
|
||||
"@backstage/plugin-gitops-profiles",
|
||||
"@backstage/plugin-graphiql",
|
||||
"@backstage/plugin-graphql-backend",
|
||||
"@backstage/plugin-identity-backend",
|
||||
"@backstage/plugin-jenkins",
|
||||
"@backstage/plugin-lighthouse",
|
||||
"@backstage/plugin-newrelic",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': minor
|
||||
---
|
||||
|
||||
The default mount point for backend plugins have been changed to /api. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern.
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
* @spotify/backstage-core
|
||||
/docs/features/techdocs @spotify/techdocs-core
|
||||
/plugins/cost-insights @spotify/silver-lining
|
||||
/plugins/techdocs @spotify/techdocs-core
|
||||
/plugins/techdocs-backend @spotify/techdocs-core
|
||||
/packages/techdocs-cli @spotify/techdocs-core
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
if: steps.cache-modules.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
path: '**/node_modules'
|
||||
# We use both yarn.lock and package.json as cache keys to ensure that
|
||||
# changes to local monorepo packages bust the cache.
|
||||
key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
|
||||
# If we get a cache hit for node_modules, there's no need to bring in the global
|
||||
# yarn cache or run yarn install, as all dependencies will be installed already.
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -31,6 +31,8 @@ jobs:
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Add msbuild to PATH
|
||||
uses: microsoft/setup-msbuild@v1.0.1
|
||||
- name: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
if: steps.cache-modules.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }}
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
if: steps.cache-modules.outputs.cache-hit != 'true'
|
||||
@@ -69,12 +69,14 @@ jobs:
|
||||
|
||||
# Publishes current version of packages that are not already present in the registry
|
||||
- name: publish
|
||||
if: matrix.node-version == '12.x'
|
||||
run: yarn lerna -- publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# Tags the commit with the version in the core package if the tag doesn't exist
|
||||
- uses: Klemensas/action-autotag@1.2.3
|
||||
if: matrix.node-version == '12.x'
|
||||
with:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
package_root: 'packages/core'
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [12.x, 14.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
+23
-3
@@ -8,13 +8,33 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re
|
||||
|
||||
> Collect changes for the next release below
|
||||
|
||||
### @backstage/core
|
||||
### Backend (example-backend, or backends created with @backstage/create-app)
|
||||
|
||||
- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555).
|
||||
- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/spotify/backstage/pull/2600)
|
||||
|
||||
### @backstage/auth-backend
|
||||
|
||||
- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. The default base path is `/api/auth`, but you need to set this to `/auth` if you want to keep using the old path. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- The auth backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
### @backstage/proxy-backend
|
||||
|
||||
- The proxy backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
### @backstage/techdocs-backend
|
||||
|
||||
- The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
### @backstage/plugin-identity-backend
|
||||
|
||||
- This plugin was removed, remove it from your backend if it's there. [#2616](https://github.com/spotify/backstage/pull/2616)
|
||||
|
||||
## v0.1.1-alpha.23
|
||||
|
||||
### @backstage/core
|
||||
|
||||
- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555).
|
||||
|
||||
## v0.1.1-alpha.22
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ What kind of plugins should/could be created? Some inspiration from the 120+ plu
|
||||
|
||||
## Suggesting a plugin
|
||||
|
||||
If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development.
|
||||
If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development.
|
||||
|
||||
You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work.
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ backend:
|
||||
origin: http://localhost:3000
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
credentials: true
|
||||
csp:
|
||||
connect-src: ["'self'", 'http:', 'https:']
|
||||
|
||||
+37
-3
@@ -9,14 +9,13 @@ backend:
|
||||
database:
|
||||
client: sqlite3
|
||||
connection: ':memory:'
|
||||
csp:
|
||||
connect-src: ["'self'", 'https:']
|
||||
|
||||
# See README.md in the proxy-backend plugin for information on the configuration format
|
||||
proxy:
|
||||
'/circleci/api':
|
||||
target: https://circleci.com/api/v1.1
|
||||
changeOrigin: true
|
||||
pathRewrite:
|
||||
'^/proxy/circleci/api/': '/'
|
||||
headers:
|
||||
Circle-Token:
|
||||
$secret:
|
||||
@@ -29,6 +28,15 @@ proxy:
|
||||
$secret:
|
||||
env: JENKINS_BASIC_AUTH_HEADER
|
||||
|
||||
'/travisci/api':
|
||||
target: https://api.travis-ci.com
|
||||
changeOrigin: true
|
||||
headers:
|
||||
Authorization:
|
||||
$secret:
|
||||
env: TRAVISCI_AUTH_TOKEN
|
||||
travis-api-version: 3
|
||||
|
||||
organization:
|
||||
name: Spotify
|
||||
|
||||
@@ -130,6 +138,12 @@ scaffolder:
|
||||
token:
|
||||
$secret:
|
||||
env: GITLAB_ACCESS_TOKEN
|
||||
azure:
|
||||
baseUrl: https://dev.azure.com/{your-organization}
|
||||
api:
|
||||
token:
|
||||
$secret:
|
||||
env: AZURE_PRIVATE_TOKEN
|
||||
|
||||
auth:
|
||||
providers:
|
||||
@@ -213,3 +227,23 @@ auth:
|
||||
tenantId:
|
||||
$secret:
|
||||
env: AUTH_MICROSOFT_TENANT_ID
|
||||
costInsights:
|
||||
engineerCost: 200000
|
||||
products:
|
||||
computeEngine:
|
||||
name: Compute Engine
|
||||
icon: compute
|
||||
cloudDataflow:
|
||||
name: Cloud Dataflow
|
||||
icon: data
|
||||
cloudStorage:
|
||||
name: Cloud Storage
|
||||
icon: storage
|
||||
bigQuery:
|
||||
name: Big Query
|
||||
icon: search
|
||||
metrics:
|
||||
dailyCost:
|
||||
name: Your Company's Daily Cost
|
||||
DAU:
|
||||
name: Cost Per DAU
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12 AS build
|
||||
FROM node:12-buster AS build
|
||||
|
||||
RUN mkdir /app
|
||||
COPY . /app
|
||||
|
||||
+5
-6
@@ -86,7 +86,7 @@ well-known tech and a large flora of components.
|
||||
|
||||
### What technology does Backstage use?
|
||||
|
||||
The code base is a large-scale React application that uses TypeScript. For
|
||||
The codebase is a large-scale React application that uses TypeScript. For
|
||||
[Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use
|
||||
Node.js and GraphQL.
|
||||
|
||||
@@ -142,10 +142,9 @@ can browse and search for all available plugins.
|
||||
By far, our most-used plugin is our TechDocs plugin, which we use for creating
|
||||
technical documentation. Our philosophy at Spotify is to treat "docs like code",
|
||||
where you write documentation using the same workflow as you write your code.
|
||||
This makes it easier to create, find, and update documentation. We hope to
|
||||
release
|
||||
[the open source version](https://github.com/spotify/backstage/issues/687) in
|
||||
the future. (See also:
|
||||
This makes it easier to create, find, and update documentation.
|
||||
[TechDocs is now open source.](https://backstage.io/docs/features/techdocs/techdocs-overview)
|
||||
(See also:
|
||||
"[Will Spotify's internal plugins be open sourced, too?](#will-spotifys-internal-plugins-be-open-sourced-too)"
|
||||
above)
|
||||
|
||||
@@ -205,7 +204,7 @@ Please report sensitive security issues via Spotify's
|
||||
### Does Backstage collect any information that is shared with Spotify?
|
||||
|
||||
No. Backstage does not collect any telemetry from any third party using the
|
||||
platform. Spotify, and the open source community, does have access to
|
||||
platform. Spotify, and the open source community, do have access to
|
||||
[GitHub Insights](https://github.com/features/insights), which contains
|
||||
information such as contributors, commits, traffic, and dependencies. Backstage
|
||||
is an open platform, but you are in control of your own data. You control who
|
||||
|
||||
@@ -97,7 +97,7 @@ app, and the app itself.
|
||||
|
||||
### Core APIs
|
||||
|
||||
Starting with the Backstage core library, it provides implementation for all of
|
||||
Starting with the Backstage core library, it provides implementations for all of
|
||||
the core APIs. The core APIs are the ones exported by `@backstage/core`, such as
|
||||
the `errorApiRef` and `configApiRef`. You can find a full list of them
|
||||
[here](../reference/utility-apis/README.md).
|
||||
@@ -113,7 +113,7 @@ While doing so they should usually also provide default implementations of their
|
||||
own APIs, for example, the `catalog` plugin exports `catalogApiRef`, and also
|
||||
supplies a default `ApiFactory` of that API using the `CatalogClient`. There is
|
||||
one restriction to plugin-provided API Factories: plugins may not supply
|
||||
factories for core APIs, trying to do so will cause the app to crash.
|
||||
factories for core APIs, trying to do so will cause the app to refuse to start.
|
||||
|
||||
Plugins supply their APIs through the `apis` option of `createPlugin`, for
|
||||
example:
|
||||
@@ -248,8 +248,8 @@ directly tied to React.
|
||||
The indirection provided by Utility APIs also makes it straightforward to test
|
||||
components that depend on APIs, and to provide a standard common development
|
||||
environment for plugins. A proper test wrapper with mocked API implementations
|
||||
is not yet ready, but it will provided as a part of `@backstage/test-utils`. It
|
||||
will provide mocked variants of APIs, with additional methods for asserting a
|
||||
is not yet ready, but it will be provided as a part of `@backstage/test-utils`.
|
||||
It will provide mocked variants of APIs, with additional methods for asserting a
|
||||
component's interaction with the API.
|
||||
|
||||
The common development environment for plugins is included in
|
||||
|
||||
@@ -22,7 +22,7 @@ This ADR describes the default format of these descriptor files.
|
||||
|
||||
### Inspiration
|
||||
|
||||
Internally at Spotify, a home grown software catalog system is used heavily and
|
||||
Internally at Spotify, a homegrown software catalog system is used heavily and
|
||||
forms a core part of Backstage and other important pieces of the infrastructure.
|
||||
The user experience, learnings and certain pieces of metadata from that catalog
|
||||
are being carried over to the open source effort.
|
||||
@@ -40,7 +40,7 @@ triggers from the version control system, etc.
|
||||
|
||||
Each file describes one or more entities in accordance with the
|
||||
[Backstage System Model](https://github.com/spotify/backstage/issues/390). All
|
||||
of these entities have a common stucture and nomenclature, and they are stored
|
||||
of these entities have a common structure and nomenclature, and they are stored
|
||||
in the software catalog from which they then can be queried.
|
||||
|
||||
Entities have distinct names, and they may reference each other by those names.
|
||||
|
||||
@@ -44,7 +44,7 @@ A summary:
|
||||
name each.
|
||||
|
||||
Using named exports helps prevent needing to rename symbols, which has myriad
|
||||
benefts. A few are:
|
||||
benefits. A few are:
|
||||
|
||||
- IDE tools like "Find All References" and "Go To Definition" function
|
||||
- Manual codebase searching ("grep", etc) is easier with a unique symbol
|
||||
|
||||
@@ -22,8 +22,8 @@ or
|
||||
We currently do not use any pattern for how to structure exports. There is a mix
|
||||
of package-level re-exports deep into the directory tree, shallow re-exports for
|
||||
each directory, exports using `*` and explicit lists of each symbol, etc. The
|
||||
mix and lack of predictability makes it difficult to reason about the boundaries
|
||||
of a module, and for example knowing whether is is safe to export a symbol in a
|
||||
mix and lack of predictability make it difficult to reason about the boundaries
|
||||
of a module, and for example knowing whether it is safe to export a symbol in a
|
||||
given file.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -13,7 +13,7 @@ with next to no benefits in combination with a few downsides.
|
||||
The main reasons were:
|
||||
|
||||
- **children props** were implicitly added
|
||||
- **Generic Type** were not supported on children
|
||||
- **Generic Type** was not supported on children
|
||||
|
||||
Read more about the removal in
|
||||
[this PR](https://github.com/facebook/create-react-app/pull/8177).
|
||||
@@ -25,7 +25,7 @@ should be avoided in our codebase when adding new code.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
/* Avoid this: */
|
||||
type BadProps = { text: string };
|
||||
const BadComponent: FC<BadProps> = ({ text, children }) => (
|
||||
|
||||
@@ -11,7 +11,7 @@ tests, unit tests to e2e tests always have their own implementation of mocking
|
||||
these requests. There's been traction in the outer community towards using this
|
||||
library to mock network requests by using an express style declaration for
|
||||
routes. react-testing-library suggests using this library instead of mocking
|
||||
fetch directly wether this be in a browser or in node.
|
||||
fetch directly whether this be in a browser or in node.
|
||||
|
||||
https://github.com/mswjs/msw
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ description: Documentation on User Authentication and Authorization in Backstage
|
||||
|
||||
## Summary
|
||||
|
||||
The purpose of the Auth APIs in Backstage are to identify the user, and to
|
||||
The purpose of the Auth APIs in Backstage is to identify the user, and to
|
||||
provide a way for plugins to request access to 3rd party services on behalf of
|
||||
the user (OAuth). This documentation focuses on the implementation of that
|
||||
solution and how to extend it. For documentation on how to consume the Auth APIs
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ in a new popup window that is opened by the app. By using a popup-based flow it
|
||||
is possible to request authentication at any point in the app, without requiring
|
||||
a redirect. Because of this there is no need to ask for all scopes upfront, or
|
||||
interrupt the app with a redirect and forcing plugin authors to take care in
|
||||
restoring state after a redirect has been make. All in all it makes it much
|
||||
restoring state after a redirect has been made. All in all it makes it much
|
||||
easier to make authenticated requests inside a plugin.
|
||||
|
||||
## OAuth Flow
|
||||
@@ -67,7 +67,7 @@ Component and APIs can request Access or ID Tokens from any available Auth
|
||||
provider. If there already exists a cached fresh token that covers (at least)
|
||||
the requested scopes, it will be returned immediately. If the OAuth provider
|
||||
implements token refreshes, this check will also trigger a token refresh attempt
|
||||
if no session is a available.
|
||||
if no session is available.
|
||||
|
||||
If new scopes are requested, or the user is not yet logged in with that
|
||||
provider, a dialog is shown informing the user that they need to log in with the
|
||||
|
||||
@@ -18,7 +18,7 @@ Backstage natively supports tracking of the following component
|
||||
Since these types are likely not the only kind of software you will want to
|
||||
track in Backstage, it is possible to
|
||||
|
||||
It is possible to add your own software types that fits your organization's data
|
||||
It is possible to add your own software types that fit your organization's data
|
||||
model. Inside Spotify our model has grown significantly over the years, and now
|
||||
includes ML models, Apps, data pipelines and many more.
|
||||
|
||||
@@ -30,7 +30,7 @@ catalog.
|
||||
## The Other type
|
||||
|
||||
It might be tempting to put software that doesn't fit into any of the existing
|
||||
types into Other. There are a few reasons why we advice against this; firstly,
|
||||
types into Other. There are a few reasons why we advise against this; firstly,
|
||||
we have found that it is preferred to match the conceptual model that your
|
||||
engineers have when describing your software. Secondly, Backstage helps your
|
||||
engineers manage their software by integrating the infrastructure tooling
|
||||
|
||||
@@ -84,7 +84,7 @@ registered in the catalog.
|
||||
### Static catalog configuration
|
||||
|
||||
In addition to manually registering components, it is also possible to register
|
||||
components though [static configuration](../../conf/index.md). For example, the
|
||||
components through [static configuration](../../conf/index.md). For example, the
|
||||
above example can be added using the following configuration:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -67,7 +67,7 @@ be left out in its own yaml file because that's the default value there too),
|
||||
and name `pet-managers`.
|
||||
|
||||
The entries in `implementsApis` are also references. In this case, none of them
|
||||
need to specify a kind since we know from the context that that's the only kind
|
||||
needs to specify a kind since we know from the context that that's the only kind
|
||||
that's supported here. The second entry specifies a namespace but the other ones
|
||||
don't, and in this context, the default is to refer to the same namespace as the
|
||||
originating entity (`external-systems` here). So the three references
|
||||
@@ -79,7 +79,7 @@ there to exist three API kind entities in the catalog matching those references.
|
||||
|
||||
This is a more verbose version of a reference, where each part of the
|
||||
kind-namespace-name triplet is expressed as a field in a structure. This format
|
||||
can be used where necessary, such as if either of the three elements contain
|
||||
can be used where necessary, such as if either of the three elements contains
|
||||
colons or forward slashes. Avoid using it where possible, since it is harder to
|
||||
read and write for humans.
|
||||
|
||||
|
||||
@@ -41,6 +41,27 @@ expecting a two-item array out of it. The format of the target part is
|
||||
type-dependent and could conceivably even be an empty string, but the separator
|
||||
colon is always present.
|
||||
|
||||
### backstage.io/definition-at-location
|
||||
|
||||
```yaml
|
||||
# Example
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: petstore
|
||||
annotations:
|
||||
backstage.io/definition-at-location: 'url:https://petstore.swagger.io/v2/swagger.json'
|
||||
spec:
|
||||
type: openapi
|
||||
```
|
||||
|
||||
This annotation allows to fetch an API definition from another location, instead
|
||||
of wrapping the API definition inside the definition field. This allows to
|
||||
easily consume existing API definition. The definition is fetched during
|
||||
ingestion by a processor and included in the entity. It is updated on every
|
||||
refresh. The annotation contains a location reference string that contains the
|
||||
location processor type and the target.
|
||||
|
||||
### backstage.io/techdocs-ref
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Documentation on Adding your own Templates
|
||||
---
|
||||
|
||||
Templates are stored in the **Service Catalog** under a kind `Template`. The
|
||||
minimum that the a template skeleton needs is a `template.yaml` but it would be
|
||||
minimum that the template skeleton needs is a `template.yaml` but it would be
|
||||
good to also have some files in there that can be templated in.
|
||||
|
||||
A simple `template.yaml` definition might look something like this:
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
templater: cookiecutter
|
||||
# what does this template create
|
||||
type: website
|
||||
# if the template is not in the current directory where this definition is kept then specfiy
|
||||
# if the template is not in the current directory where this definition is kept then specify
|
||||
path: './template'
|
||||
# the schema for the form which is displayed in the frontend.
|
||||
# should follow JSON schema for forms: https://jsonforms.io/
|
||||
@@ -107,7 +107,7 @@ This should then have added the catalog, and also should now be listed under the
|
||||
create page at http://localhost:3000/create.
|
||||
|
||||
The `type` field which is chosen in the request to add the `template.yaml` to
|
||||
the Service Catalog here, will be come the `PreparerKey` which will be used to
|
||||
the Service Catalog here, will become the `PreparerKey` which will be used to
|
||||
select the `Preparer` when creating a job.
|
||||
|
||||
### Adding form values in the Scaffolder Wizard
|
||||
|
||||
@@ -17,7 +17,7 @@ location protocols:
|
||||
These two are added to the `PreparersBuilder` and then passed into the
|
||||
`createRouter` function of the `@spotify/plugin-scaffolder-backend`
|
||||
|
||||
An full example backend can be found
|
||||
A full example backend can be found
|
||||
[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts),
|
||||
but it looks something like the following
|
||||
|
||||
@@ -98,4 +98,4 @@ const preparers = new Preparers();
|
||||
preparers.register('gcs', new GoogleCloudStoragePreparer());
|
||||
```
|
||||
|
||||
And then pass this in to the `createRouter` function.
|
||||
And then pass this into the `createRouter` function.
|
||||
|
||||
@@ -8,10 +8,10 @@ Publishers are responsible for pushing and storing the templated skeleton after
|
||||
the values have been templated by the `Templater`. See
|
||||
[Create your own templater](./create-your-own-templater.md) for more info.
|
||||
|
||||
They receive a directory or location where the templater has sucessfully run and
|
||||
is now ready to store somewhere. They also are given some other options which
|
||||
are sent from the frontend, such as the `storePath` which is a string of where
|
||||
the frontend thinks we should save this templated folder.
|
||||
They receive a directory or location where the templater has successfully run
|
||||
and is now ready to store somewhere. They also are given some other options
|
||||
which are sent from the frontend, such as the `storePath` which is a string of
|
||||
where the frontend thinks we should save this templated folder.
|
||||
|
||||
Currently we provide the following `publishers`:
|
||||
|
||||
|
||||
@@ -147,4 +147,4 @@ const templaters = new Templaters();
|
||||
templaters.register('handlebars', new HandlebarsTemplater());
|
||||
```
|
||||
|
||||
And then pass this in to the `createRouter` function.
|
||||
And then pass this into the `createRouter` function.
|
||||
|
||||
@@ -11,13 +11,13 @@ You're at the right place.
|
||||
This guide is going to take you through how the Scaffolder in Backstage works.
|
||||
We'll dive into some jargon and run through what's going on in the backend to be
|
||||
able to create these templates. There's also more guides that you might find
|
||||
useful at the bottom of this document. At it's core, theres 3 simple stages.
|
||||
useful at the bottom of this document. At its core, there are 3 simple stages.
|
||||
|
||||
1. Pick a skeleton
|
||||
2. Template some variables into the skeleton
|
||||
3. Send the templated skeleton somewhere
|
||||
|
||||
These three steps are translated to the folllowing stages under the hood in the
|
||||
These three steps are translated to the following stages under the hood in the
|
||||
scaffolder that you will need to know:
|
||||
|
||||
1. Prepare
|
||||
@@ -38,7 +38,7 @@ the router to pick the correct `Preparer` to run for the `Template` entity.
|
||||
|
||||
**Templater** - The templater is responsible for actually running the chosen
|
||||
templater on top of the previously returned temporary directory from the
|
||||
**Preprarer**. We advise making these Docker containers as it can keep all
|
||||
**Preparer**. We advise making these Docker containers as it can keep all
|
||||
dependencies--for example Cookiecutter--self contained and not a dependency on
|
||||
the host machine.
|
||||
|
||||
@@ -82,7 +82,7 @@ Once that has been posted, a job will be setup with different stages, and the
|
||||
job processor will complete each stage before moving onto the next stage, whilst
|
||||
collecting logs and mutating the running job.
|
||||
|
||||
Here's some futher reading that you might find useful:
|
||||
Here's some further reading that you might find useful:
|
||||
|
||||
- [Adding your own Template](../adding-templates.md)
|
||||
- [Creating your own Templater](./create-your-own-templater.md)
|
||||
|
||||
@@ -217,6 +217,11 @@ The Github access token is retrieved from environment variables via the config.
|
||||
The config file needs to specify what environment variable the token is
|
||||
retrieved from. Your config should have the following objects.
|
||||
|
||||
You can configure who can see the new repositories that the scaffolder creates
|
||||
by specifying `visibility` option. Valid options are `public`, `private` and
|
||||
`internal`. `internal` options is for GitHub Enterprise clients, which means
|
||||
public within the organization.
|
||||
|
||||
#### Gitlab
|
||||
|
||||
For Gitlab, we currently support the configuration of the GitLab publisher and
|
||||
@@ -238,10 +243,23 @@ scaffolder:
|
||||
env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN
|
||||
```
|
||||
|
||||
You can configure who can see the new repositories that the scaffolder creates
|
||||
by specifying `visibility` option. Valid options are `public`, `private` and
|
||||
`internal`. `internal` options is for GitHub Enterprise clients, which means
|
||||
public within the organization.
|
||||
#### Azure DevOps
|
||||
|
||||
For Azure DevOps we support both the preparer and publisher stage with the
|
||||
configuration of a private access token (PAT). For the publisher it's also
|
||||
required to define the base URL for the client to connect to the service. This
|
||||
will hopefully support on-prem installations as well but that has not been
|
||||
verified.
|
||||
|
||||
```yaml
|
||||
scaffolder:
|
||||
azure:
|
||||
baseUrl: https://dev.azure.com/{your-organization}
|
||||
api:
|
||||
token:
|
||||
$secret:
|
||||
env: AZURE_PRIVATE_TOKEN
|
||||
```
|
||||
|
||||
### Running the Backend
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ If you haven't setup Backstage already, start
|
||||
## Installing TechDocs
|
||||
|
||||
TechDocs is provided with the Backstage application by default. If you want to
|
||||
set up TechDocs manually, keep follow the instructions below.
|
||||
set up TechDocs manually, keep following the instructions below.
|
||||
|
||||
### Adding the package
|
||||
|
||||
|
||||
+24
-20
@@ -27,10 +27,11 @@ We have divided the project into three high-level _phases_:
|
||||
With a single catalog, Backstage makes it easy for a team to manage ten
|
||||
services — and makes it possible for your company to manage thousands of them.
|
||||
|
||||
- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is
|
||||
different. By fostering a vibrant community of contributors we hope to provide
|
||||
an ecosystem of Open Source plugins/integrations that allows you to pick the
|
||||
tools that match your stack.
|
||||
- 🐇 **Phase 3:** Ecosystem (ongoing, see
|
||||
[Plugin Marketplace](https://backstage.io/plugins)) - Everyone's
|
||||
infrastructure stack is different. By fostering a vibrant community of
|
||||
contributors we hope to provide an ecosystem of Open Source
|
||||
plugins/integrations that allows you to pick the tools that match your stack.
|
||||
|
||||
## Detailed roadmap
|
||||
|
||||
@@ -53,24 +54,24 @@ guidelines to get started.
|
||||
it much easier to see how a plugin can be built that integrates with the
|
||||
Backstage Service Catalog.
|
||||
|
||||
- **[Kubernetes support](https://github.com/spotify/backstage/milestone/20)** -
|
||||
Native support for Kubernetes, making it easier for developers to see and
|
||||
manage their services running in k8s.
|
||||
|
||||
- **[Helm charts](https://github.com/spotify/backstage/issues/2540)** - Provide
|
||||
Helm charts for easy deployments of Backstage and its subsystems on
|
||||
Kubernetes.
|
||||
|
||||
- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** -
|
||||
The platform APIs and features are stable and can be depended on for
|
||||
production use. After this plugins will require little to no maintenance.
|
||||
|
||||
- **Backstage Design System** - By providing design guidelines for common plugin
|
||||
layouts together, rich set of reusable UI components
|
||||
([Storybook](https://backstage.io/storybook)) and Figma design resources. The
|
||||
Design System will make it easy to design and build plugins that are
|
||||
consistent across the platform -- supporting both developers and designers.
|
||||
|
||||
- **[TechDocs v1](https://github.com/spotify/backstage/milestone/16)** - Our
|
||||
docs-like-code feature TechDocs working end to end.
|
||||
|
||||
- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** -
|
||||
A GraphQL API will open up the rich metadata provided by Backstage in a single
|
||||
query. Plugins can easily query this API as well as extend the model where
|
||||
needed.
|
||||
|
||||
- **Production deployments** - Provide instructions and default configurations
|
||||
(e.g. through Helm charts) for easy deployments of Backstage and its
|
||||
subsystems on Kubernetes.
|
||||
|
||||
- **Cloud Cost Insights plugin (from Spotify)** - Spotify teams are fully
|
||||
responsible for their own software, including the cost of the cloud resources
|
||||
they use. By making our internal cost insights plugin available as open source
|
||||
@@ -96,10 +97,6 @@ Chances are that someone will jump in and help build it.
|
||||
|
||||
### Future work 🔮
|
||||
|
||||
- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** -
|
||||
The platform APIs and features are stable and can be depended on for
|
||||
production use. After this plugins will require little to no maintenance.
|
||||
|
||||
- **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage
|
||||
deployment available publicly so that people can click around and get a feel
|
||||
for the product without having to install anything.
|
||||
@@ -118,8 +115,15 @@ Chances are that someone will jump in and help build it.
|
||||
[AWS](https://github.com/spotify/backstage/issues/290),
|
||||
[Azure](https://github.com/spotify/backstage/issues/348) and others.
|
||||
|
||||
- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** -
|
||||
A GraphQL API will open up the rich metadata provided by Backstage in a single
|
||||
query. Plugins can easily query this API as well as extend the model where
|
||||
needed.
|
||||
|
||||
### Completed milestones ✅
|
||||
|
||||
- [Donate Backstage to the CNCF 🎉](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox)
|
||||
- [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs)
|
||||
- [Plugin marketplace](https://backstage.io/plugins)
|
||||
- [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage)
|
||||
- [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)
|
||||
|
||||
@@ -9,7 +9,7 @@ Backstage is a single-page application composed of a set of plugins.
|
||||
Our goal for the plugin ecosystem is that the definition of a plugin is flexible
|
||||
enough to allow you to expose pretty much any kind of infrastructure or software
|
||||
development tool as a plugin in Backstage. By following strong
|
||||
[design guidelines](../dls/design.md) we ensure the the overall user experience
|
||||
[design guidelines](../dls/design.md) we ensure the overall user experience
|
||||
stays consistent between plugins.
|
||||
|
||||

|
||||
@@ -21,7 +21,7 @@ To create a plugin, follow the steps outlined [here](create-a-plugin.md).
|
||||
## Suggesting a plugin
|
||||
|
||||
If you start developing a plugin that you aim to release as open source, we
|
||||
suggest that you create a new
|
||||
suggest that you create a
|
||||
[new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME).
|
||||
This helps the community know what plugins are in development.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
|
||||
const service = createServiceBuilder(module)
|
||||
.loadConfig(configReader)
|
||||
/** ... other routers ... */
|
||||
.addRouter('/proxy', await proxy(proxyEnv, '/proxy'));
|
||||
.addRouter('/proxy', await proxy(proxyEnv));
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -49,7 +49,7 @@ Each key under the proxy configuration entry is a route to match, below the
|
||||
prefix that the proxy plugin is mounted on. It must start with a slash. For
|
||||
example, if the backend mounts the proxy plugin as `/proxy`, the above
|
||||
configuration will lead to the proxy acting on backend requests to
|
||||
`/proxy/simple-example/...` and `/proxy/larger-example/v1/...`.
|
||||
`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`.
|
||||
|
||||
The value inside each route is either a simple URL string, or an object on the
|
||||
format accepted by
|
||||
@@ -74,6 +74,6 @@ except with the following caveats for convenience:
|
||||
commonly useful value.
|
||||
- If `pathRewrite` is not specified, it is set to a single rewrite that removes
|
||||
the entire prefix and route. In the above example, a rewrite of
|
||||
`'^/proxy/larger-example/v1/': '/'` is added. That means that a request to
|
||||
`/proxy/larger-example/v1/some/path` will be translated to a request to
|
||||
`'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to
|
||||
`/api/proxy/larger-example/v1/some/path` will be translated to a request to
|
||||
`http://larger.example.com:8080/svc.v1/some/path`.
|
||||
|
||||
@@ -21,6 +21,9 @@ out a new branch that you will use for the release, e.g.
|
||||
$ git checkout -b new-release
|
||||
```
|
||||
|
||||
First bump the `CHANGELOG.md` in the root of the repo and commit. You bump it by
|
||||
adding a header for the new version just below the `## Next Release` one.
|
||||
|
||||
Then, from the root of the repo, run
|
||||
|
||||
```sh
|
||||
|
||||
@@ -84,7 +84,7 @@ structure our plugins. There are usually one or multiple page components and
|
||||
next to them you can split up the UI in as many components as you feel like.
|
||||
|
||||
We have the `ExamplePage` to show an example Backstage page component. The
|
||||
`ExampleFetchComponent` show cases the common task of making an async request to
|
||||
`ExampleFetchComponent` showcases the common task of making an async request to
|
||||
a public API and plot the response data in a table using Material-UI components.
|
||||
|
||||
You may tweak these components, rename them and/or replace them completely.
|
||||
|
||||
@@ -32,7 +32,7 @@ working on.
|
||||
|
||||
## Naming Test Files
|
||||
|
||||
Tests should be name `[filename].test.js`.
|
||||
Tests should be named `[filename].test.js`.
|
||||
|
||||
For example, the tests for **`Link.js`** exist in the file **`Link.test.js`**.
|
||||
|
||||
@@ -54,7 +54,7 @@ TODO.
|
||||
|
||||
# Writing Unit Tests
|
||||
|
||||
The following principles are good guides to determining if you are writing high
|
||||
The following principles are good guides for determining if you are writing high
|
||||
quality frontend unit tests.
|
||||
|
||||
## Bad Unit Test Principle
|
||||
@@ -170,7 +170,7 @@ input changes
|
||||
component is mounted, just that it is mounted in response to the input.
|
||||
|
||||
✓ **Fulfills Scalability Principle**: If we decide to refactor the entire way
|
||||
the loading indicator is displayed the test still works without touching it.
|
||||
the loading indicator has displayed the test still works without touching it.
|
||||
|
||||
✓ **Fulfills Broken Functionality Principle**: this test verifies the
|
||||
functionality (displaying an indicator) is working, rather than how it is
|
||||
|
||||
@@ -134,7 +134,7 @@ work.
|
||||
# 5. The First User
|
||||
|
||||
At this point the only things that anyone that wants to use Sam's plugin needs
|
||||
to do is to add
|
||||
to do are to add
|
||||
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
|
||||
to their catalog schema, import and add the `PlayerWidget` on the desired entity
|
||||
template pages, and make sure they're providing Spotify auth.
|
||||
@@ -145,7 +145,7 @@ select a theme tune when creating a new component. Sam jumps on the idea and
|
||||
adds a new creation hook that is exported by the plugin. The hook can be
|
||||
installed either in a single, all, or component templates that match a label. It
|
||||
adds a field as a part of the component creation process with a nice search box
|
||||
that allow users to search for a track that they want to use as the theme tune.
|
||||
that allows users to search for a track that they want to use as the theme tune.
|
||||
|
||||
# 6. Return to the Repo
|
||||
|
||||
@@ -203,7 +203,7 @@ backend plugin also extends the common GraphQL schema with a mutation that
|
||||
updates the track ID in the database.
|
||||
|
||||
On the frontend the Pull Request doesn't change much. It defines the save action
|
||||
the was previously using the `RepoApi` in it's own API.
|
||||
the was previously using the `RepoApi` in its own API.
|
||||
|
||||
```ts
|
||||
type ThemeTuneStorageApi = {
|
||||
@@ -247,7 +247,7 @@ once an internal concern of the plugin is now becoming a standard in the
|
||||
community.
|
||||
|
||||
In order to standardize the annotation in Backstage, Sam submits a Pull Request
|
||||
to the Backstage Core repo. The request suggest a new well-known metadata
|
||||
to the Backstage Core repo. The request suggests a new well-known metadata
|
||||
annotation called `spotify.com/track-id`, with the same schema definition as
|
||||
Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin
|
||||
as existing usages. The Backstage maintainers merge the Pull Request, after
|
||||
|
||||
@@ -155,39 +155,7 @@ const app = createApp({
|
||||
});
|
||||
```
|
||||
|
||||
6. Open and change _root > packages > app > src >_ `apis.ts` as follows
|
||||
|
||||
```ts
|
||||
// Add the following imports to the existing list from core
|
||||
import { githubAuthApiRef, GithubAuth } from '@backstage/core';
|
||||
```
|
||||
|
||||
7. In the same file, change the builder block for oauthRequestApiRef as follows
|
||||
|
||||
_from:_
|
||||
|
||||
```ts
|
||||
builder.add(oauthRequestApiRef, new OAuthRequestManager());
|
||||
```
|
||||
|
||||
_to:_
|
||||
|
||||
```ts
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
8. Start the backend and frontend as before
|
||||
7. Start the backend and frontend as before
|
||||
|
||||
When the browser loads, you should be presented with a login page for GitHub.
|
||||
Login as usual with your GitHub account. If this is your first time, you will be
|
||||
|
||||
@@ -210,7 +210,7 @@ type Viewer = {
|
||||
};
|
||||
```
|
||||
|
||||
# The Tabel Model
|
||||
# The Table Model
|
||||
|
||||
Using Backstage's own component library, let's define a custom table. This
|
||||
component will get used if we have data to display.
|
||||
@@ -312,6 +312,6 @@ return (
|
||||
> Break apart ExampleFetchComponent into smaller logical parts contained in
|
||||
> their own files. Rename your components to something other than ExampleXxx.
|
||||
>
|
||||
> You might be real proud of a plugin you develop. Follow this next tutorial for
|
||||
> an in-depth look at publishing and including that for the entire Backstage
|
||||
> You might be really proud of a plugin you develop. Follow this next tutorial
|
||||
> for an in-depth look at publishing and including that for the entire Backstage
|
||||
> community. [TODO](#).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Cost Insights
|
||||
author: Spotify
|
||||
authorUrl: https://github.com/spotify
|
||||
category: Discovery
|
||||
description: Visualize, understand and optimize your team's cloud costs.
|
||||
documentation: https://github.com/spotify/backstage/tree/master/plugins/cost-insights
|
||||
iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png
|
||||
npmPackageName: '@backstage/plugin-cost-insights'
|
||||
tags:
|
||||
- web
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "12"
|
||||
"node": "12 || 14"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"yarn start\" \"yarn start-backend\"",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@backstage/plugin-api-docs": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-circleci": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-cost-insights": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-explore": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-gcp-projects": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-github-actions": "^0.1.1-alpha.23",
|
||||
@@ -32,7 +33,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@octokit/rest": "^18.0.0",
|
||||
"@roadiehq/backstage-plugin-github-pull-requests": "0.3.0",
|
||||
"@roadiehq/backstage-plugin-travis-ci": "^0.1.4",
|
||||
"@roadiehq/backstage-plugin-travis-ci": "^0.2.3",
|
||||
"history": "^5.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
|
||||
@@ -29,11 +29,15 @@ import {
|
||||
TravisCIApi,
|
||||
travisCIApiRef,
|
||||
} from '@roadiehq/backstage-plugin-travis-ci';
|
||||
|
||||
import {
|
||||
GithubPullRequestsClient,
|
||||
githubPullRequestsApiRef,
|
||||
} from '@roadiehq/backstage-plugin-github-pull-requests';
|
||||
|
||||
import { costInsightsApiRef } from '@backstage/plugin-cost-insights';
|
||||
import { ExampleCostInsightsClient } from './plugins/cost-insights';
|
||||
|
||||
export const apis = [
|
||||
createApiFactory({
|
||||
api: graphQlBrowseApiRef,
|
||||
@@ -54,6 +58,8 @@ export const apis = [
|
||||
]),
|
||||
}),
|
||||
|
||||
createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()),
|
||||
|
||||
// TODO: move to plugins
|
||||
createApiFactory(travisCIApiRef, new TravisCIApi()),
|
||||
createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()),
|
||||
|
||||
@@ -23,6 +23,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
|
||||
import MapIcon from '@material-ui/icons/MyLocation';
|
||||
import LibraryBooks from '@material-ui/icons/LibraryBooks';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import MoneyIcon from '@material-ui/icons/MonetizationOn';
|
||||
import LogoFull from './LogoFull';
|
||||
import LogoIcon from './LogoIcon';
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ const Root: FC<{}> = ({ children }) => (
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
|
||||
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarItem
|
||||
icon={graphiQLRouteRef.icon!}
|
||||
to={graphiQLRouteRef.path}
|
||||
|
||||
@@ -14,23 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
Router as GitHubActionsRouter,
|
||||
isPluginApplicableToEntity as isTravisCIAvailable,
|
||||
RecentTravisCIBuildsWidget,
|
||||
Router as TravisCIRouter,
|
||||
} from '@roadiehq/backstage-plugin-travis-ci';
|
||||
import {
|
||||
isPluginApplicableToEntity as isGitHubActionsAvailable,
|
||||
RecentWorkflowRunsCard,
|
||||
Router as GitHubActionsRouter,
|
||||
} from '@backstage/plugin-github-actions';
|
||||
import {
|
||||
Router as JenkinsRouter,
|
||||
isPluginApplicableToEntity as isJenkinsAvailable,
|
||||
LatestRunCard as JenkinsLatestRunCard,
|
||||
Router as JenkinsRouter,
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import {
|
||||
Router as CircleCIRouter,
|
||||
isPluginApplicableToEntity as isCircleCIAvailable,
|
||||
Router as CircleCIRouter,
|
||||
} from '@backstage/plugin-circleci';
|
||||
import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
|
||||
import { Router as SentryRouter } from '@backstage/plugin-sentry';
|
||||
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
|
||||
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
AboutCard,
|
||||
EntityPageLayout,
|
||||
@@ -50,6 +56,8 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => {
|
||||
return <GitHubActionsRouter entity={entity} />;
|
||||
case isCircleCIAvailable(entity):
|
||||
return <CircleCIRouter entity={entity} />;
|
||||
case isTravisCIAvailable(entity):
|
||||
return <TravisCIRouter entity={entity} />;
|
||||
default:
|
||||
return (
|
||||
<WarningPanel title="CI/CD switcher:">
|
||||
@@ -60,16 +68,37 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
|
||||
let content: ReactNode;
|
||||
switch (true) {
|
||||
case isJenkinsAvailable(entity):
|
||||
content = <JenkinsLatestRunCard branch="master" />;
|
||||
break;
|
||||
case isGitHubActionsAvailable(entity):
|
||||
content = <RecentWorkflowRunsCard entity={entity} />;
|
||||
break;
|
||||
case isTravisCIAvailable(entity):
|
||||
content = <RecentTravisCIBuildsWidget entity={entity} />;
|
||||
break;
|
||||
default:
|
||||
content = null;
|
||||
}
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Grid item sm={6}>
|
||||
{content}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const OverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item>
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} />
|
||||
</Grid>
|
||||
{isJenkinsAvailable(entity) && (
|
||||
<Grid item sm={4}>
|
||||
<JenkinsLatestRunCard branch="master" />
|
||||
</Grid>
|
||||
)}
|
||||
<RecentCICDRunsSwitcher entity={entity} />
|
||||
</Grid>
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
gitlabAuthApiRef,
|
||||
oktaAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
samlAuthApiRef,
|
||||
microsoftAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
@@ -53,4 +54,10 @@ export const providers = [
|
||||
message: 'Sign In using Okta',
|
||||
apiRef: oktaAuthApiRef,
|
||||
},
|
||||
{
|
||||
id: 'saml-auth-provider',
|
||||
title: 'SAML',
|
||||
message: 'Sign In using SAML',
|
||||
apiRef: samlAuthApiRef,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,3 +34,4 @@ export { plugin as ApiDocs } from '@backstage/plugin-api-docs';
|
||||
export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests';
|
||||
export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects';
|
||||
export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
|
||||
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable no-restricted-imports */
|
||||
|
||||
import {
|
||||
CostInsightsApi,
|
||||
Alert,
|
||||
Cost,
|
||||
Duration,
|
||||
Project,
|
||||
ProductCost,
|
||||
Group,
|
||||
} from '@backstage/plugin-cost-insights';
|
||||
|
||||
export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
private request(_: any, res: any): Promise<any> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0, res));
|
||||
}
|
||||
|
||||
async getUserGroups(userId: string): Promise<Group[]> {
|
||||
const groups: Group[] = await this.request({ userId }, [
|
||||
{ id: 'pied-piper' },
|
||||
]);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async getGroupProjects(group: string): Promise<Project[]> {
|
||||
const projects: Project[] = await this.request({ group }, [
|
||||
{ id: 'project-a' },
|
||||
{ id: 'project-b' },
|
||||
{ id: 'project-c' },
|
||||
]);
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
async getGroupDailyCost(
|
||||
group: string,
|
||||
metric: string | null,
|
||||
intervals: string,
|
||||
): Promise<Cost> {
|
||||
const groupDailyCost: Cost = await this.request(
|
||||
{ group, metric, intervals },
|
||||
{
|
||||
id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel
|
||||
aggregation: [
|
||||
{ date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) },
|
||||
{ date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) },
|
||||
],
|
||||
change: {
|
||||
ratio: 0.86,
|
||||
amount: 65_000,
|
||||
},
|
||||
trendline: {
|
||||
slope: 0,
|
||||
intercept: 90_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return groupDailyCost;
|
||||
}
|
||||
|
||||
async getProjectDailyCost(
|
||||
project: string,
|
||||
metric: string | null,
|
||||
intervals: string,
|
||||
): Promise<Cost> {
|
||||
const projectDailyCost: Cost = await this.request(
|
||||
{ project, metric, intervals },
|
||||
{
|
||||
id: 'project-a',
|
||||
aggregation: [
|
||||
{ date: '2020-08-01', amount: 1000 },
|
||||
{ date: '2020-08-02', amount: 2000 },
|
||||
{ date: '2020-08-03', amount: 3000 },
|
||||
{ date: '2020-08-04', amount: 4000 },
|
||||
{ date: '2020-08-05', amount: 5000 },
|
||||
{ date: '2020-08-06', amount: 6000 },
|
||||
{ date: '2020-08-07', amount: 7000 },
|
||||
{ date: '2020-08-08', amount: 8000 },
|
||||
{ date: '2020-08-09', amount: 9000 },
|
||||
{ date: '2020-08-10', amount: 10_000 },
|
||||
],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 10000,
|
||||
},
|
||||
trendline: {
|
||||
slope: 0,
|
||||
intercept: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return projectDailyCost;
|
||||
}
|
||||
|
||||
async getProductInsights(
|
||||
product: string,
|
||||
group: string,
|
||||
duration: Duration,
|
||||
): Promise<ProductCost> {
|
||||
const productInsights: ProductCost = await this.request(
|
||||
{ product, group, duration },
|
||||
{
|
||||
aggregation: [200_000, 250_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 50_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
|
||||
aggregation: [45_000, 50_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [20_000, 30_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [18_000, 25_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-d',
|
||||
aggregation: [36_000, 42_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-e',
|
||||
aggregation: [0, 10_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-f',
|
||||
aggregation: [17_000, 19_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-g',
|
||||
aggregation: [49_000, 30_000],
|
||||
},
|
||||
{
|
||||
id: 'entity-h',
|
||||
aggregation: [0, 34_000],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return productInsights;
|
||||
}
|
||||
|
||||
async getAlerts(group: string): Promise<Alert[]> {
|
||||
const alerts: Alert[] = await this.request({ group }, [
|
||||
{
|
||||
id: 'projectGrowth',
|
||||
project: 'example-project',
|
||||
periodStart: 'Q1 2020',
|
||||
periodEnd: 'Q2 2020',
|
||||
aggregation: [60_000, 120_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 60000,
|
||||
},
|
||||
products: [
|
||||
{
|
||||
id: 'Compute Engine',
|
||||
aggregation: [58_000, 118_000],
|
||||
},
|
||||
{
|
||||
id: 'Cloud Dataflow',
|
||||
aggregation: [1200, 1500],
|
||||
},
|
||||
{
|
||||
id: 'Cloud Storage',
|
||||
aggregation: [800, 500],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ExampleCostInsightsClient } from './ExampleCostInsightsClient';
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { PluginEndpointDiscovery } from './types';
|
||||
import { readBaseOptions } from '../service/lib/config';
|
||||
import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl';
|
||||
|
||||
/**
|
||||
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
|
||||
* that assumes that all plugins are hosted in a single deployment.
|
||||
*
|
||||
* The deployment may be scaled horizontally, as long as the external URL
|
||||
* is the same for all instances. However, internal URLs will always be
|
||||
* resolved to the same host, so there won't be any balancing of internal traffic.
|
||||
*/
|
||||
export class SingleHostDiscovery implements PluginEndpointDiscovery {
|
||||
/**
|
||||
* Creates a new SingleHostDiscovery discovery instance by reading
|
||||
* from the `backend` config section, specifically the `.baseUrl` for
|
||||
* discovering the external URL, and the `.listen` and `.https` config
|
||||
* for the internal one.
|
||||
*
|
||||
* The basePath defaults to `/api`, meaning the default full internal
|
||||
* path for the `catalog` plugin will be `http://localhost:7000/api/catalog`.
|
||||
*/
|
||||
static fromConfig(config: Config, options?: { basePath?: string }) {
|
||||
const basePath = options?.basePath ?? '/api';
|
||||
const externalBaseUrl = config.getString('backend.baseUrl');
|
||||
|
||||
const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions(
|
||||
config.getConfig('backend'),
|
||||
);
|
||||
const protocol = config.has('backend.https') ? 'https' : 'http';
|
||||
|
||||
// Translate bind-all to localhost, and support IPv6
|
||||
let host = listenHost;
|
||||
if (host === '::') {
|
||||
// We use localhost instead of ::1, since IPv6-compatible systems should default
|
||||
// to using IPv6 when they see localhost, but if the system doesn't support IPv6
|
||||
// things will still work.
|
||||
host = 'localhost';
|
||||
} else if (host === '0.0.0.0') {
|
||||
host = '127.0.0.1';
|
||||
}
|
||||
if (host.includes(':')) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
|
||||
|
||||
return new SingleHostDiscovery(
|
||||
internalBaseUrl + basePath,
|
||||
externalBaseUrl + basePath,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly internalBaseUrl: string,
|
||||
private readonly externalBaseUrl: string,
|
||||
) {}
|
||||
|
||||
async getBaseUrl(pluginId: string): Promise<string> {
|
||||
return `${this.internalBaseUrl}/${pluginId}`;
|
||||
}
|
||||
|
||||
async getExternalBaseUrl(pluginId: string): Promise<string> {
|
||||
return `${this.externalBaseUrl}/${pluginId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { SingleHostDiscovery } from './SingleHostDiscovery';
|
||||
export type { PluginEndpointDiscovery } from './types';
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The PluginEndpointDiscovery is used to provide a mechanism for backend
|
||||
* plugins to discover the endpoints for itself or other backend plugins.
|
||||
*
|
||||
* The purpose of the discovery API is to allow for many different deployment
|
||||
* setups and routing methods through a central configuration, instead
|
||||
* of letting each individual plugin manage that configuration.
|
||||
*
|
||||
* Implementations of the discovery API can be as simple as a URL pattern
|
||||
* using the pluginId, but could also have overrides for individual plugins,
|
||||
* or query a separate discovery service.
|
||||
*/
|
||||
export type PluginEndpointDiscovery = {
|
||||
/**
|
||||
* Returns the internal HTTP base URL for a given plugin, without a trailing slash.
|
||||
*
|
||||
* The returned URL should point to an internal endpoint for the plugin, with
|
||||
* the shortest route possible. The URL should be used for service-to-service
|
||||
* communication within a Backstage backend deployment.
|
||||
*
|
||||
* This method must always be called just before making a request, as opposed to
|
||||
* fetching the URL when constructing an API client. That is to ensure that more
|
||||
* flexible routing patterns can be supported.
|
||||
*
|
||||
* For example, asking for the URL for `catalog` may return something
|
||||
* like `http://10.1.2.3/api/catalog`
|
||||
*/
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Returns the external HTTP base backend URL for a given plugin, without a trailing slash.
|
||||
*
|
||||
* The returned URL should point to an external endpoint for the plugin, such that
|
||||
* it is reachable from the Backstage frontend and other external services. The returned
|
||||
* URL should be usable for example as a callback / webhook URL.
|
||||
*
|
||||
* The returned URL should be stable and in general not change unless other static
|
||||
* or external configuration is changed. Changes should not come as a surprise
|
||||
* to an operator of the Backstage backend.
|
||||
*
|
||||
* For example, asking for the URL for `catalog` may return something
|
||||
* like `https://backstage.example.com/api/catalog`
|
||||
*/
|
||||
getExternalBaseUrl(pluginId: string): Promise<string>;
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './config';
|
||||
export * from './database';
|
||||
export * from './discovery';
|
||||
export * from './errors';
|
||||
export * from './logging';
|
||||
export * from './middleware';
|
||||
|
||||
@@ -31,23 +31,40 @@ import {
|
||||
} from '../../middleware';
|
||||
import { ServiceBuilder } from '../types';
|
||||
import {
|
||||
CspOptions,
|
||||
HttpsSettings,
|
||||
readBaseOptions,
|
||||
readCorsOptions,
|
||||
readCspOptions,
|
||||
readHttpsSettings,
|
||||
HttpsSettings,
|
||||
} from './config';
|
||||
import { createHttpServer, createHttpsServer } from './hostFactory';
|
||||
import { metricsHandler } from './metrics';
|
||||
|
||||
const DEFAULT_PORT = 7000;
|
||||
export const DEFAULT_PORT = 7000;
|
||||
// '' is express default, which listens to all interfaces
|
||||
const DEFAULT_HOST = '';
|
||||
// taken from the helmet source code - don't seem to be exported
|
||||
const DEFAULT_CSP = {
|
||||
'default-src': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'block-all-mixed-content': [],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'object-src': ["'none'"],
|
||||
'script-src': ["'self'"],
|
||||
'script-src-attr': ["'none'"],
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'upgrade-insecure-requests': [],
|
||||
};
|
||||
|
||||
export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private port: number | undefined;
|
||||
private host: string | undefined;
|
||||
private logger: Logger | undefined;
|
||||
private corsOptions: cors.CorsOptions | undefined;
|
||||
private cspOptions: CspOptions | undefined;
|
||||
private httpsSettings: HttpsSettings | undefined;
|
||||
private enableMetrics: boolean = true;
|
||||
private routers: [string, Router][];
|
||||
@@ -79,6 +96,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
this.corsOptions = corsOptions;
|
||||
}
|
||||
|
||||
const cspOptions = readCspOptions(backendConfig);
|
||||
if (cspOptions) {
|
||||
this.cspOptions = cspOptions;
|
||||
}
|
||||
|
||||
const httpsSettings = readHttpsSettings(backendConfig);
|
||||
if (httpsSettings) {
|
||||
this.httpsSettings = httpsSettings;
|
||||
@@ -115,6 +137,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
setCsp(options: CspOptions): ServiceBuilder {
|
||||
this.cspOptions = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
addRouter(root: string, router: Router): ServiceBuilder {
|
||||
this.routers.push([root, router]);
|
||||
return this;
|
||||
@@ -127,10 +154,20 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host,
|
||||
logger,
|
||||
corsOptions,
|
||||
cspOptions,
|
||||
httpsSettings,
|
||||
} = this.getOptions();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...DEFAULT_CSP,
|
||||
...cspOptions,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (corsOptions) {
|
||||
app.use(cors(corsOptions));
|
||||
}
|
||||
@@ -177,6 +214,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host: string;
|
||||
logger: Logger;
|
||||
corsOptions?: cors.CorsOptions;
|
||||
cspOptions?: CspOptions;
|
||||
httpsSettings?: HttpsSettings;
|
||||
} {
|
||||
return {
|
||||
@@ -184,6 +222,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host: this.host ?? DEFAULT_HOST,
|
||||
logger: this.logger ?? getRootLogger(),
|
||||
corsOptions: this.corsOptions,
|
||||
cspOptions: this.cspOptions,
|
||||
httpsSettings: this.httpsSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CorsOptions } from 'cors';
|
||||
|
||||
export type BaseOptions = {
|
||||
@@ -57,6 +57,13 @@ export type CertificateAttributes = {
|
||||
commonName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map from CSP directive names to their values.
|
||||
*
|
||||
* Added here since helmet doesn't export this type publicly.
|
||||
*/
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* Reads some base options out of a config object.
|
||||
*
|
||||
@@ -71,7 +78,7 @@ export type CertificateAttributes = {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readBaseOptions(config: ConfigReader): BaseOptions {
|
||||
export function readBaseOptions(config: Config): BaseOptions {
|
||||
if (typeof config.get('listen') === 'string') {
|
||||
// TODO(freben): Expand this to support more addresses and perhaps optional
|
||||
const { host, port } = parseListenAddress(config.getString('listen'));
|
||||
@@ -105,7 +112,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
export function readCorsOptions(config: Config): CorsOptions | undefined {
|
||||
const cc = config.getOptionalConfig('cors');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
@@ -123,6 +130,33 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a CSP options object from the root of a config object.
|
||||
*
|
||||
* @param config The root of a backend config object
|
||||
* @returns A CSP options object, or undefined if not specified
|
||||
*
|
||||
* @example
|
||||
* ```yaml
|
||||
* backend:
|
||||
* csp:
|
||||
* connect-src: ["'self'", 'http:', 'https:']
|
||||
* ```
|
||||
*/
|
||||
export function readCspOptions(config: Config): CspOptions | undefined {
|
||||
const cc = config.getOptionalConfig('csp');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: CspOptions = {};
|
||||
for (const key of cc.keys()) {
|
||||
result[key] = cc.getStringArray(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a https settings object from the root of a config object.
|
||||
*
|
||||
@@ -138,9 +172,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readHttpsSettings(
|
||||
config: ConfigReader,
|
||||
): HttpsSettings | undefined {
|
||||
export function readHttpsSettings(config: Config): HttpsSettings | undefined {
|
||||
const cc = config.getOptionalConfig('https');
|
||||
|
||||
if (!cc) {
|
||||
@@ -157,7 +189,7 @@ export function readHttpsSettings(
|
||||
}
|
||||
|
||||
function getOptionalStringOrStrings(
|
||||
config: ConfigReader,
|
||||
config: Config,
|
||||
key: string,
|
||||
): string | string[] | undefined {
|
||||
const value = config.getOptional(key);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12
|
||||
FROM node:12-buster
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "12"
|
||||
"node": "12 || 14"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli backend:build",
|
||||
@@ -25,7 +25,6 @@
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-graphql-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-identity-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-proxy-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-rollbar-backend": "^0.1.1-alpha.23",
|
||||
@@ -34,6 +33,7 @@
|
||||
"@backstage/plugin-techdocs-backend": "^0.1.1-alpha.23",
|
||||
"@gitbeaker/node": "^23.5.0",
|
||||
"@octokit/rest": "^18.0.0",
|
||||
"azure-devops-node-api": "^10.1.1",
|
||||
"dockerode": "^3.2.0",
|
||||
"example-app": "^0.1.1-alpha.23",
|
||||
"express": "^4.17.1",
|
||||
@@ -41,7 +41,7 @@
|
||||
"knex": "^0.21.1",
|
||||
"pg": "^8.3.0",
|
||||
"pg-connection-string": "^2.3.0",
|
||||
"sqlite3": "^4.2.0",
|
||||
"sqlite3": "^5.0.0",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -30,12 +30,12 @@ import {
|
||||
getRootLogger,
|
||||
useHotMemoize,
|
||||
notFoundHandler,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader, AppConfig } from '@backstage/config';
|
||||
import healthcheck from './plugins/healthcheck';
|
||||
import auth from './plugins/auth';
|
||||
import catalog from './plugins/catalog';
|
||||
import identity from './plugins/identity';
|
||||
import kubernetes from './plugins/kubernetes';
|
||||
import rollbar from './plugins/rollbar';
|
||||
import scaffolder from './plugins/scaffolder';
|
||||
@@ -59,7 +59,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, database, config };
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
return { logger, database, config, discovery };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +72,6 @@ async function main() {
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
|
||||
const authEnv = useHotMemoize(module, () => createEnv('auth'));
|
||||
const identityEnv = useHotMemoize(module, () => createEnv('identity'));
|
||||
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
|
||||
const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar'));
|
||||
const sentryEnv = useHotMemoize(module, () => createEnv('sentry'));
|
||||
@@ -85,11 +85,10 @@ async function main() {
|
||||
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
|
||||
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
|
||||
apiRouter.use('/sentry', await sentry(sentryEnv));
|
||||
apiRouter.use('/auth', await auth(authEnv, '/api/auth'));
|
||||
apiRouter.use('/identity', await identity(identityEnv));
|
||||
apiRouter.use('/auth', await auth(authEnv));
|
||||
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
|
||||
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv, '/api/proxy'));
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv));
|
||||
apiRouter.use('/graphql', await graphql(graphqlEnv));
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import { createRouter } from '@backstage/plugin-auth-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
{ logger, database, config }: PluginEnvironment,
|
||||
basePath: string,
|
||||
) {
|
||||
return await createRouter({ logger, config, database, basePath });
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
database,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({ logger, config, database, discovery });
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
import { createRouter } from '@backstage/plugin-proxy-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
{ logger, config }: PluginEnvironment,
|
||||
pathPrefix: string,
|
||||
) {
|
||||
return await createRouter({ logger, config, pathPrefix });
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({ logger, config, discovery });
|
||||
}
|
||||
|
||||
@@ -20,16 +20,19 @@ import {
|
||||
FilePreparer,
|
||||
GithubPreparer,
|
||||
GitlabPreparer,
|
||||
AzurePreparer,
|
||||
Preparers,
|
||||
Publishers,
|
||||
GithubPublisher,
|
||||
GitlabPublisher,
|
||||
AzurePublisher,
|
||||
CreateReactAppTemplater,
|
||||
Templaters,
|
||||
RepoVisibilityOptions,
|
||||
} from '@backstage/plugin-scaffolder-backend';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
@@ -46,12 +49,14 @@ export default async function createPlugin({
|
||||
const filePreparer = new FilePreparer();
|
||||
const githubPreparer = new GithubPreparer();
|
||||
const gitlabPreparer = new GitlabPreparer(config);
|
||||
const azurePreparer = new AzurePreparer(config);
|
||||
const preparers = new Preparers();
|
||||
|
||||
preparers.register('file', filePreparer);
|
||||
preparers.register('github', githubPreparer);
|
||||
preparers.register('gitlab', gitlabPreparer);
|
||||
preparers.register('gitlab/api', gitlabPreparer);
|
||||
preparers.register('azure/api', azurePreparer);
|
||||
|
||||
const publishers = new Publishers();
|
||||
|
||||
@@ -111,6 +116,32 @@ export default async function createPlugin({
|
||||
}
|
||||
}
|
||||
|
||||
const azureConfig = config.getOptionalConfig('scaffolder.azure');
|
||||
if (azureConfig) {
|
||||
try {
|
||||
const baseUrl = azureConfig.getString('baseUrl');
|
||||
const azureToken = azureConfig.getConfig('api').getString('token');
|
||||
|
||||
const authHandler = getPersonalAccessTokenHandler(azureToken);
|
||||
const webApi = new WebApi(baseUrl, authHandler);
|
||||
const azureClient = await webApi.getGitApi();
|
||||
|
||||
const azurePublisher = new AzurePublisher(azureClient, azureToken);
|
||||
publishers.register('azure/api', azurePublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'azure';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dockerClient = new Docker();
|
||||
return await createRouter({
|
||||
preparers,
|
||||
|
||||
@@ -29,6 +29,7 @@ import Docker from 'dockerode';
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
const generators = new Generators();
|
||||
const techdocsGenerator = new TechdocsGenerator(logger, config);
|
||||
@@ -51,5 +52,6 @@ export default async function createPlugin({
|
||||
dockerClient,
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
|
||||
export type PluginEnvironment = {
|
||||
logger: Logger;
|
||||
database: Knex;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
};
|
||||
|
||||
@@ -8,3 +8,5 @@ spec:
|
||||
targets:
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: spotify
|
||||
description: The Spotify web API
|
||||
tags:
|
||||
- spotify
|
||||
- rest
|
||||
annotations:
|
||||
backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml'
|
||||
spec:
|
||||
type: openapi
|
||||
lifecycle: production
|
||||
owner: spotify@example.com
|
||||
@@ -87,7 +87,7 @@ export type EntityMeta = JsonObject & {
|
||||
/**
|
||||
* The name of the entity.
|
||||
*
|
||||
* Must be uniqe within the catalog at any given point in time, for any
|
||||
* Must be unique within the catalog at any given point in time, for any
|
||||
* given namespace + kind pair.
|
||||
*/
|
||||
name: string;
|
||||
@@ -120,8 +120,3 @@ export type EntityMeta = JsonObject & {
|
||||
*/
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The keys of EntityMeta that are auto-generated.
|
||||
*/
|
||||
export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The namespace that entities without an explicit namespace fall into.
|
||||
*/
|
||||
export const ENTITY_DEFAULT_NAMESPACE = 'default';
|
||||
|
||||
/**
|
||||
* The keys of EntityMeta that are auto-generated.
|
||||
*/
|
||||
export const ENTITY_META_GENERATED_FIELDS = [
|
||||
'uid',
|
||||
'etag',
|
||||
'generation',
|
||||
] as const;
|
||||
@@ -14,10 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { entityMetaGeneratedFields } from './Entity';
|
||||
export {
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
ENTITY_META_GENERATED_FIELDS,
|
||||
} from './constants';
|
||||
export type { Entity, EntityMeta } from './Entity';
|
||||
export * from './policies';
|
||||
export { parseEntityName, serializeEntityRef } from './ref';
|
||||
export {
|
||||
getEntityName,
|
||||
parseEntityName,
|
||||
parseEntityRef,
|
||||
serializeEntityRef,
|
||||
} from './ref';
|
||||
export {
|
||||
entityHasChanges,
|
||||
generateEntityEtag,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import yaml from 'yaml';
|
||||
import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
|
||||
import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
|
||||
|
||||
describe('DefaultNamespaceEntityPolicy', () => {
|
||||
@@ -54,7 +55,10 @@ describe('DefaultNamespaceEntityPolicy', () => {
|
||||
await expect(result).resolves.not.toBe(withoutNamespace);
|
||||
await expect(result).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
metadata: { name: 'my-component-yay', namespace: 'default' },
|
||||
metadata: {
|
||||
name: 'my-component-yay',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import lodash from 'lodash';
|
||||
import { EntityPolicy } from '../../types';
|
||||
import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
|
||||
import { Entity } from '../Entity';
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ import { Entity } from '../Entity';
|
||||
export class DefaultNamespaceEntityPolicy implements EntityPolicy {
|
||||
private readonly namespace: string;
|
||||
|
||||
constructor(namespace: string = 'default') {
|
||||
constructor(namespace: string = ENTITY_DEFAULT_NAMESPACE) {
|
||||
this.namespace = namespace;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
|
||||
import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
|
||||
|
||||
describe('ref', () => {
|
||||
@@ -27,7 +28,7 @@ describe('ref', () => {
|
||||
expect(() => parseEntityName('b/c')).toThrow(/kind/);
|
||||
expect(parseEntityName('a:c')).toEqual({
|
||||
kind: 'a',
|
||||
namespace: 'default',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c',
|
||||
});
|
||||
expect(() => parseEntityName('c')).toThrow(/kind/);
|
||||
@@ -116,7 +117,7 @@ describe('ref', () => {
|
||||
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
|
||||
expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({
|
||||
kind: 'a',
|
||||
namespace: 'default',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c',
|
||||
});
|
||||
expect(
|
||||
@@ -124,7 +125,7 @@ describe('ref', () => {
|
||||
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
|
||||
expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({
|
||||
kind: 'x',
|
||||
namespace: 'default',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c',
|
||||
});
|
||||
});
|
||||
@@ -150,7 +151,7 @@ describe('ref', () => {
|
||||
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
|
||||
expect(
|
||||
parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }),
|
||||
).toEqual({ kind: 'a', namespace: 'default', name: 'c' });
|
||||
).toEqual({ kind: 'a', namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c' });
|
||||
expect(
|
||||
parseEntityName(
|
||||
{ name: 'c' },
|
||||
@@ -159,7 +160,7 @@ describe('ref', () => {
|
||||
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
|
||||
expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({
|
||||
kind: 'x',
|
||||
namespace: 'default',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c',
|
||||
});
|
||||
// empty strings are errors, not defaults
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
*/
|
||||
|
||||
import { EntityName, EntityRef } from '../types';
|
||||
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
|
||||
import { Entity } from './Entity';
|
||||
|
||||
/**
|
||||
* Extracts the kind, namespace and name that form the name triplet of the
|
||||
* given entity.
|
||||
*
|
||||
* @param entity An entity
|
||||
* @returns The complete entity name
|
||||
*/
|
||||
export function getEntityName(entity: Entity): EntityName {
|
||||
return {
|
||||
kind: entity.kind,
|
||||
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
|
||||
name: entity.metadata.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The context of defaults that entity reference parsing happens within.
|
||||
@@ -43,7 +60,7 @@ export function parseEntityName(
|
||||
context: EntityRefContext = {},
|
||||
): EntityName {
|
||||
const { kind, namespace, name } = parseEntityRef(ref, {
|
||||
defaultNamespace: 'default',
|
||||
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
...context,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"css-loader": "^3.5.3",
|
||||
"dashify": "^2.0.0",
|
||||
"diff": "^4.0.2",
|
||||
"esbuild": "0.6.3",
|
||||
"esbuild": "^0.7.7",
|
||||
"eslint": "^7.1.0",
|
||||
"eslint-formatter-friendly": "^7.0.0",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
|
||||
@@ -310,3 +310,13 @@ export const oauth2ApiRef = createApiRef<
|
||||
id: 'core.auth.oauth2',
|
||||
description: 'Example of how to use oauth2 custom provider',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication for saml based identity providers
|
||||
*/
|
||||
export const samlAuthApiRef = createApiRef<
|
||||
ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>({
|
||||
id: 'core.auth.saml',
|
||||
description: 'Example of how to use SAML custom provider',
|
||||
});
|
||||
|
||||
@@ -19,5 +19,6 @@ export * from './gitlab';
|
||||
export * from './google';
|
||||
export * from './oauth2';
|
||||
export * from './okta';
|
||||
export * from './saml';
|
||||
export * from './auth0';
|
||||
export * from './microsoft';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 SamlIcon from '@material-ui/icons/AcUnit';
|
||||
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import { Observable } from '../../../../types';
|
||||
import {
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
SessionState,
|
||||
AuthRequestOptions,
|
||||
ProfileInfoApi,
|
||||
BackstageIdentityApi,
|
||||
SessionApi,
|
||||
} from '../../../definitions/auth';
|
||||
import { AuthProvider, DiscoveryApi } from '../../../definitions';
|
||||
import { SamlSession } from './types';
|
||||
import {
|
||||
AuthSessionStore,
|
||||
StaticAuthSessionManager,
|
||||
} from '../../../../lib/AuthSessionManager';
|
||||
|
||||
type CreateOptions = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
environment?: string;
|
||||
provider?: AuthProvider & { id: string };
|
||||
};
|
||||
|
||||
export type SamlAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'saml',
|
||||
title: 'SAML',
|
||||
icon: SamlIcon,
|
||||
};
|
||||
|
||||
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
}: CreateOptions) {
|
||||
const connector = new DirectAuthConnector<SamlSession>({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
});
|
||||
|
||||
const sessionManager = new StaticAuthSessionManager<SamlSession>({
|
||||
connector,
|
||||
});
|
||||
|
||||
const authSessionStore = new AuthSessionStore<SamlSession>({
|
||||
manager: sessionManager,
|
||||
storageKey: 'samlSession',
|
||||
});
|
||||
|
||||
return new SamlAuth(authSessionStore);
|
||||
}
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionManager.sessionState$();
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
|
||||
|
||||
async signIn() {
|
||||
await this.getBackstageIdentity({});
|
||||
}
|
||||
async signOut() {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
async getBackstageIdentity(
|
||||
options: AuthRequestOptions,
|
||||
): Promise<BackstageIdentity | undefined> {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.backstageIdentity;
|
||||
}
|
||||
|
||||
async getProfile(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.profile;
|
||||
}
|
||||
}
|
||||
|
||||
export default SamlAuth;
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export { default as SamlAuth } from './SamlAuth';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
|
||||
|
||||
export type SamlSession = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 {
|
||||
AuthProvider,
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
DiscoveryApi,
|
||||
} from '../../apis/definitions';
|
||||
import { showLoginPopup } from '../loginPopup';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
environment?: string;
|
||||
provider: AuthProvider & { id: string };
|
||||
};
|
||||
|
||||
export type DirectAuthResponse = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
export class DirectAuthConnector<DirectAuthResponse> {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly environment: string | undefined;
|
||||
private readonly provider: AuthProvider & { id: string };
|
||||
|
||||
constructor(options: Options) {
|
||||
const { discoveryApi, environment, provider } = options;
|
||||
|
||||
this.discoveryApi = discoveryApi;
|
||||
this.environment = environment;
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async createSession(): Promise<DirectAuthResponse> {
|
||||
const popupUrl = await this.buildUrl('/start');
|
||||
const payload = await showLoginPopup({
|
||||
url: popupUrl,
|
||||
name: `${this.provider.title} Login`,
|
||||
origin: new URL(popupUrl).origin,
|
||||
width: 450,
|
||||
height: 730,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
id: payload.profile.email,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<any> {}
|
||||
|
||||
async removeSession(): Promise<void> {
|
||||
const res = await fetch(await this.buildUrl('/logout'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(error => {
|
||||
throw new Error(`Logout request failed, ${error}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error: any = new Error(`Logout request failed, ${res.statusText}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildUrl(path: string): Promise<string> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
|
||||
return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`;
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { DefaultAuthConnector } from './DefaultAuthConnector';
|
||||
export { DirectAuthConnector } from './DirectAuthConnector';
|
||||
export * from './types';
|
||||
|
||||
@@ -28,7 +28,7 @@ type Options<T> = {
|
||||
/** Storage key to use to store sessions */
|
||||
storageKey: string;
|
||||
/** Used to get the scope of the session */
|
||||
sessionScopes: SessionScopesFunc<T>;
|
||||
sessionScopes?: SessionScopesFunc<T>;
|
||||
/** Used to check if the session needs to be refreshed, defaults to never refresh */
|
||||
sessionShouldRefresh?: SessionShouldRefreshFunc<T>;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ type Options<T> = {
|
||||
/** The connector used for acting on the auth session */
|
||||
connector: AuthConnector<T>;
|
||||
/** Used to get the scope of the session */
|
||||
sessionScopes: (session: T) => Set<string>;
|
||||
sessionScopes?: (session: T) => Set<string>;
|
||||
/** The default scopes that should always be present in a session, defaults to none. */
|
||||
defaultScopes?: Set<string>;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export function hasScopes(
|
||||
}
|
||||
|
||||
type ScopeHelperOptions<T> = {
|
||||
sessionScopes: SessionScopesFunc<T>;
|
||||
sessionScopes: SessionScopesFunc<T> | undefined;
|
||||
defaultScopes?: Set<string>;
|
||||
};
|
||||
|
||||
@@ -46,13 +46,16 @@ export class SessionScopeHelper<T> {
|
||||
if (!scopes) {
|
||||
return true;
|
||||
}
|
||||
if (this.options.sessionScopes === undefined) {
|
||||
return true;
|
||||
}
|
||||
const sessionScopes = this.options.sessionScopes(session);
|
||||
return hasScopes(sessionScopes, scopes);
|
||||
}
|
||||
|
||||
getExtendedScope(session: T | undefined, scopes?: Set<string>) {
|
||||
const newScope = new Set(this.options.defaultScopes);
|
||||
if (session) {
|
||||
if (session && this.options.sessionScopes !== undefined) {
|
||||
const sessionScopes = this.options.sessionScopes(session);
|
||||
for (const scope of sessionScopes) {
|
||||
newScope.add(scope);
|
||||
|
||||
@@ -14,30 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { RouteRefConfig, RouteRefOverrideConfig } from './types';
|
||||
import {
|
||||
ConcreteRoute,
|
||||
routeReference,
|
||||
ReferencedRoute,
|
||||
resolveRoute,
|
||||
RouteRefConfig,
|
||||
} from './types';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
export class MutableRouteRef {
|
||||
private effectiveConfig: RouteRefConfig = this.config;
|
||||
type SubRouteConfig = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export class SubRouteRef<T extends { [name in string]: string } | never = never>
|
||||
implements ReferencedRoute {
|
||||
constructor(
|
||||
private readonly parent: ConcreteRoute,
|
||||
private readonly config: SubRouteConfig,
|
||||
) {}
|
||||
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
link<Args extends T extends never ? [] : [T]>(...args: Args): ConcreteRoute {
|
||||
return {
|
||||
[routeReference]: this,
|
||||
[resolveRoute]: (path: string) => {
|
||||
const ownPart = generatePath(this.config.path, args[0] ?? {});
|
||||
const parentPart = this.parent[resolveRoute](path);
|
||||
return parentPart + ownPart;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AbsoluteRouteRef implements ConcreteRoute {
|
||||
constructor(private readonly config: RouteRefConfig) {}
|
||||
|
||||
override(overrideConfig: RouteRefOverrideConfig) {
|
||||
this.effectiveConfig = { ...this.config, ...overrideConfig };
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return this.effectiveConfig.icon;
|
||||
return this.config.icon;
|
||||
}
|
||||
|
||||
// TODO(Rugvip): Remove this, routes are looked up via the registry instead
|
||||
get path() {
|
||||
return this.effectiveConfig.path;
|
||||
return this.config.path;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.effectiveConfig.title;
|
||||
return this.config.title;
|
||||
}
|
||||
|
||||
createSubRoute<T extends { [name in string]: string } | never = never>(
|
||||
config: SubRouteConfig,
|
||||
) {
|
||||
return new SubRouteRef<T>(this, config);
|
||||
}
|
||||
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
[resolveRoute](path: string) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouteRef(config: RouteRefConfig): MutableRouteRef {
|
||||
return new MutableRouteRef(config);
|
||||
export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef {
|
||||
return new AbsoluteRouteRef(config);
|
||||
}
|
||||
|
||||
// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone
|
||||
// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider
|
||||
// a different model for how to create sub routes, just avoid this
|
||||
export type MutableRouteRef = AbsoluteRouteRef;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRefRegistry } from './RouteRefRegistry';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
|
||||
const dummyConfig = { path: '/', icon: () => null, title: 'my-title' };
|
||||
const ref1 = createRouteRef(dummyConfig);
|
||||
const ref11 = createRouteRef(dummyConfig);
|
||||
const ref12 = createRouteRef(dummyConfig);
|
||||
const ref121 = createRouteRef(dummyConfig);
|
||||
const ref2 = createRouteRef(dummyConfig);
|
||||
const ref2a = ref2.createSubRoute({ path: '/a' });
|
||||
const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' });
|
||||
|
||||
describe('RouteRefRegistry', () => {
|
||||
it('should be constructed with a root route', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.resolveRoute([], [])).toBe('');
|
||||
});
|
||||
|
||||
it('should register and resolve some absolute routes', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.registerRoute([ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref11], '11')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12], '12')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2], 'duplicate')).toBe(false);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
|
||||
expect(registry.resolveRoute([], [ref1])).toBe('/1');
|
||||
expect(registry.resolveRoute([], [ref11])).toBe(undefined);
|
||||
expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11');
|
||||
expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11');
|
||||
expect(registry.resolveRoute([ref1], [ref2])).toBe('/2');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe(
|
||||
'/1/12/121',
|
||||
);
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe(
|
||||
'/1/12/121',
|
||||
);
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1');
|
||||
});
|
||||
|
||||
it('should register and resolve with sub routes', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.registerRoute([ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true);
|
||||
|
||||
expect(registry.resolveRoute([], [ref1])).toBe('/1');
|
||||
expect(registry.resolveRoute([], [ref2])).toBe('/2');
|
||||
expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1');
|
||||
expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe(
|
||||
'/2/b/abc/1',
|
||||
);
|
||||
expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe(
|
||||
'/2/b/xyz/2',
|
||||
);
|
||||
expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe(
|
||||
'/2/b/abc/2',
|
||||
);
|
||||
expect(
|
||||
registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]),
|
||||
).toBe('/2/b/abc/2');
|
||||
});
|
||||
|
||||
it('should throw when registering routes incorrectly', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(() => {
|
||||
registry.registerRoute([ref1, ref11], '11');
|
||||
}).toThrow('Could not find parent for new routing node');
|
||||
expect(() => {
|
||||
registry.registerRoute([], '11');
|
||||
}).toThrow('Must provide at least 1 route to add routing node');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 {
|
||||
ConcreteRoute,
|
||||
routeReference,
|
||||
resolveRoute,
|
||||
ReferencedRoute,
|
||||
} from './types';
|
||||
|
||||
const rootRoute: ConcreteRoute = {
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
},
|
||||
[resolveRoute]: () => '',
|
||||
};
|
||||
|
||||
export type RouteRefResolver = {
|
||||
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string;
|
||||
};
|
||||
|
||||
class Node {
|
||||
readonly children = new Map<unknown, Node>();
|
||||
|
||||
constructor(readonly path: string, readonly parent: Node | undefined) {}
|
||||
|
||||
/**
|
||||
* Look up a node in the tree given a path.
|
||||
*/
|
||||
findNode(routes: ReferencedRoute[]): Node | undefined {
|
||||
let node = this as Node | undefined;
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
node = node?.children.get(routes[i][routeReference]);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a path to a leaf node in the routing tree. All ancestor
|
||||
* nodes of the new leaf node must already exist, or an error will be thrown.
|
||||
*
|
||||
* Returns true if the node was added, or false if the node already existed.
|
||||
*/
|
||||
addNode(routes: ReferencedRoute[], path: string): boolean {
|
||||
if (routes.length === 0) {
|
||||
throw new Error('Must provide at least 1 route to add routing node');
|
||||
}
|
||||
|
||||
const parentNode = this.findNode(routes.slice(0, -1));
|
||||
if (!parentNode) {
|
||||
throw new Error('Could not find parent for new routing node');
|
||||
}
|
||||
|
||||
const lastRoute = routes[routes.length - 1];
|
||||
const lastRouteRef = lastRoute[routeReference];
|
||||
|
||||
const existingNode = parentNode.children.get(lastRouteRef);
|
||||
if (existingNode) {
|
||||
return existingNode.path === path;
|
||||
}
|
||||
|
||||
parentNode.children.set(lastRouteRef, new Node(path, parentNode));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute URL that represents this node in the routing tree, using
|
||||
* using the supplied concrete routes and ancestors of this node.
|
||||
*
|
||||
* The length of the provided routes array must match the depth of
|
||||
* the routing tree that this node is at, or an error will be thrown.
|
||||
*/
|
||||
resolve(routes: ConcreteRoute[]) {
|
||||
const parts = Array(routes.length);
|
||||
|
||||
let node = this as Node | undefined;
|
||||
for (let i = routes.length - 1; i >= 0; i--) {
|
||||
if (!node) {
|
||||
throw new Error('Route resolve missing required parent');
|
||||
}
|
||||
|
||||
const route = routes[i];
|
||||
parts[i] = route[resolveRoute](node.path);
|
||||
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
throw new Error('Route resolve did not reach root');
|
||||
}
|
||||
|
||||
return parts.join('/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry for resolving route refs into concrete string routes.
|
||||
*/
|
||||
export class RouteRefRegistry {
|
||||
private readonly root = new Node('', undefined);
|
||||
|
||||
/**
|
||||
* Register a new leaf path for a sequence of routes. All ancestor
|
||||
* routes must already exist.
|
||||
*/
|
||||
registerRoute(routes: ReferencedRoute[], path: string): boolean {
|
||||
return this.root.addNode(routes, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute path from a point in the routing tree.
|
||||
*
|
||||
* The route referenced by `from` must exist, and is the starting
|
||||
* point for the search, walking up the tree until a subtree that
|
||||
* matches the routes reference in `to` are found.
|
||||
*
|
||||
* If `from` is empty, the search starts and ends at the root node.
|
||||
* If `to` is empty, the route referenced by `from` will always be returned.
|
||||
*/
|
||||
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined {
|
||||
// Keep track of the `from` routes and pop the last ones as we traverse up
|
||||
// the routing tree. The list of concrete routes that we're passing to
|
||||
// `node.resolve()` should only include the ones in the resolve path.
|
||||
const concreteStack = from.slice();
|
||||
|
||||
let fromNode = this.root.findNode(from);
|
||||
while (fromNode) {
|
||||
const resolvedNode = fromNode.findNode(to);
|
||||
if (resolvedNode) {
|
||||
return resolvedNode.resolve([rootRoute].concat(concreteStack, to));
|
||||
}
|
||||
|
||||
// Search at this level of the tree failed, move up to parent
|
||||
concreteStack.pop();
|
||||
fromNode = fromNode.parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types';
|
||||
export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef';
|
||||
export { createRouteRef } from './RouteRef';
|
||||
export type { MutableRouteRef } from './RouteRef';
|
||||
|
||||
@@ -16,7 +16,19 @@
|
||||
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
export const resolveRoute = Symbol('resolve-route');
|
||||
export const routeReference = Symbol('route-ref');
|
||||
|
||||
export type ReferencedRoute = {
|
||||
[routeReference]: unknown;
|
||||
};
|
||||
|
||||
export type ConcreteRoute = ReferencedRoute & {
|
||||
[resolveRoute](path: string): string;
|
||||
};
|
||||
|
||||
export type RouteRef = {
|
||||
// TODO(Rugvip): Remove path, look up via registry instead
|
||||
path: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
@@ -27,9 +39,3 @@ export type RouteRefConfig = {
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type RouteRefOverrideConfig = {
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
createApiFactory,
|
||||
configApiRef,
|
||||
UrlPatternDiscovery,
|
||||
samlAuthApiRef,
|
||||
SamlAuth,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
export const defaultApis = [
|
||||
@@ -132,4 +134,11 @@ export const defaultApis = [
|
||||
factory: ({ discoveryApi, oauthRequestApi }) =>
|
||||
OAuth2.create({ discoveryApi, oauthRequestApi }),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: samlAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -100,3 +100,15 @@ export const WithLink = () => (
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
export const Fixed = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with a fixed position fixed at the bottom of the page"
|
||||
variant="info"
|
||||
id="fixed_dismissable"
|
||||
fixed
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, ReactNode, useState, useEffect } from 'react';
|
||||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core-api';
|
||||
import { useObservable } from 'react-use';
|
||||
import classNames from 'classnames';
|
||||
@@ -27,12 +27,17 @@ import Close from '@material-ui/icons/Close';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
root: {
|
||||
position: 'relative',
|
||||
padding: theme.spacing(0),
|
||||
marginBottom: theme.spacing(6),
|
||||
marginTop: -theme.spacing(3),
|
||||
marginBottom: theme.spacing(0),
|
||||
marginTop: theme.spacing(0),
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
},
|
||||
// showing on top
|
||||
topPosition: {
|
||||
position: 'relative',
|
||||
marginBottom: theme.spacing(6),
|
||||
marginTop: -theme.spacing(3),
|
||||
zIndex: 'unset',
|
||||
},
|
||||
icon: {
|
||||
@@ -45,6 +50,10 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
message: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.banner.text,
|
||||
'& a': {
|
||||
color: theme.palette.banner.link,
|
||||
},
|
||||
},
|
||||
info: {
|
||||
backgroundColor: theme.palette.banner.info,
|
||||
@@ -58,9 +67,15 @@ type Props = {
|
||||
variant: 'info' | 'error';
|
||||
message: ReactNode;
|
||||
id: string;
|
||||
fixed?: boolean;
|
||||
};
|
||||
|
||||
export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
|
||||
export const DismissableBanner = ({
|
||||
variant,
|
||||
message,
|
||||
id,
|
||||
fixed = false,
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const notificationsStore = storageApi.forBucket('notifications');
|
||||
@@ -88,9 +103,13 @@ export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={
|
||||
fixed
|
||||
? { vertical: 'bottom', horizontal: 'center' }
|
||||
: { vertical: 'top', horizontal: 'center' }
|
||||
}
|
||||
open={!dismissedBanners.has(id)}
|
||||
classes={{ root: classes.root }}
|
||||
classes={{ root: classNames(classes.root, fixed && classes.topPosition) }}
|
||||
>
|
||||
<SnackbarContent
|
||||
classes={{
|
||||
|
||||
@@ -71,13 +71,7 @@ export const MetadataTable = ({
|
||||
dense?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Table>
|
||||
{!dense && (
|
||||
<colgroup>
|
||||
<col style={{ width: 'auto' }} />
|
||||
<col style={{ width: '100%' }} />
|
||||
</colgroup>
|
||||
)}
|
||||
<Table size={dense ? 'small' : 'medium'}>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
+13
@@ -56,3 +56,16 @@ export const Default = () => (
|
||||
</InfoCard>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const NotDenseTable = () => (
|
||||
<Wrapper>
|
||||
<InfoCard
|
||||
title="Not Dense Structured Metadata Table"
|
||||
subheader="Wrapped in InfoCard"
|
||||
>
|
||||
<div style={cardContentStyle}>
|
||||
<StructuredMetadataTable metadata={metadata} dense={false} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Component, Fragment, ReactElement } from 'react';
|
||||
import React, { Fragment, ReactElement } from 'react';
|
||||
import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core';
|
||||
import startCase from 'lodash/startCase';
|
||||
|
||||
@@ -142,17 +142,17 @@ const TableItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ComponentProps {
|
||||
type Props = {
|
||||
metadata: { [key: string]: any };
|
||||
dense?: boolean;
|
||||
options?: any;
|
||||
}
|
||||
};
|
||||
|
||||
export class StructuredMetadataTable extends Component<ComponentProps> {
|
||||
render() {
|
||||
const { metadata, dense, options } = this.props;
|
||||
const metadataItems = mapToItems(metadata, options || {});
|
||||
|
||||
return <MetadataTable dense={dense}>{metadataItems}</MetadataTable>;
|
||||
}
|
||||
}
|
||||
export const StructuredMetadataTable = ({
|
||||
metadata,
|
||||
dense = true,
|
||||
options,
|
||||
}: Props) => {
|
||||
const metadataItems = mapToItems(metadata, options || {});
|
||||
return <MetadataTable dense={dense}>{metadataItems}</MetadataTable>;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user