Merge branch 'cloudbuild-plugin' of github.com:ebarriosjr/backstage into cloudbuild-plugin

This commit is contained in:
ebarrios
2020-09-25 14:50:44 +02:00
192 changed files with 5195 additions and 505 deletions
+2
View File
@@ -13,6 +13,8 @@ flags:
core:
paths:
- packages/core/
carryforward: true
core-api:
paths:
- packages/core-api/
carryforward: true
+18
View File
@@ -8,10 +8,28 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re
> Collect changes for the next release below
### Backend (example-backend, or backends created with @backstage/create-app)
- 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/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).
### @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. 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`.
## v0.1.1-alpha.22
### @backstage/core
+4
View File
@@ -32,6 +32,10 @@ If you start developing a plugin that you aim to release as open source, we sugg
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.
## Adding Non-code Contributions
Since there is such a large landscape of possible development, build, and deployment environments, we welcome community contributions in these areas in the [`/contrib`](https://github.com/spotify/backstage/tree/master/contrib) folder of the project. This is an excellent place to put things that help out the community at large, but which may not fit within the scope of the core product to support natively. Here, you will find Helm charts, alternative Docker images, and much more.
## Write Documentation
The current documentation is very limited. Help us make the `/docs` folder come alive.
+3 -3
View File
@@ -26,6 +26,8 @@ Out of the box, Backstage includes:
For more information go to [backstage.io](https://backstage.io) or join our [Discord chatroom](https://discord.gg/EBHEGzX).
🎉 Backstage is a CNCF Sandbox project. Read the announcement [here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox).
## Project roadmap
A detailed project roadmap, including already delivered milestones, is available [here](https://backstage.io/docs/overview/roadmap).
@@ -51,11 +53,9 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how
- [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll
- [Adopters](ADOPTERS.md) - Companies already using Backstage
- [Blog](https://backstage.io/blog/) - Announcements and updates
- [Newsletter](https://mailchi.mp/spotify/backstage-community)
- [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to our email newsletter
- Give us a star ⭐️ - If you are using Backstage or think it is an interesting project, we would love a star ❤️
Or, if you are an open source developer and are interested in joining our team, please reach out to [foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com)
## License
Copyright 2020 Spotify AB.
+2
View File
@@ -9,3 +9,5 @@ backend:
origin: http://localhost:3000
methods: [GET, POST, PUT, DELETE]
credentials: true
csp:
connect-src: ["'self'", 'http:', 'https:']
+8 -2
View File
@@ -9,6 +9,8 @@ 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:
@@ -33,8 +35,8 @@ organization:
name: Spotify
techdocs:
storageUrl: http://localhost:7000/techdocs/static/docs
requestUrl: http://localhost:7000/techdocs/docs
storageUrl: http://localhost:7000/api/techdocs/static/docs
requestUrl: http://localhost:7000/api/techdocs/docs
generators:
techdocs: 'docker'
@@ -55,6 +57,10 @@ newrelic:
lighthouse:
baseUrl: http://localhost:3003
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters: []
catalog:
rules:
- allow: [Component, API, Group, Template, Location]
@@ -1,4 +1,4 @@
FROM node:12 AS build
FROM node:12-buster AS build
RUN mkdir /app
COPY . /app
@@ -7,7 +7,7 @@ description: Architecture Decision Record (ADR) log on Default Catalog File Name
## Background
While the spec for the catalog file format is well described in
[ADR002](./adr002-default-catalog-file-format.md), guidance was note provided as
[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as
to the name of the catalog file.
Following discussion in
@@ -23,4 +23,4 @@ catalog-info.yaml
```
This name is a default, **not a requirement**. The catalog file will work with
Backstage irregardless of its name.
Backstage regardless of its name.
@@ -0,0 +1,69 @@
---
id: adrs-adr009
title: ADR009: Entity References
description: Architecture Decision Record (ADR) log on Entity References
---
## Background
While the spec for the catalog file format is well described in
[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as
to how one is expected to express references to other entities in the catalog.
There was also some confusion on how to reference entities in URLs in the
Backstage frontend.
Following discussion in
[Issue 1947](https://github.com/spotify/backstage/issues/1947), a decision was
made.
## Entity References in YAML files
The textual format, as written by humans, to reference entities by name is on
the following form, where square brackets denote optionality:
```
[<kind>:][<namespace>/]<name>
```
That is, it is composed of between one and three parts in this specific order,
without any additional encoding, with those exact separator characters.
Optionality of `kind` and `namespace` are contextual, and they may or may not
have default contextual fallback values.
When that format is insufficient or when machine made interchange formats wish
to express such relations in a more expressive form, a nested structure on the
following form can be used:
```yaml
kind: <kind>
namespace: <namespace>
name: <name>
```
Of these, only `name` is always required. Optionality of `kind` and `namespace`
are contextual, and they may or may not have default contextual fallback values.
All other possible key values in this structure are reserved for future use.
A system or user wanting to express a full entity name that is always valid,
shall supply the entire triplet whether using the string form or the compound
form.
A full description of the format can be found
[in the documentation](https://backstage.io/docs/features/software-catalog/references).
## Entity References in URLs
Where entities are referenced by name in the Backstage frontend, the URL
containing the reference shall take the following form:
```
:namespace/:kind/:name
```
All three parts are required under all circumstances. The default value for the
`namespace` in the catalog is the string `"default"`, if the entity does not
specify one explicitly in `metadata.namespace`.
This means that we do not encourage the string form of entity references to be
used as a single URL segment, due to the use of URL-unsafe characters leading to
possible risk, confusion, and uglier URLs.
+4 -3
View File
@@ -229,9 +229,10 @@ name.
### Test the new provider
You can `curl -i localhost:7000/auth/providerA/start` and which should provide a
`302` redirect with a `Location` header. Paste the url from that header into a
web browser and you should be able to trigger the authorization flow.
You can `curl -i localhost:7000/api/auth/providerA/start` and which should
provide a `302` redirect with a `Location` header. Paste the url from that
header into a web browser and you should be able to trigger the authorization
flow.
---
@@ -22,6 +22,8 @@ humans. However, the structure and semantics is the same in both cases.
- [Kind: Component](#kind-component)
- [Kind: Template](#kind-template)
- [Kind: API](#kind-api)
- [Kind: Group](#kind-group)
- [Kind: User](#kind-user)
## Overall Shape Of An Entity
@@ -571,3 +573,167 @@ group of people in an organizational structure.
The definition of the API, based on the format defined by `spec.type`. This
field is required.
## Kind: Group
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `Group` |
A group describes an organizational entity, such as for example a team, a
business unit, or a loose collection of people in an interest group. Members of
these groups are modeled in the catalog as kind [`User`](#kind-user).
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: infrastructure
description: The infra business unit
spec:
type: business-unit
parent: ops
ancestors: [ops, global-synergies, acme-corp]
children: [backstage, other]
descendants: [backstage, other, team-a, team-b, team-c, team-d]
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `Group`, respectively.
### `spec.type` [required]
The type of group as a string, e.g. `team`. There is currently no enforced set
of values for this field, so it is left up to the adopting organization to
choose a nomenclature that matches their org hierarchy.
Some common values for this field could be:
- `team`
- `business-unit`
- `product-area`
- `root` - as a common virtual root of the hierarchy, if desired
### `spec.parent` [optional]
The immediate parent group in the hierarchy, if any. Not all groups must have a
parent; the catalog supports multi-root hierarchies. Groups may however not have
more than one parent.
This field is an
[entity reference](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
this field points to a group in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of that group.
### `spec.ancestors` [required]
The recursive list of parents up the hierarchy, by stepping through parents one
by one. The list must be present, but may be empty if `parent` is not present.
The first entry in the list is equal to `parent`, and then the following ones
are progressively farther up the hierarchy.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.children` [required]
The immediate child groups of this group in the hierarchy (whose `parent` field
points to this group). The list must be present, but may be empty if there are
no child groups. The items are not guaranteed to be ordered in any particular
way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.descendants` [required]
The immediate and recursive child groups of this group in the hierarchy
(children, and children's children, etc.). The list must be present, but may be
empty if there are no child groups. The items are not guaranteed to be ordered
in any particular way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
## Kind: User
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `User` |
A user describes a person, such as an employee, a contractor, or similar. Users
belong to [`Group`](#kind-group) entities in the catalog.
These catalog user entries are connected to the way that authentication within
the Backstage ecosystem works. See the [auth](https://backstage.io/docs/auth)
section of the docs for a discussion of these concepts.
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: jdoe
spec:
profile:
displayName: Jenny Doe
email: jenny-doe@example.com
picture: https://example.com/staff/jenny-with-party-hat.jpeg
memberOf: [team-b, employees]
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `User`, respectively.
### `spec.profile` [optional]
Optional profile information about the user, mainly for display purposes. All
fields of this structure are also optional. The email would be a primary email
of some form, that the user may wish to be used for contacting them. The picture
is expected to be a URL pointing to an image that's representative of the user,
and that a browser could fetch and render on a profile page or similar.
### `spec.memberOf` [required]
The list of groups that the user is a direct member of (i.e., no transitive
memberships are listed here). The list must be present, but may be empty if the
user is not member of any groups. The items are not guaranteed to be ordered in
any particular way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
@@ -0,0 +1,104 @@
---
id: references
title: Entity References
description: How to express references between entities
---
Entities commonly have a need to reference other entities. For example, a
[Component](descriptor-format.md#kind-component) entity may want to declare who
its owner is by mentioning a Group or User entity, and a User entity may want to
declare what Group entities it is a member of. This article describes how to
write those references in your yaml entity declaration files.
Each entity in the catalog is uniquely identified by the triplet of its
[kind](descriptor-format.md#apiversion-and-kind-required),
[namespace](descriptor-format.md#namespace-optional), and
[name](descriptor-format.md#name-required). But that's a lot to type out
manually, and in a lot of circumstances, both the kind and the namespace are
fixed, or possible to deduce, or could have sane default values. So in order to
help the writer, the catalog has a few tricks up its sleeve.
Each reference can be expressed in one of two ways: as a compact string, or as a
compound reference structure.
## String References
This is the most common alternative, that should be used in almost all
circumstances.
The string is on the form `[<kind>:][<namespace>/]<name>`, that is, it is
composed of between one and three parts in this specific order, without any
additional encoding:
- Optionally, the kind, followed by a colon
- Optionally, the namespace, followed by a forward slash
- The name
The name is always required. Depending on the context, you may be able to leave
out the kind and/or namespace. If you do, it is contextual what values will be
used, and the relevant documentation should specify which rule applies where.
All strings are case insensitive.
```yaml
# Example:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: petstore
namespace: external-systems
description: Petstore
spec:
type: service
lifecycle: experimental
owner: group:pet-managers
implementsApis:
- petstore
- internal/streetlights
- hello-world
```
The field `spec.owner` is a reference. In this case, the string
`group:pet-managers` was given by the user. That means that the kind is `Group`,
the namespace is left out, and the name is `pet-managers`. In this context, the
namespace was chosen to fall back to the value `default` by the code that parsed
the reference, so the end result is that we expect to find another entity in the
catalog that is of kind `Group`, namespace `default` (which, actually, also can
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
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
essentially expand to `api:external-systems/petstore`,
`api:internal/streetlights`, and `api:external-systems/hello-world`. We expect
there to exist three API kind entities in the catalog matching those references.
## Compound 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
colons or forward slashes. Avoid using it where possible, since it is harder to
read and write for humans.
```yaml
# Example:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: petstore
description: Petstore
spec:
type: service
lifecycle: experimental
owner:
kind: Group
name: aegis-imports/pet-managers
```
In this example, the `spec.owner` has been broken apart since the name was
complex. The kind happened to be written with an uppercase letter G, which also
works. The namespace was left out just like in the string version above, which
is handled identically.
@@ -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
easitly 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
@@ -88,7 +88,7 @@ running:
```sh
curl \
--location \
--request POST 'localhost:7000/catalog/locations' \
--request POST 'localhost:7000/api/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"file\", \"target\": \"${YOUR PATH HERE}/template.yaml\"}"
```
@@ -98,7 +98,7 @@ If loading from a Git location, you can run the following
```sh
curl \
--location \
--request POST 'localhost:7000/catalog/locations' \
--request POST 'localhost:7000/api/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"github\", \"target\": \"https://${YOUR GITHUB REPO}blob/master/${PATH TO FOLDER}/template.yaml\"}"
```
+2 -2
View File
@@ -57,8 +57,8 @@ The default storage and request URLs:
```yaml
techdocs:
storageUrl: http://localhost:7000/techdocs/static/docs
requestUrl: http://localhost:7000/techdocs/docs
storageUrl: http://localhost:7000/api/techdocs/static/docs
requestUrl: http://localhost:7000/api/techdocs/docs
```
If you want `techdocs-backend` to manage building and publishing, you want
+10
View File
@@ -0,0 +1,10 @@
---
id: troubleshooting
title: Troubleshooting TechDocs
sidebar_label: Troubleshooting
description: Troubleshooting for TechDocs
---
- TechDocs will fail to clone your docs if you have a git config which overrides
the `https` protocol with `ssh` or something else. Make sure to remove your
git config locally when you try TechDocs.
+3 -3
View File
@@ -6,10 +6,10 @@ info:
**Provided by `@backstage/auth-backend`.**
The purpose of the Auth APIs in Backstage are to identify the user, and to provide a way for plugins
The purpose of the Auth APIs in Backstage are to identify the user, and to provide a way for plugins
to request access to 3rd party services on behalf of that user.
The API is supplied with a list of providers - such as `Google` or `Github` - and will add the endpoints
The API is supplied with a list of providers - such as `Google` or `Github` - and will add the endpoints
described below to each of those providers.
Read more about [User Authentication and Authorization in Backstage](https://github.com/spotify/backstage/blob/master/docs/auth/overview.md).
@@ -21,7 +21,7 @@ externalDocs:
description: Backstage official documentation
url: https://github.com/spotify/backstage/blob/master/docs/README.md
servers:
- url: http://localhost:7000/auth/
- url: http://localhost:7000/api/auth/
tags:
- name: provider
description: List of endpoints per provider
+17
View File
@@ -4,6 +4,23 @@ title: Architecture overview
description: Documentation on Architecture overview
---
## Terminology
Backstage is constructed out of three parts. We separate Backstage in this way
because we see three groups of contributors that work with Backstage in three
different ways.
- Core - Base functionality built by core developers in the open source project.
- App - The app is an instance of a Backstage app that is deployed and tweaked.
The app ties together core functionality with additional plugins. The app is
built and maintained by app developers, usually a productivity team within a
company.
- Plugins - Additional functionality to make your Backstage app useful for your
company. Plugins can be specific to a company or open sourced and reusable. At
Spotify we have over 100 plugins built by over 50 different teams. It has been
very powerful to get contributions from various infrastructure teams added
into a single unified developer experience.
## Overview
The following diagram shows how Backstage might look when deployed inside a
-20
View File
@@ -1,20 +0,0 @@
---
id: architecture-terminology
title: Architecture terminology
description: Documentation on Architecture terminology
---
Backstage is constructed out of three parts. We separate Backstage in this way
because we see three groups of contributors that work with Backstage in three
different ways.
- Core - Base functionality built by core devs in the open source project.
- App - The app is an instance of a Backstage app that is deployed and tweaked.
The app ties together core functionality with additional plugins. The app is
built and maintained by app developers, usually a productivity team within a
company.
- Plugins - Additional functionality to make your Backstage app useful for your
company. Plugins can be specific to a company or open sourced and reusable. At
Spotify we have over 100 plugins built by over 50 different teams. It has been
very powerful to get contributions from various infrastructure teams added
into a single unified developer experience.
+8 -1
View File
@@ -1,7 +1,8 @@
---
id: background
title: The Spotify Story
description: Documentation on Background and Story behind making of Backstage
description: Backstage was born out of necessity at Spotify. We found that as we grew, our
infrastructure was becoming more fragmented, our engineers less productive.
---
Backstage was born out of necessity at Spotify. We found that as we grew, our
@@ -29,3 +30,9 @@ building a new microservice using an automated template in Backstage. Create,
maintain, and find the documentation for all that software in Backstage.
One place for everything. Accessible to everyone.
Backstage was originally built by Spotify and then donated to the CNCF.
Backstage is currently in the Sandbox phase. Read the announcement
[here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox).
<img src="https://backstage.io/img/cncf-white.svg" width="400" />
+24 -20
View File
@@ -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)
+2 -5
View File
@@ -13,10 +13,7 @@ description: Support and Community Details and Links
- [FAQ](../FAQ.md) - Frequently Asked Questions
- [Code of Conduct](../../CODE_OF_CONDUCT.md) - This is how we roll
- [Blog](https://backstage.io/blog/) - Announcements and updates
- [Newsletter](https://mailchi.mp/spotify/backstage-community)
- [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to
our email newsletter
- Give us a star ⭐️ - If you are using Backstage or think it is an interesting
project, we would love a star ❤️
Or, if you are an open source developer and are interested in joining our team,
please reach out to
[foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com)
+9 -2
View File
@@ -1,7 +1,7 @@
---
id: what-is-backstage
title: What is Backstage?
description: Backsatge is an open platform for building developer portals.
description: Backstage is an open platform for building developer portals.
Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure
---
@@ -33,7 +33,14 @@ Out of the box, Backstage includes:
[open source plugins](https://github.com/spotify/backstage/tree/master/plugins)
that further expand Backstages customizability and functionality
### Benefits
## Backstage and the CNCF
Backstage is a CNCF Sandbox project. Read the announcement
[here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox).
<img src="https://backstage.io/img/cncf-white.svg" width="400" />
## Benefits
- For _engineering managers_, it allows you to maintain standards and best
practices across the organization, and can help you manage your whole tech
+1 -1
View File
@@ -115,7 +115,7 @@ export AUTH_GITHUB_CLIENT_SECRET=xxx
> Log into http://github.com
> Navigate to (Settings > Developer Settings > OAuth Apps > New OAuth App)[https://github.com/settings/applications/new]
> Set Homepage URL = http://localhost:3000
> Set Callback URL = http://localhost:7000/auth/github
> Set Callback URL = http://localhost:7000/api/auth/github
> Click [Register application]
> On the next page, copy and paste your new Client ID and Client Secret to the environment variables above, `AUTH_GITHUB_CLIENT_ID` & `AUTH_GITHUB_CLIENT_SECRET`
> Don't forget to `source` that profile file again if necessary.
+1
View File
@@ -51,6 +51,7 @@ class Footer extends React.Component {
<a href="https://mailchi.mp/spotify/backstage-community">
Subscribe to our newsletter
</a>
<a href="https://www.cncf.io/sandbox-projects/">CNCF Sandbox</a>
</div>
<div>
<h5>More</h5>
+4 -2
View File
@@ -3,7 +3,6 @@
"Overview": [
"overview/what-is-backstage",
"overview/architecture-overview",
"overview/architecture-terminology",
"overview/roadmap",
"overview/vision",
"overview/background",
@@ -43,6 +42,7 @@
"features/software-catalog/configuration",
"features/software-catalog/system-model",
"features/software-catalog/descriptor-format",
"features/software-catalog/references",
"features/software-catalog/well-known-annotations",
"features/software-catalog/extending-the-model",
"features/software-catalog/external-integrations",
@@ -71,6 +71,7 @@
"features/techdocs/concepts",
"features/techdocs/architecture",
"features/techdocs/creating-and-publishing",
"features/techdocs/troubleshooting",
"features/techdocs/faqs"
]
}
@@ -158,7 +159,8 @@
"architecture-decisions/adrs-adr005",
"architecture-decisions/adrs-adr006",
"architecture-decisions/adrs-adr007",
"architecture-decisions/adrs-adr008"
"architecture-decisions/adrs-adr008",
"architecture-decisions/adrs-adr009"
],
"Contribute": ["../CONTRIBUTING"],
"Support": ["overview/support"],
+3 -1
View File
@@ -5,7 +5,6 @@ nav:
- Overview:
- What is Backstage?: 'overview/what-is-backstage.md'
- Backstage architecture: 'overview/architecture-overview.md'
- Architecture and terminology: 'overview/architecture-terminology.md'
- Roadmap: 'overview/roadmap.md'
- Vision: 'overview/vision.md'
- The Spotify story: 'overview/background.md'
@@ -32,6 +31,7 @@ nav:
- Configuration: 'features/software-catalog/configuration.md'
- System model: 'features/software-catalog/system-model.md'
- YAML File Format: 'features/software-catalog/descriptor-format.md'
- Entity References: 'features/software-catalog/references.md'
- Well-known Annotations: 'features/software-catalog/well-known-annotations.md'
- Extending the model: 'features/software-catalog/extending-the-model.md'
- External integrations: 'features/software-catalog/external-integrations.md'
@@ -51,6 +51,7 @@ nav:
- Concepts: 'features/techdocs/concepts.md'
- TechDocs Architecture: 'features/techdocs/architecture.md'
- Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md'
- Troubleshooting: 'features/techdocs/troubleshooting.md'
- FAQ: 'features/techdocs/FAQ.md'
- Plugins:
- Overview: 'plugins/index.md'
@@ -104,6 +105,7 @@ nav:
- ADR006 - Avoid React.FC and React.SFC: 'architecture-decisions/adr006-avoid-react-fc.md'
- ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md'
- ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md'
- ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md'
- Contribute: '../CONTRIBUTING.md'
- Support: 'overview/support.md'
- FAQ: FAQ.md
+1 -1
View File
@@ -31,7 +31,7 @@ describe('App', () => {
baseUrl: 'http://localhost:3003',
},
techdocs: {
storageUrl: 'http://localhost:7000/techdocs/static/docs',
storageUrl: 'http://localhost:7000/api/techdocs/static/docs',
},
},
context: 'test',
-12
View File
@@ -16,11 +16,8 @@
import {
errorApiRef,
discoveryApiRef,
UrlPatternDiscovery,
githubAuthApiRef,
createApiFactory,
configApiRef,
} from '@backstage/core';
import {
@@ -38,15 +35,6 @@ import {
} from '@roadiehq/backstage-plugin-github-pull-requests';
export const apis = [
// TODO(Rugvip): migrate to use /api
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/{{ pluginId }}`,
),
}),
createApiFactory({
api: graphQlBrowseApiRef,
deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef },
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import {
Router as GitHubActionsRouter,
isPluginApplicableToEntity as isGitHubActionsAvailable,
RecentWorkflowRunsCard,
Router as GitHubActionsRouter,
} from '@backstage/plugin-github-actions';
import {
Router as CloudbuildRouter,
@@ -25,16 +26,17 @@ 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,
@@ -66,16 +68,34 @@ 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;
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>
);
+7
View File
@@ -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,
},
];
@@ -0,0 +1,80 @@
/*
* 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 === '::') {
host = '::1';
} 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>;
};
+1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
FROM node:12
FROM node:12-buster
WORKDIR /usr/src/app
+1
View File
@@ -37,6 +37,7 @@
"dockerode": "^3.2.0",
"example-app": "^0.1.1-alpha.23",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.1",
"pg": "^8.3.0",
"pg-connection-string": "^2.3.0",
+19 -11
View File
@@ -22,12 +22,15 @@
* Happy hacking!
*/
import Router from 'express-promise-router';
import {
createDatabaseClient,
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { ConfigReader, AppConfig } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
@@ -57,7 +60,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
},
},
);
return { logger, database, config };
const discovery = SingleHostDiscovery.fromConfig(config);
return { logger, database, config, discovery };
};
}
@@ -78,19 +82,23 @@ async function main() {
const graphqlEnv = useHotMemoize(module, () => createEnv('graphql'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/sentry', await sentry(sentryEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/identity', await identity(identityEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/graphql', await graphql(graphqlEnv));
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
.loadConfig(configReader)
.addRouter('', await healthcheck(healthcheckEnv))
.addRouter('/catalog', await catalog(catalogEnv))
.addRouter('/rollbar', await rollbar(rollbarEnv))
.addRouter('/scaffolder', await scaffolder(scaffolderEnv))
.addRouter('/sentry', await sentry(sentryEnv))
.addRouter('/auth', await auth(authEnv))
.addRouter('/identity', await identity(identityEnv))
.addRouter('/techdocs', await techdocs(techdocsEnv))
.addRouter('/kubernetes', await kubernetes(kubernetesEnv))
.addRouter('/proxy', await proxy(proxyEnv, '/proxy'))
.addRouter('/graphql', await graphql(graphqlEnv))
.addRouter('/api', apiRouter)
.addRouter('', await app(appEnv));
await service.start().catch(err => {
+2 -1
View File
@@ -21,6 +21,7 @@ export default async function createPlugin({
logger,
database,
config,
discovery,
}: PluginEnvironment) {
return await createRouter({ logger, config, database });
return await createRouter({ logger, config, database, discovery });
}
+5 -2
View File
@@ -17,6 +17,9 @@
import { createRouter } from '@backstage/plugin-kubernetes-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
+6 -5
View File
@@ -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 });
}
+2
View File
@@ -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,
});
}
+2
View File
@@ -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
@@ -28,6 +28,7 @@ import {
GroupEntityV1alpha1Policy,
LocationEntityV1alpha1Policy,
TemplateEntityV1alpha1Policy,
UserEntityV1alpha1Policy,
} from './kinds';
import { EntityPolicy } from './types';
@@ -77,6 +78,7 @@ export class EntityPolicies implements EntityPolicy {
EntityPolicies.anyOf([
new ComponentEntityV1alpha1Policy(),
new GroupEntityV1alpha1Policy(),
new UserEntityV1alpha1Policy(),
new LocationEntityV1alpha1Policy(),
new TemplateEntityV1alpha1Policy(),
new ApiEntityV1alpha1Policy(),
+1 -6
View File
@@ -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;
+10 -1
View File
@@ -14,9 +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 {
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;
}
@@ -0,0 +1,363 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
describe('ref', () => {
describe('parseEntityName', () => {
it('handles some omissions', () => {
expect(parseEntityName('a:b/c')).toEqual({
kind: 'a',
namespace: 'b',
name: 'c',
});
expect(() => parseEntityName('b/c')).toThrow(/kind/);
expect(parseEntityName('a:c')).toEqual({
kind: 'a',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
expect(() => parseEntityName('c')).toThrow(/kind/);
});
it('rejects bad inputs', () => {
expect(() => parseEntityName(null as any)).toThrow();
expect(() => parseEntityName(7 as any)).toThrow();
expect(() => parseEntityName('a:b:c')).toThrow();
expect(() => parseEntityName('a/b/c')).toThrow();
expect(() => parseEntityName('a/b:c')).toThrow();
expect(() => parseEntityName('a:b/c/d')).toThrow();
expect(() => parseEntityName('a:b/c:d')).toThrow();
});
it('rejects empty parts in strings', () => {
// one is empty
expect(() => parseEntityName(':b/c')).toThrow();
expect(() => parseEntityName('a:/c')).toThrow();
expect(() => parseEntityName('a:b/')).toThrow();
// two are empty
expect(() => parseEntityName('a:/')).toThrow();
expect(() => parseEntityName(':b/')).toThrow();
expect(() => parseEntityName(':/c')).toThrow();
// three are empty
expect(() => parseEntityName(':/')).toThrow();
// one is left out, one empty
expect(() => parseEntityName('/c')).toThrow();
expect(() => parseEntityName('b/')).toThrow();
expect(() => parseEntityName(':c')).toThrow();
expect(() => parseEntityName('a:')).toThrow();
// nothing at all
expect(() => parseEntityName('')).toThrow();
});
it('rejects empty parts in compounds', () => {
// one is empty
expect(() =>
parseEntityName({ kind: '', namespace: 'b', name: 'c' }),
).toThrow();
expect(() => parseEntityName({ namespace: 'b', name: 'c' })).toThrow();
expect(() =>
parseEntityName({ kind: 'a', namespace: '', name: 'c' }),
).toThrow();
expect(() => parseEntityName({ kind: 'a', name: 'c' })).not.toThrow();
expect(() =>
parseEntityName({ kind: 'a', namespace: 'b', name: '' }),
).toThrow();
expect(() =>
parseEntityName({ kind: 'a', namespace: 'b' } as any),
).toThrow();
// two are empty
expect(() =>
parseEntityName({ kind: '', namespace: '', name: 'c' }),
).toThrow();
expect(() => parseEntityName({ name: 'c' })).toThrow();
expect(() =>
parseEntityName({ kind: '', namespace: 'b', name: '' }),
).toThrow();
expect(() => parseEntityName({ namespace: 'b' } as any)).toThrow();
expect(() =>
parseEntityName({ kind: 'a', namespace: '', name: '' }),
).toThrow();
expect(() => parseEntityName({ kind: 'a' } as any)).toThrow();
// three are empty
expect(() =>
parseEntityName({ kind: '', namespace: '', name: '' }),
).toThrow();
expect(() => parseEntityName({} as any)).toThrow();
// one is left out, one empty
expect(() => parseEntityName({ namespace: '', name: 'c' })).toThrow();
expect(() => parseEntityName({ namespace: 'b', name: '' })).toThrow();
expect(() => parseEntityName({ kind: '', name: 'c' })).toThrow();
expect(() => parseEntityName({ kind: 'a', name: '' })).toThrow();
});
it('adds defaults where necessary to strings', () => {
expect(
parseEntityName('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'a', namespace: 'b', name: 'c' });
expect(
parseEntityName('b/c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'x', namespace: 'b', name: 'c' });
expect(
parseEntityName('a:c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({
kind: 'a',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
expect(
parseEntityName('c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({
kind: 'x',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
});
it('adds defaults where necessary to compounds', () => {
expect(
parseEntityName(
{ kind: 'a', namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'a', namespace: 'b', name: 'c' });
expect(
parseEntityName(
{ namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'x', namespace: 'b', name: 'c' });
expect(
parseEntityName(
{ kind: 'a', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(
parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }),
).toEqual({ kind: 'a', namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c' });
expect(
parseEntityName(
{ name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({
kind: 'x',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
// empty strings are errors, not defaults
expect(() =>
parseEntityName(
{ kind: '', namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toThrow(/kind/);
expect(() =>
parseEntityName(
{ kind: 'a', namespace: '', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toThrow(/namespace/);
});
});
describe('parseEntityRef', () => {
it('handles some omissions', () => {
expect(parseEntityRef('a:b/c')).toEqual({
kind: 'a',
namespace: 'b',
name: 'c',
});
expect(parseEntityRef('b/c')).toEqual({
kind: undefined,
namespace: 'b',
name: 'c',
});
expect(parseEntityRef('a:c')).toEqual({
kind: 'a',
namespace: undefined,
name: 'c',
});
expect(parseEntityRef('c')).toEqual({
kind: undefined,
namespace: undefined,
name: 'c',
});
});
it('rejects bad inputs', () => {
expect(() => parseEntityRef(null as any)).toThrow();
expect(() => parseEntityRef(7 as any)).toThrow();
expect(() => parseEntityRef('a:b:c')).toThrow();
expect(() => parseEntityRef('a/b/c')).toThrow();
expect(() => parseEntityRef('a/b:c')).toThrow();
expect(() => parseEntityRef('a:b/c/d')).toThrow();
expect(() => parseEntityRef('a:b/c:d')).toThrow();
});
it('rejects empty parts in strings', () => {
// one is empty
expect(() => parseEntityRef(':b/c')).toThrow();
expect(() => parseEntityRef('a:/c')).toThrow();
expect(() => parseEntityRef('a:b/')).toThrow();
// two are empty
expect(() => parseEntityRef('a:/')).toThrow();
expect(() => parseEntityRef(':b/')).toThrow();
expect(() => parseEntityRef(':/c')).toThrow();
// three are empty
expect(() => parseEntityRef(':/')).toThrow();
// one is left out, one empty
expect(() => parseEntityRef('/c')).toThrow();
expect(() => parseEntityRef('b/')).toThrow();
expect(() => parseEntityRef(':c')).toThrow();
expect(() => parseEntityRef('a:')).toThrow();
// nothing at all
expect(() => parseEntityRef('')).toThrow();
});
it('rejects empty parts in compounds', () => {
// one is empty
expect(() =>
parseEntityRef({ kind: '', namespace: 'b', name: 'c' }),
).toThrow();
expect(() =>
parseEntityRef({ kind: 'a', namespace: '', name: 'c' }),
).toThrow();
expect(() =>
parseEntityRef({ kind: 'a', namespace: 'b', name: '' }),
).toThrow();
// two are empty
expect(() =>
parseEntityRef({ kind: '', namespace: '', name: 'c' }),
).toThrow();
expect(() =>
parseEntityRef({ kind: '', namespace: 'b', name: '' }),
).toThrow();
expect(() =>
parseEntityRef({ kind: 'a', namespace: '', name: '' }),
).toThrow();
// three are empty
expect(() =>
parseEntityRef({ kind: '', namespace: '', name: '' }),
).toThrow();
// one is left out, one empty
expect(() => parseEntityRef({ namespace: '', name: 'c' })).toThrow();
expect(() => parseEntityRef({ namespace: 'b', name: '' })).toThrow();
expect(() => parseEntityRef({ kind: '', name: 'c' })).toThrow();
expect(() => parseEntityRef({ kind: 'a', name: '' })).toThrow();
// nothing at all
expect(() => parseEntityRef({} as any)).toThrow();
});
it('adds defaults where necessary to strings', () => {
expect(
parseEntityRef('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'a', namespace: 'b', name: 'c' });
expect(
parseEntityRef('b/c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'x', namespace: 'b', name: 'c' });
expect(
parseEntityRef('a:c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(
parseEntityRef('c', { defaultKind: 'x', defaultNamespace: 'y' }),
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
});
it('adds defaults where necessary to compounds', () => {
expect(
parseEntityRef(
{ kind: 'a', namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'a', namespace: 'b', name: 'c' });
expect(
parseEntityRef(
{ namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'x', namespace: 'b', name: 'c' });
expect(
parseEntityRef(
{ kind: 'a', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(
parseEntityRef(
{ name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
// empty strings are errors, not defaults
expect(() =>
parseEntityRef(
{ kind: '', namespace: 'b', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toThrow(/kind/);
expect(() =>
parseEntityRef(
{ kind: 'a', namespace: '', name: 'c' },
{ defaultKind: 'x', defaultNamespace: 'y' },
),
).toThrow(/namespace/);
});
});
describe('serializeEntityRef', () => {
it('handles partials', () => {
expect(
serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }),
).toEqual('a:b/c');
expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c');
expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c');
expect(serializeEntityRef({ name: 'c' })).toEqual('c');
});
it('picks the least complex form', () => {
expect(
serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }),
).toEqual('a:b/c');
expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c');
expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c');
expect(serializeEntityRef({ name: 'c' })).toEqual('c');
expect(
serializeEntityRef({ kind: 'a:x', namespace: 'b', name: 'c' }),
).toEqual({ kind: 'a:x', namespace: 'b', name: 'c' });
expect(
serializeEntityRef({ kind: 'a/x', namespace: 'b', name: 'c' }),
).toEqual({ kind: 'a/x', namespace: 'b', name: 'c' });
expect(
serializeEntityRef({ kind: 'a', namespace: 'b:x', name: 'c' }),
).toEqual({ kind: 'a', namespace: 'b:x', name: 'c' });
expect(
serializeEntityRef({ kind: 'a', namespace: 'b/x', name: 'c' }),
).toEqual({ kind: 'a', namespace: 'b/x', name: 'c' });
expect(
serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c:x' }),
).toEqual({ kind: 'a', namespace: 'b', name: 'c:x' });
expect(
serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c/x' }),
).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' });
});
});
});
+181
View File
@@ -0,0 +1,181 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityName, 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.
*/
type EntityRefContext = {
/** The default kind, if none is given in the reference */
defaultKind?: string;
/** The default namespace, if none is given in the reference */
defaultNamespace?: string;
};
/**
* Parses an entity reference, either on string or compound form, and always
* returns a complete entity name including kind, namespace and name.
*
* This function automatically assumes the default namespace "default" unless
* otherwise specified as part of the options, and will throw an error if no
* kind was specified in the input reference and no default kind was given.
*
* @param ref The reference to parse
* @param context The context of defaults that the parsing happens within
* @returns A complete entity name
*/
export function parseEntityName(
ref: EntityRef,
context: EntityRefContext = {},
): EntityName {
const { kind, namespace, name } = parseEntityRef(ref, {
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
...context,
});
if (!kind) {
throw new Error(
`Entity reference ${namespace}/${name} did not contain a kind`,
);
}
return { kind, namespace, name };
}
/**
* Parses an entity reference, either on string or compound form, and returns
* a structure with a name, and optional kind and namespace.
*
* The options object can contain default values for the kind and namespace,
* that will be used if the input reference did not specify any.
*
* @param ref The reference to parse
* @param context The context of defaults that the parsing happens within
* @returns The compound form of the reference
*/
export function parseEntityRef(
ref: EntityRef,
context?: { defaultKind: string },
): {
kind: string;
namespace?: string;
name: string;
};
export function parseEntityRef(
ref: EntityRef,
context?: { defaultNamespace: string },
): {
kind?: string;
namespace: string;
name: string;
};
export function parseEntityRef(
ref: EntityRef,
context?: { defaultKind: string; defaultNamespace: string },
): {
kind: string;
namespace: string;
name: string;
};
export function parseEntityRef(
ref: EntityRef,
context: EntityRefContext = {},
): {
kind?: string;
namespace?: string;
name: string;
} {
if (!ref) {
throw new Error(`Entity reference must not be empty`);
}
if (typeof ref === 'string') {
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
if (!match) {
throw new Error(
`Entity reference "${ref}" was not on the form [<kind>:][<namespace>/]<name>`,
);
}
return {
kind: match[1]?.slice(0, -1) ?? context.defaultKind,
namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace,
name: match[3],
};
}
const { kind, namespace, name } = ref;
if (kind === '') {
throw new Error('Entity reference kinds must not be empty');
} else if (namespace === '') {
throw new Error('Entity reference namespaces must not be empty');
} else if (!name) {
throw new Error('Entity references must contain a name');
}
return {
kind: kind ?? context.defaultKind,
namespace: namespace ?? context.defaultNamespace,
name,
};
}
/**
* Takes an entity reference or name, and outputs an entity reference on the
* most compact form possible. I.e. if the parts do not contain any
* special/reserved characters, it outputs the string form, otherwise it
* outputs the compound form.
*
* @param ref The reference to serialize
* @returns The same reference on either string or compound form
*/
export function serializeEntityRef(ref: {
kind?: string;
namespace?: string;
name: string;
}): EntityRef {
const { kind, namespace, name } = ref;
if (
kind?.includes(':') ||
kind?.includes('/') ||
namespace?.includes(':') ||
namespace?.includes('/') ||
name.includes(':') ||
name.includes('/')
) {
return { kind, namespace, name };
}
return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`;
}
+1 -1
View File
@@ -18,5 +18,5 @@ export * from './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export * from './location';
export type { EntityPolicy, JSONSchema } from './types';
export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types';
export * from './validation';
@@ -0,0 +1,150 @@
/*
* 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 { EntityPolicy } from '../types';
import {
UserEntityV1alpha1,
UserEntityV1alpha1Policy,
} from './UserEntityV1alpha1';
describe('UserV1alpha1Policy', () => {
let entity: UserEntityV1alpha1;
let policy: EntityPolicy;
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'doe',
},
spec: {
profile: {
displayName: 'John Doe',
email: 'john@doe.org',
picture: 'https://doe.org/john.jpeg',
},
memberOf: ['team-a', 'developers'],
},
};
policy = new UserEntityV1alpha1Policy();
});
it('happy path: accepts valid data', async () => {
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// root
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/);
});
it('rejects unknown kind', async () => {
(entity as any).kind = 'Wizard';
await expect(policy.enforce(entity)).rejects.toThrow(/kind/);
});
it('spec accepts unknown additional fields', async () => {
(entity as any).spec.foo = 'data';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// profile
it('accepts missing profile', async () => {
delete (entity as any).spec.profile;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects wrong profile', async () => {
(entity as any).spec.profile = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/profile/);
});
it('profile accepts missing displayName', async () => {
delete (entity as any).spec.profile.displayName;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong displayName', async () => {
(entity as any).spec.profile.displayName = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
});
it('profile rejects empty displayName', async () => {
(entity as any).spec.profile.displayName = '';
await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
});
it('profile accepts missing email', async () => {
delete (entity as any).spec.profile.email;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong email', async () => {
(entity as any).spec.profile.email = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/email/);
});
it('profile rejects empty email', async () => {
(entity as any).spec.profile.email = '';
await expect(policy.enforce(entity)).rejects.toThrow(/email/);
});
it('profile accepts missing picture', async () => {
delete (entity as any).spec.profile.picture;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong picture', async () => {
(entity as any).spec.profile.picture = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
});
it('profile rejects empty picture', async () => {
(entity as any).spec.profile.picture = '';
await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
});
it('profile accepts unknown additional fields', async () => {
(entity as any).spec.profile.foo = 'data';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// memberOf
it('rejects missing memberOf', async () => {
delete (entity as any).spec.memberOf;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf', async () => {
(entity as any).spec.memberOf = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf item', async () => {
(entity as any).spec.memberOf[0] = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
});
@@ -0,0 +1,62 @@
/*
* 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 * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'User' as const;
export interface UserEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
spec: {
profile?: {
displayName?: string;
email?: string;
picture?: string;
};
memberOf: string[];
};
}
export class UserEntityV1alpha1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<UserEntityV1alpha1>>({
apiVersion: yup.string().required().oneOf(API_VERSION),
kind: yup.string().required().equals([KIND]),
spec: yup
.object({
profile: yup
.object({
displayName: yup.string().min(1).notRequired(),
email: yup.string().min(1).notRequired(),
picture: yup.string().min(1).notRequired(),
})
.notRequired(),
memberOf: yup.array(yup.string()).required(),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
return await this.schema.validate(envelope, { strict: true });
}
}
+9 -4
View File
@@ -14,6 +14,11 @@
* limitations under the License.
*/
export { ApiEntityV1alpha1Policy } from './ApiEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
ApiEntityV1alpha1,
} from './ApiEntityV1alpha1';
export { ComponentEntityV1alpha1Policy } from './ComponentEntityV1alpha1';
export type {
ComponentEntityV1alpha1 as ComponentEntity,
@@ -34,8 +39,8 @@ export type {
TemplateEntityV1alpha1 as TemplateEntity,
TemplateEntityV1alpha1,
} from './TemplateEntityV1alpha1';
export { ApiEntityV1alpha1Policy } from './ApiEntityV1alpha1';
export { UserEntityV1alpha1Policy } from './UserEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
ApiEntityV1alpha1,
} from './ApiEntityV1alpha1';
UserEntityV1alpha1 as UserEntity,
UserEntityV1alpha1,
} from './UserEntityV1alpha1';
@@ -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';
+26
View File
@@ -34,3 +34,29 @@ export type EntityPolicy = {
};
export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue };
/**
* A complete entity name, with the full kind-namespace-name triplet.
*/
export type EntityName = {
kind: string;
namespace: string;
name: string;
};
/**
* A reference by name to an entity, either as a compact string representation,
* or as a compound reference structure.
*
* The string representation is on the form [<kind>:][<namespace>/]<name>.
*
* Left-out parts of the reference need to be handled by the application,
* either by rejecting the reference or by falling back to default values.
*/
export type EntityRef =
| string
| {
kind?: string;
namespace?: string;
name: string;
};
@@ -231,7 +231,11 @@ export default async (cmd: Command) => {
const privatePackage = cmd.private === false ? false : true;
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
const appPackage = paths.resolveTargetRoot('packages/app');
const templateDir = paths.resolveOwn('templates/default-plugin');
const templateDir = paths.resolveOwn(
cmd.backend
? 'templates/default-backend-plugin'
: 'templates/default-plugin',
);
const tempDir = resolvePath(os.tmpdir(), answers.id);
const pluginDir = isMonoRepo
? paths.resolveTargetRoot('plugins', answers.id)
@@ -252,6 +256,7 @@ export default async (cmd: Command) => {
await createTemporaryPluginFolder(tempDir);
Task.section('Preparing files');
await templatingTask(templateDir, tempDir, {
...answers,
version,
@@ -267,7 +272,7 @@ export default async (cmd: Command) => {
Task.section('Building the plugin');
await buildPlugin(pluginDir);
if (await fs.pathExists(appPackage)) {
if ((await fs.pathExists(appPackage)) && !cmd.backend) {
Task.section('Adding plugin as dependency in app');
await addPluginDependencyToApp(paths.targetRoot, name, version);
+4
View File
@@ -61,6 +61,10 @@ export function registerCommands(program: CommanderStatic) {
program
.command('create-plugin')
.option(
'--backend',
'Create plugin with the backend dependencies as default',
)
.description('Creates a new plugin in the current repository')
.option('--scope <scope>', 'NPM scope')
.option('--npm-registry <URL>', 'NPM registry URL')
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,14 @@
# {{id}}
Welcome to the {{id}} backend plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn
start` in the root directory, and then navigating to [/{{id}}](http://localhost:3000/{{id}}).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
@@ -0,0 +1,44 @@
{
"name": "{{name}}",
"version": "{{version}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
{{#if privatePackage}} "private": {{privatePackage}},
{{/if}}
"publishConfig": {
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
{{/if}}
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^{{version}}",
"@backstage/config": "^{{version}}",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"winston": "^3.2.1",
"node-fetch": "^2.6.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"msw": "^0.20.5"
},
"files": [
"dist"
]
}
@@ -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 * from './service/router';
@@ -0,0 +1,33 @@
/*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,45 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,40 @@
/*
* 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 { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
export interface RouterOptions {
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.send({ status: 'ok' });
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,47 @@
/*
* 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 { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: '{{id}}-backend' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000' })
.addRouter('/{{id}}', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -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 {};
global.fetch = require('node-fetch');
@@ -41,7 +41,8 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
"msw": "^0.20.5",
"node-fetch": "^2.6.1"
},
"files": [
"dist"
@@ -1,18 +1,35 @@
import React from 'react';
import { render } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import ExampleComponent from './ExampleComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
describe('ExampleComponent', () => {
const server = setupServer();
// Enable API mocking before tests.
beforeAll(() => server.listen())
// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())
// Disable API mocking after the tests are done.
afterAll(() => server.close())
// setup mock response
beforeEach(() => {
server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))))
})
it('should render', () => {
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ExampleComponent />
</ThemeProvider>,
);
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
);
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
});
});
@@ -1,11 +1,25 @@
import React from 'react';
import { render } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import ExampleFetchComponent from './ExampleFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
describe('ExampleFetchComponent', () => {
const server = setupServer();
// Enable API mocking before tests.
beforeAll(() => server.listen())
// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())
// Disable API mocking after the tests are done.
afterAll(() => server.close())
// setup mock response
beforeEach(() => {
server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({}))))
})
it('should render', async () => {
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(<ExampleFetchComponent />);
expect(await rendered.findByTestId('progress')).toBeInTheDocument();
});
@@ -1,3 +1,2 @@
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
global.fetch = require('node-fetch');
@@ -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);
+60 -12
View File
@@ -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;
}
}
+2 -2
View File
@@ -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';
+12 -6
View File
@@ -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 }),
}),
];
@@ -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>
);
@@ -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>;
};
@@ -21,6 +21,7 @@ import {
oauth2ApiRef,
oktaAuthApiRef,
microsoftAuthApiRef,
samlAuthApiRef,
useApi,
} from '@backstage/core-api';
import Star from '@material-ui/icons/Star';
@@ -69,6 +70,13 @@ export const DefaultProviderSettings = () => {
icon={Star}
/>
)}
{providers.includes('saml') && (
<ProviderSettingsItem
title="SAML"
apiRef={samlAuthApiRef}
icon={Star}
/>
)}
{providers.includes('oauth2') && (
<ProviderSettingsItem
title="YourOrg"
@@ -9,3 +9,5 @@ backend:
origin: http://localhost:3000
methods: [GET, POST, PUT, DELETE]
credentials: true
csp:
connect-src: ["'self'", 'http:', 'https:']
@@ -9,6 +9,8 @@ backend:
baseUrl: http://localhost:7000
listen:
port: 7000
csp:
connect-src: ["'self'", 'https:']
{{#if dbTypeSqlite}}
database:
client: sqlite3
@@ -44,8 +46,8 @@ proxy:
changeOrigin: true
techdocs:
storageUrl: http://localhost:7000/techdocs/static/docs
requestUrl: http://localhost:7000/techdocs/docs
storageUrl: http://localhost:7000/api/techdocs/static/docs
requestUrl: http://localhost:7000/api/techdocs/docs
generators:
techdocs: 'docker'
@@ -12,7 +12,7 @@ describe('App', () => {
app: { title: 'Test' },
backend: { baseUrl: 'http://localhost:7000' },
techdocs: {
storageUrl: 'http://localhost:7000/techdocs/static/docs',
storageUrl: 'http://localhost:7000/api/techdocs/static/docs',
},
},
context: 'test',
@@ -1,4 +1,4 @@
FROM node:12
FROM node:12-buster
WORKDIR /usr/src/app

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