Merge branch 'master' of https://github.com/spotify/backstage
This commit is contained in:
+19
-3
@@ -8,13 +8,29 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re
|
||||
|
||||
> Collect changes for the next release below
|
||||
|
||||
### @backstage/core
|
||||
### Backend (example-backend, or backends created with @backstage/create-app)
|
||||
|
||||
- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555).
|
||||
- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/spotify/backstage/pull/2600)
|
||||
|
||||
### @backstage/auth-backend
|
||||
|
||||
- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. The default base path is `/api/auth`, but you need to set this to `/auth` if you want to keep using the old path. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562)
|
||||
- The auth backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
### @backstage/proxy-backend
|
||||
|
||||
- The proxy backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
### @backstage/techdocs-backend
|
||||
|
||||
- The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`.
|
||||
|
||||
## v0.1.1-alpha.23
|
||||
|
||||
### @backstage/core
|
||||
|
||||
- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555).
|
||||
|
||||
## v0.1.1-alpha.22
|
||||
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12 AS build
|
||||
FROM node:12-buster AS build
|
||||
|
||||
RUN mkdir /app
|
||||
COPY . /app
|
||||
|
||||
@@ -97,7 +97,7 @@ app, and the app itself.
|
||||
|
||||
### Core APIs
|
||||
|
||||
Starting with the Backstage core library, it provides implementation for all of
|
||||
Starting with the Backstage core library, it provides implementations for all of
|
||||
the core APIs. The core APIs are the ones exported by `@backstage/core`, such as
|
||||
the `errorApiRef` and `configApiRef`. You can find a full list of them
|
||||
[here](../reference/utility-apis/README.md).
|
||||
@@ -113,7 +113,7 @@ While doing so they should usually also provide default implementations of their
|
||||
own APIs, for example, the `catalog` plugin exports `catalogApiRef`, and also
|
||||
supplies a default `ApiFactory` of that API using the `CatalogClient`. There is
|
||||
one restriction to plugin-provided API Factories: plugins may not supply
|
||||
factories for core APIs, trying to do so will cause the app to crash.
|
||||
factories for core APIs, trying to do so will cause the app to refuse to start.
|
||||
|
||||
Plugins supply their APIs through the `apis` option of `createPlugin`, for
|
||||
example:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+24
-20
@@ -27,10 +27,11 @@ We have divided the project into three high-level _phases_:
|
||||
With a single catalog, Backstage makes it easy for a team to manage ten
|
||||
services — and makes it possible for your company to manage thousands of them.
|
||||
|
||||
- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is
|
||||
different. By fostering a vibrant community of contributors we hope to provide
|
||||
an ecosystem of Open Source plugins/integrations that allows you to pick the
|
||||
tools that match your stack.
|
||||
- 🐇 **Phase 3:** Ecosystem (ongoing, see
|
||||
[Plugin Marketplace](https://backstage.io/plugins)) - Everyone's
|
||||
infrastructure stack is different. By fostering a vibrant community of
|
||||
contributors we hope to provide an ecosystem of Open Source
|
||||
plugins/integrations that allows you to pick the tools that match your stack.
|
||||
|
||||
## Detailed roadmap
|
||||
|
||||
@@ -53,24 +54,24 @@ guidelines to get started.
|
||||
it much easier to see how a plugin can be built that integrates with the
|
||||
Backstage Service Catalog.
|
||||
|
||||
- **[Kubernetes support](https://github.com/spotify/backstage/milestone/20)** -
|
||||
Native support for Kubernetes, making it easier for developers to see and
|
||||
manage their services running in k8s.
|
||||
|
||||
- **[Helm charts](https://github.com/spotify/backstage/issues/2540)** - Provide
|
||||
Helm charts for easy deployments of Backstage and its subsystems on
|
||||
Kubernetes.
|
||||
|
||||
- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** -
|
||||
The platform APIs and features are stable and can be depended on for
|
||||
production use. After this plugins will require little to no maintenance.
|
||||
|
||||
- **Backstage Design System** - By providing design guidelines for common plugin
|
||||
layouts together, rich set of reusable UI components
|
||||
([Storybook](https://backstage.io/storybook)) and Figma design resources. The
|
||||
Design System will make it easy to design and build plugins that are
|
||||
consistent across the platform -- supporting both developers and designers.
|
||||
|
||||
- **[TechDocs v1](https://github.com/spotify/backstage/milestone/16)** - Our
|
||||
docs-like-code feature TechDocs working end to end.
|
||||
|
||||
- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** -
|
||||
A GraphQL API will open up the rich metadata provided by Backstage in a single
|
||||
query. Plugins can easily query this API as well as extend the model where
|
||||
needed.
|
||||
|
||||
- **Production deployments** - Provide instructions and default configurations
|
||||
(e.g. through Helm charts) for easy deployments of Backstage and its
|
||||
subsystems on Kubernetes.
|
||||
|
||||
- **Cloud Cost Insights plugin (from Spotify)** - Spotify teams are fully
|
||||
responsible for their own software, including the cost of the cloud resources
|
||||
they use. By making our internal cost insights plugin available as open source
|
||||
@@ -96,10 +97,6 @@ Chances are that someone will jump in and help build it.
|
||||
|
||||
### Future work 🔮
|
||||
|
||||
- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** -
|
||||
The platform APIs and features are stable and can be depended on for
|
||||
production use. After this plugins will require little to no maintenance.
|
||||
|
||||
- **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage
|
||||
deployment available publicly so that people can click around and get a feel
|
||||
for the product without having to install anything.
|
||||
@@ -118,8 +115,15 @@ Chances are that someone will jump in and help build it.
|
||||
[AWS](https://github.com/spotify/backstage/issues/290),
|
||||
[Azure](https://github.com/spotify/backstage/issues/348) and others.
|
||||
|
||||
- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** -
|
||||
A GraphQL API will open up the rich metadata provided by Backstage in a single
|
||||
query. Plugins can easily query this API as well as extend the model where
|
||||
needed.
|
||||
|
||||
### Completed milestones ✅
|
||||
|
||||
- [Donate Backstage to the CNCF 🎉](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox)
|
||||
- [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs)
|
||||
- [Plugin marketplace](https://backstage.io/plugins)
|
||||
- [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage)
|
||||
- [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)
|
||||
|
||||
@@ -23,7 +23,7 @@ const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
|
||||
const service = createServiceBuilder(module)
|
||||
.loadConfig(configReader)
|
||||
/** ... other routers ... */
|
||||
.addRouter('/proxy', await proxy(proxyEnv, '/proxy'));
|
||||
.addRouter('/proxy', await proxy(proxyEnv));
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -21,6 +21,9 @@ out a new branch that you will use for the release, e.g.
|
||||
$ git checkout -b new-release
|
||||
```
|
||||
|
||||
First bump the `CHANGELOG.md` in the root of the repo and commit. You bump it by
|
||||
adding a header for the new version just below the `## Next Release` one.
|
||||
|
||||
Then, from the root of the repo, run
|
||||
|
||||
```sh
|
||||
|
||||
@@ -42,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",
|
||||
@@ -70,6 +71,7 @@
|
||||
"features/techdocs/concepts",
|
||||
"features/techdocs/architecture",
|
||||
"features/techdocs/creating-and-publishing",
|
||||
"features/techdocs/troubleshooting",
|
||||
"features/techdocs/faqs"
|
||||
]
|
||||
}
|
||||
@@ -157,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"],
|
||||
|
||||
@@ -31,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'
|
||||
@@ -50,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'
|
||||
@@ -103,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
|
||||
|
||||
@@ -14,23 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
Router as GitHubActionsRouter,
|
||||
isPluginApplicableToEntity as isGitHubActionsAvailable,
|
||||
RecentWorkflowRunsCard,
|
||||
Router as GitHubActionsRouter,
|
||||
} from '@backstage/plugin-github-actions';
|
||||
import {
|
||||
Router as JenkinsRouter,
|
||||
isPluginApplicableToEntity as isJenkinsAvailable,
|
||||
LatestRunCard as JenkinsLatestRunCard,
|
||||
Router as JenkinsRouter,
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import {
|
||||
Router as CircleCIRouter,
|
||||
isPluginApplicableToEntity as isCircleCIAvailable,
|
||||
Router as CircleCIRouter,
|
||||
} from '@backstage/plugin-circleci';
|
||||
import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
|
||||
import { Router as SentryRouter } from '@backstage/plugin-sentry';
|
||||
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
|
||||
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
AboutCard,
|
||||
EntityPageLayout,
|
||||
@@ -60,16 +61,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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,83 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { PluginEndpointDiscovery } from './types';
|
||||
import { readBaseOptions } from '../service/lib/config';
|
||||
import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl';
|
||||
|
||||
/**
|
||||
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
|
||||
* that assumes that all plugins are hosted in a single deployment.
|
||||
*
|
||||
* The deployment may be scaled horizontally, as long as the external URL
|
||||
* is the same for all instances. However, internal URLs will always be
|
||||
* resolved to the same host, so there won't be any balancing of internal traffic.
|
||||
*/
|
||||
export class SingleHostDiscovery implements PluginEndpointDiscovery {
|
||||
/**
|
||||
* Creates a new SingleHostDiscovery discovery instance by reading
|
||||
* from the `backend` config section, specifically the `.baseUrl` for
|
||||
* discovering the external URL, and the `.listen` and `.https` config
|
||||
* for the internal one.
|
||||
*
|
||||
* The basePath defaults to `/api`, meaning the default full internal
|
||||
* path for the `catalog` plugin will be `http://localhost:7000/api/catalog`.
|
||||
*/
|
||||
static fromConfig(config: Config, options?: { basePath?: string }) {
|
||||
const basePath = options?.basePath ?? '/api';
|
||||
const externalBaseUrl = config.getString('backend.baseUrl');
|
||||
|
||||
const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions(
|
||||
config.getConfig('backend'),
|
||||
);
|
||||
const protocol = config.has('backend.https') ? 'https' : 'http';
|
||||
|
||||
// Translate bind-all to localhost, and support IPv6
|
||||
let host = listenHost;
|
||||
if (host === '::') {
|
||||
// We use localhost instead of ::1, since IPv6-compatible systems should default
|
||||
// to using IPv6 when they see localhost, but if the system doesn't support IPv6
|
||||
// things will still work.
|
||||
host = 'localhost';
|
||||
} else if (host === '0.0.0.0') {
|
||||
host = '127.0.0.1';
|
||||
}
|
||||
if (host.includes(':')) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
|
||||
|
||||
return new SingleHostDiscovery(
|
||||
internalBaseUrl + basePath,
|
||||
externalBaseUrl + basePath,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly internalBaseUrl: string,
|
||||
private readonly externalBaseUrl: string,
|
||||
) {}
|
||||
|
||||
async getBaseUrl(pluginId: string): Promise<string> {
|
||||
return `${this.internalBaseUrl}/${pluginId}`;
|
||||
}
|
||||
|
||||
async getExternalBaseUrl(pluginId: string): Promise<string> {
|
||||
return `${this.externalBaseUrl}/${pluginId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SingleHostDiscovery } from './SingleHostDiscovery';
|
||||
export type { PluginEndpointDiscovery } from './types';
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The PluginEndpointDiscovery is used to provide a mechanism for backend
|
||||
* plugins to discover the endpoints for itself or other backend plugins.
|
||||
*
|
||||
* The purpose of the discovery API is to allow for many different deployment
|
||||
* setups and routing methods through a central configuration, instead
|
||||
* of letting each individual plugin manage that configuration.
|
||||
*
|
||||
* Implementations of the discovery API can be as simple as a URL pattern
|
||||
* using the pluginId, but could also have overrides for individual plugins,
|
||||
* or query a separate discovery service.
|
||||
*/
|
||||
export type PluginEndpointDiscovery = {
|
||||
/**
|
||||
* Returns the internal HTTP base URL for a given plugin, without a trailing slash.
|
||||
*
|
||||
* The returned URL should point to an internal endpoint for the plugin, with
|
||||
* the shortest route possible. The URL should be used for service-to-service
|
||||
* communication within a Backstage backend deployment.
|
||||
*
|
||||
* This method must always be called just before making a request, as opposed to
|
||||
* fetching the URL when constructing an API client. That is to ensure that more
|
||||
* flexible routing patterns can be supported.
|
||||
*
|
||||
* For example, asking for the URL for `catalog` may return something
|
||||
* like `http://10.1.2.3/api/catalog`
|
||||
*/
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Returns the external HTTP base backend URL for a given plugin, without a trailing slash.
|
||||
*
|
||||
* The returned URL should point to an external endpoint for the plugin, such that
|
||||
* it is reachable from the Backstage frontend and other external services. The returned
|
||||
* URL should be usable for example as a callback / webhook URL.
|
||||
*
|
||||
* The returned URL should be stable and in general not change unless other static
|
||||
* or external configuration is changed. Changes should not come as a surprise
|
||||
* to an operator of the Backstage backend.
|
||||
*
|
||||
* For example, asking for the URL for `catalog` may return something
|
||||
* like `https://backstage.example.com/api/catalog`
|
||||
*/
|
||||
getExternalBaseUrl(pluginId: string): Promise<string>;
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './config';
|
||||
export * from './database';
|
||||
export * from './discovery';
|
||||
export * from './errors';
|
||||
export * from './logging';
|
||||
export * from './middleware';
|
||||
|
||||
@@ -31,23 +31,40 @@ import {
|
||||
} from '../../middleware';
|
||||
import { ServiceBuilder } from '../types';
|
||||
import {
|
||||
CspOptions,
|
||||
HttpsSettings,
|
||||
readBaseOptions,
|
||||
readCorsOptions,
|
||||
readCspOptions,
|
||||
readHttpsSettings,
|
||||
HttpsSettings,
|
||||
} from './config';
|
||||
import { createHttpServer, createHttpsServer } from './hostFactory';
|
||||
import { metricsHandler } from './metrics';
|
||||
|
||||
const DEFAULT_PORT = 7000;
|
||||
export const DEFAULT_PORT = 7000;
|
||||
// '' is express default, which listens to all interfaces
|
||||
const DEFAULT_HOST = '';
|
||||
// taken from the helmet source code - don't seem to be exported
|
||||
const DEFAULT_CSP = {
|
||||
'default-src': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'block-all-mixed-content': [],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'object-src': ["'none'"],
|
||||
'script-src': ["'self'"],
|
||||
'script-src-attr': ["'none'"],
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'upgrade-insecure-requests': [],
|
||||
};
|
||||
|
||||
export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private port: number | undefined;
|
||||
private host: string | undefined;
|
||||
private logger: Logger | undefined;
|
||||
private corsOptions: cors.CorsOptions | undefined;
|
||||
private cspOptions: CspOptions | undefined;
|
||||
private httpsSettings: HttpsSettings | undefined;
|
||||
private enableMetrics: boolean = true;
|
||||
private routers: [string, Router][];
|
||||
@@ -79,6 +96,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
this.corsOptions = corsOptions;
|
||||
}
|
||||
|
||||
const cspOptions = readCspOptions(backendConfig);
|
||||
if (cspOptions) {
|
||||
this.cspOptions = cspOptions;
|
||||
}
|
||||
|
||||
const httpsSettings = readHttpsSettings(backendConfig);
|
||||
if (httpsSettings) {
|
||||
this.httpsSettings = httpsSettings;
|
||||
@@ -115,6 +137,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
setCsp(options: CspOptions): ServiceBuilder {
|
||||
this.cspOptions = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
addRouter(root: string, router: Router): ServiceBuilder {
|
||||
this.routers.push([root, router]);
|
||||
return this;
|
||||
@@ -127,10 +154,20 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host,
|
||||
logger,
|
||||
corsOptions,
|
||||
cspOptions,
|
||||
httpsSettings,
|
||||
} = this.getOptions();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...DEFAULT_CSP,
|
||||
...cspOptions,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (corsOptions) {
|
||||
app.use(cors(corsOptions));
|
||||
}
|
||||
@@ -177,6 +214,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host: string;
|
||||
logger: Logger;
|
||||
corsOptions?: cors.CorsOptions;
|
||||
cspOptions?: CspOptions;
|
||||
httpsSettings?: HttpsSettings;
|
||||
} {
|
||||
return {
|
||||
@@ -184,6 +222,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
host: this.host ?? DEFAULT_HOST,
|
||||
logger: this.logger ?? getRootLogger(),
|
||||
corsOptions: this.corsOptions,
|
||||
cspOptions: this.cspOptions,
|
||||
httpsSettings: this.httpsSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CorsOptions } from 'cors';
|
||||
|
||||
export type BaseOptions = {
|
||||
@@ -57,6 +57,13 @@ export type CertificateAttributes = {
|
||||
commonName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map from CSP directive names to their values.
|
||||
*
|
||||
* Added here since helmet doesn't export this type publicly.
|
||||
*/
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* Reads some base options out of a config object.
|
||||
*
|
||||
@@ -71,7 +78,7 @@ export type CertificateAttributes = {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readBaseOptions(config: ConfigReader): BaseOptions {
|
||||
export function readBaseOptions(config: Config): BaseOptions {
|
||||
if (typeof config.get('listen') === 'string') {
|
||||
// TODO(freben): Expand this to support more addresses and perhaps optional
|
||||
const { host, port } = parseListenAddress(config.getString('listen'));
|
||||
@@ -105,7 +112,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
export function readCorsOptions(config: Config): CorsOptions | undefined {
|
||||
const cc = config.getOptionalConfig('cors');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
@@ -123,6 +130,33 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a CSP options object from the root of a config object.
|
||||
*
|
||||
* @param config The root of a backend config object
|
||||
* @returns A CSP options object, or undefined if not specified
|
||||
*
|
||||
* @example
|
||||
* ```yaml
|
||||
* backend:
|
||||
* csp:
|
||||
* connect-src: ["'self'", 'http:', 'https:']
|
||||
* ```
|
||||
*/
|
||||
export function readCspOptions(config: Config): CspOptions | undefined {
|
||||
const cc = config.getOptionalConfig('csp');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: CspOptions = {};
|
||||
for (const key of cc.keys()) {
|
||||
result[key] = cc.getStringArray(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a https settings object from the root of a config object.
|
||||
*
|
||||
@@ -138,9 +172,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readHttpsSettings(
|
||||
config: ConfigReader,
|
||||
): HttpsSettings | undefined {
|
||||
export function readHttpsSettings(config: Config): HttpsSettings | undefined {
|
||||
const cc = config.getOptionalConfig('https');
|
||||
|
||||
if (!cc) {
|
||||
@@ -157,7 +189,7 @@ export function readHttpsSettings(
|
||||
}
|
||||
|
||||
function getOptionalStringOrStrings(
|
||||
config: ConfigReader,
|
||||
config: Config,
|
||||
key: string,
|
||||
): string | string[] | undefined {
|
||||
const value = config.getOptional(key);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12
|
||||
FROM node:12-buster
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getRootLogger,
|
||||
useHotMemoize,
|
||||
notFoundHandler,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader, AppConfig } from '@backstage/config';
|
||||
import healthcheck from './plugins/healthcheck';
|
||||
@@ -59,7 +60,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, database, config };
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
return { logger, database, config, discovery };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,11 +87,11 @@ async function main() {
|
||||
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
|
||||
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
|
||||
apiRouter.use('/sentry', await sentry(sentryEnv));
|
||||
apiRouter.use('/auth', await auth(authEnv, '/api/auth'));
|
||||
apiRouter.use('/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, '/api/proxy'));
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv));
|
||||
apiRouter.use('/graphql', await graphql(graphqlEnv));
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import { createRouter } from '@backstage/plugin-auth-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
{ logger, database, config }: PluginEnvironment,
|
||||
basePath: string,
|
||||
) {
|
||||
return await createRouter({ logger, config, database, basePath });
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
database,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({ logger, config, database, discovery });
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
import { createRouter } from '@backstage/plugin-proxy-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
{ logger, config }: PluginEnvironment,
|
||||
pathPrefix: string,
|
||||
) {
|
||||
return await createRouter({ logger, config, pathPrefix });
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({ logger, config, discovery });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import Docker from 'dockerode';
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
const generators = new Generators();
|
||||
const techdocsGenerator = new TechdocsGenerator(logger, config);
|
||||
@@ -51,5 +52,6 @@ export default async function createPlugin({
|
||||
dockerClient,
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
|
||||
export type PluginEnvironment = {
|
||||
logger: Logger;
|
||||
database: Knex;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
};
|
||||
|
||||
@@ -8,3 +8,5 @@ spec:
|
||||
targets:
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml
|
||||
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: spotify
|
||||
description: The Spotify web API
|
||||
tags:
|
||||
- spotify
|
||||
- rest
|
||||
annotations:
|
||||
backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml'
|
||||
spec:
|
||||
type: openapi
|
||||
lifecycle: production
|
||||
owner: spotify@example.com
|
||||
@@ -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(),
|
||||
|
||||
@@ -87,7 +87,7 @@ export type EntityMeta = JsonObject & {
|
||||
/**
|
||||
* The name of the entity.
|
||||
*
|
||||
* Must be uniqe within the catalog at any given point in time, for any
|
||||
* Must be unique within the catalog at any given point in time, for any
|
||||
* given namespace + kind pair.
|
||||
*/
|
||||
name: string;
|
||||
@@ -120,8 +120,3 @@ export type EntityMeta = JsonObject & {
|
||||
*/
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The keys of EntityMeta that are auto-generated.
|
||||
*/
|
||||
export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The namespace that entities without an explicit namespace fall into.
|
||||
*/
|
||||
export const ENTITY_DEFAULT_NAMESPACE = 'default';
|
||||
|
||||
/**
|
||||
* The keys of EntityMeta that are auto-generated.
|
||||
*/
|
||||
export const ENTITY_META_GENERATED_FIELDS = [
|
||||
'uid',
|
||||
'etag',
|
||||
'generation',
|
||||
] as const;
|
||||
@@ -14,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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -310,3 +310,13 @@ export const oauth2ApiRef = createApiRef<
|
||||
id: 'core.auth.oauth2',
|
||||
description: 'Example of how to use oauth2 custom provider',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication for saml based identity providers
|
||||
*/
|
||||
export const samlAuthApiRef = createApiRef<
|
||||
ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>({
|
||||
id: 'core.auth.saml',
|
||||
description: 'Example of how to use SAML custom provider',
|
||||
});
|
||||
|
||||
@@ -19,5 +19,6 @@ export * from './gitlab';
|
||||
export * from './google';
|
||||
export * from './oauth2';
|
||||
export * from './okta';
|
||||
export * from './saml';
|
||||
export * from './auth0';
|
||||
export * from './microsoft';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import SamlIcon from '@material-ui/icons/AcUnit';
|
||||
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import { Observable } from '../../../../types';
|
||||
import {
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
SessionState,
|
||||
AuthRequestOptions,
|
||||
ProfileInfoApi,
|
||||
BackstageIdentityApi,
|
||||
SessionApi,
|
||||
} from '../../../definitions/auth';
|
||||
import { AuthProvider, DiscoveryApi } from '../../../definitions';
|
||||
import { SamlSession } from './types';
|
||||
import {
|
||||
AuthSessionStore,
|
||||
StaticAuthSessionManager,
|
||||
} from '../../../../lib/AuthSessionManager';
|
||||
|
||||
type CreateOptions = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
environment?: string;
|
||||
provider?: AuthProvider & { id: string };
|
||||
};
|
||||
|
||||
export type SamlAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'saml',
|
||||
title: 'SAML',
|
||||
icon: SamlIcon,
|
||||
};
|
||||
|
||||
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
}: CreateOptions) {
|
||||
const connector = new DirectAuthConnector<SamlSession>({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
});
|
||||
|
||||
const sessionManager = new StaticAuthSessionManager<SamlSession>({
|
||||
connector,
|
||||
});
|
||||
|
||||
const authSessionStore = new AuthSessionStore<SamlSession>({
|
||||
manager: sessionManager,
|
||||
storageKey: 'samlSession',
|
||||
});
|
||||
|
||||
return new SamlAuth(authSessionStore);
|
||||
}
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionManager.sessionState$();
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
|
||||
|
||||
async signIn() {
|
||||
await this.getBackstageIdentity({});
|
||||
}
|
||||
async signOut() {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
async getBackstageIdentity(
|
||||
options: AuthRequestOptions,
|
||||
): Promise<BackstageIdentity | undefined> {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.backstageIdentity;
|
||||
}
|
||||
|
||||
async getProfile(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.profile;
|
||||
}
|
||||
}
|
||||
|
||||
export default SamlAuth;
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { default as SamlAuth } from './SamlAuth';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
|
||||
|
||||
export type SamlSession = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
AuthProvider,
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
DiscoveryApi,
|
||||
} from '../../apis/definitions';
|
||||
import { showLoginPopup } from '../loginPopup';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
environment?: string;
|
||||
provider: AuthProvider & { id: string };
|
||||
};
|
||||
|
||||
export type DirectAuthResponse = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
export class DirectAuthConnector<DirectAuthResponse> {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly environment: string | undefined;
|
||||
private readonly provider: AuthProvider & { id: string };
|
||||
|
||||
constructor(options: Options) {
|
||||
const { discoveryApi, environment, provider } = options;
|
||||
|
||||
this.discoveryApi = discoveryApi;
|
||||
this.environment = environment;
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async createSession(): Promise<DirectAuthResponse> {
|
||||
const popupUrl = await this.buildUrl('/start');
|
||||
const payload = await showLoginPopup({
|
||||
url: popupUrl,
|
||||
name: `${this.provider.title} Login`,
|
||||
origin: new URL(popupUrl).origin,
|
||||
width: 450,
|
||||
height: 730,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
id: payload.profile.email,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<any> {}
|
||||
|
||||
async removeSession(): Promise<void> {
|
||||
const res = await fetch(await this.buildUrl('/logout'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(error => {
|
||||
throw new Error(`Logout request failed, ${error}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error: any = new Error(`Logout request failed, ${res.statusText}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildUrl(path: string): Promise<string> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
|
||||
return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`;
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { DefaultAuthConnector } from './DefaultAuthConnector';
|
||||
export { DirectAuthConnector } from './DirectAuthConnector';
|
||||
export * from './types';
|
||||
|
||||
@@ -28,7 +28,7 @@ type Options<T> = {
|
||||
/** Storage key to use to store sessions */
|
||||
storageKey: string;
|
||||
/** Used to get the scope of the session */
|
||||
sessionScopes: SessionScopesFunc<T>;
|
||||
sessionScopes?: SessionScopesFunc<T>;
|
||||
/** Used to check if the session needs to be refreshed, defaults to never refresh */
|
||||
sessionShouldRefresh?: SessionShouldRefreshFunc<T>;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ type Options<T> = {
|
||||
/** The connector used for acting on the auth session */
|
||||
connector: AuthConnector<T>;
|
||||
/** Used to get the scope of the session */
|
||||
sessionScopes: (session: T) => Set<string>;
|
||||
sessionScopes?: (session: T) => Set<string>;
|
||||
/** The default scopes that should always be present in a session, defaults to none. */
|
||||
defaultScopes?: Set<string>;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export function hasScopes(
|
||||
}
|
||||
|
||||
type ScopeHelperOptions<T> = {
|
||||
sessionScopes: SessionScopesFunc<T>;
|
||||
sessionScopes: SessionScopesFunc<T> | undefined;
|
||||
defaultScopes?: Set<string>;
|
||||
};
|
||||
|
||||
@@ -46,13 +46,16 @@ export class SessionScopeHelper<T> {
|
||||
if (!scopes) {
|
||||
return true;
|
||||
}
|
||||
if (this.options.sessionScopes === undefined) {
|
||||
return true;
|
||||
}
|
||||
const sessionScopes = this.options.sessionScopes(session);
|
||||
return hasScopes(sessionScopes, scopes);
|
||||
}
|
||||
|
||||
getExtendedScope(session: T | undefined, scopes?: Set<string>) {
|
||||
const newScope = new Set(this.options.defaultScopes);
|
||||
if (session) {
|
||||
if (session && this.options.sessionScopes !== undefined) {
|
||||
const sessionScopes = this.options.sessionScopes(session);
|
||||
for (const scope of sessionScopes) {
|
||||
newScope.add(scope);
|
||||
|
||||
@@ -14,30 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { RouteRefConfig, RouteRefOverrideConfig } from './types';
|
||||
import {
|
||||
ConcreteRoute,
|
||||
routeReference,
|
||||
ReferencedRoute,
|
||||
resolveRoute,
|
||||
RouteRefConfig,
|
||||
} from './types';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
export class MutableRouteRef {
|
||||
private effectiveConfig: RouteRefConfig = this.config;
|
||||
type SubRouteConfig = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export class SubRouteRef<T extends { [name in string]: string } | never = never>
|
||||
implements ReferencedRoute {
|
||||
constructor(
|
||||
private readonly parent: ConcreteRoute,
|
||||
private readonly config: SubRouteConfig,
|
||||
) {}
|
||||
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
link<Args extends T extends never ? [] : [T]>(...args: Args): ConcreteRoute {
|
||||
return {
|
||||
[routeReference]: this,
|
||||
[resolveRoute]: (path: string) => {
|
||||
const ownPart = generatePath(this.config.path, args[0] ?? {});
|
||||
const parentPart = this.parent[resolveRoute](path);
|
||||
return parentPart + ownPart;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AbsoluteRouteRef implements ConcreteRoute {
|
||||
constructor(private readonly config: RouteRefConfig) {}
|
||||
|
||||
override(overrideConfig: RouteRefOverrideConfig) {
|
||||
this.effectiveConfig = { ...this.config, ...overrideConfig };
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return this.effectiveConfig.icon;
|
||||
return this.config.icon;
|
||||
}
|
||||
|
||||
// TODO(Rugvip): Remove this, routes are looked up via the registry instead
|
||||
get path() {
|
||||
return this.effectiveConfig.path;
|
||||
return this.config.path;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.effectiveConfig.title;
|
||||
return this.config.title;
|
||||
}
|
||||
|
||||
createSubRoute<T extends { [name in string]: string } | never = never>(
|
||||
config: SubRouteConfig,
|
||||
) {
|
||||
return new SubRouteRef<T>(this, config);
|
||||
}
|
||||
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
[resolveRoute](path: string) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouteRef(config: RouteRefConfig): MutableRouteRef {
|
||||
return new MutableRouteRef(config);
|
||||
export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef {
|
||||
return new AbsoluteRouteRef(config);
|
||||
}
|
||||
|
||||
// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone
|
||||
// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider
|
||||
// a different model for how to create sub routes, just avoid this
|
||||
export type MutableRouteRef = AbsoluteRouteRef;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRefRegistry } from './RouteRefRegistry';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
|
||||
const dummyConfig = { path: '/', icon: () => null, title: 'my-title' };
|
||||
const ref1 = createRouteRef(dummyConfig);
|
||||
const ref11 = createRouteRef(dummyConfig);
|
||||
const ref12 = createRouteRef(dummyConfig);
|
||||
const ref121 = createRouteRef(dummyConfig);
|
||||
const ref2 = createRouteRef(dummyConfig);
|
||||
const ref2a = ref2.createSubRoute({ path: '/a' });
|
||||
const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' });
|
||||
|
||||
describe('RouteRefRegistry', () => {
|
||||
it('should be constructed with a root route', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.resolveRoute([], [])).toBe('');
|
||||
});
|
||||
|
||||
it('should register and resolve some absolute routes', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.registerRoute([ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref11], '11')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12], '12')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true);
|
||||
expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2], 'duplicate')).toBe(false);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
|
||||
expect(registry.resolveRoute([], [ref1])).toBe('/1');
|
||||
expect(registry.resolveRoute([], [ref11])).toBe(undefined);
|
||||
expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11');
|
||||
expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11');
|
||||
expect(registry.resolveRoute([ref1], [ref2])).toBe('/2');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe(
|
||||
'/1/12/121',
|
||||
);
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe(
|
||||
'/1/12/121',
|
||||
);
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12');
|
||||
expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1');
|
||||
});
|
||||
|
||||
it('should register and resolve with sub routes', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(registry.registerRoute([ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b], '2')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true);
|
||||
expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true);
|
||||
|
||||
expect(registry.resolveRoute([], [ref1])).toBe('/1');
|
||||
expect(registry.resolveRoute([], [ref2])).toBe('/2');
|
||||
expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1');
|
||||
expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2');
|
||||
expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe(
|
||||
'/2/b/abc/1',
|
||||
);
|
||||
expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe(
|
||||
'/2/b/xyz/2',
|
||||
);
|
||||
expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe(
|
||||
'/2/b/abc/2',
|
||||
);
|
||||
expect(
|
||||
registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]),
|
||||
).toBe('/2/b/abc/2');
|
||||
});
|
||||
|
||||
it('should throw when registering routes incorrectly', () => {
|
||||
const registry = new RouteRefRegistry();
|
||||
expect(() => {
|
||||
registry.registerRoute([ref1, ref11], '11');
|
||||
}).toThrow('Could not find parent for new routing node');
|
||||
expect(() => {
|
||||
registry.registerRoute([], '11');
|
||||
}).toThrow('Must provide at least 1 route to add routing node');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConcreteRoute,
|
||||
routeReference,
|
||||
resolveRoute,
|
||||
ReferencedRoute,
|
||||
} from './types';
|
||||
|
||||
const rootRoute: ConcreteRoute = {
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
},
|
||||
[resolveRoute]: () => '',
|
||||
};
|
||||
|
||||
export type RouteRefResolver = {
|
||||
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string;
|
||||
};
|
||||
|
||||
class Node {
|
||||
readonly children = new Map<unknown, Node>();
|
||||
|
||||
constructor(readonly path: string, readonly parent: Node | undefined) {}
|
||||
|
||||
/**
|
||||
* Look up a node in the tree given a path.
|
||||
*/
|
||||
findNode(routes: ReferencedRoute[]): Node | undefined {
|
||||
let node = this as Node | undefined;
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
node = node?.children.get(routes[i][routeReference]);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a path to a leaf node in the routing tree. All ancestor
|
||||
* nodes of the new leaf node must already exist, or an error will be thrown.
|
||||
*
|
||||
* Returns true if the node was added, or false if the node already existed.
|
||||
*/
|
||||
addNode(routes: ReferencedRoute[], path: string): boolean {
|
||||
if (routes.length === 0) {
|
||||
throw new Error('Must provide at least 1 route to add routing node');
|
||||
}
|
||||
|
||||
const parentNode = this.findNode(routes.slice(0, -1));
|
||||
if (!parentNode) {
|
||||
throw new Error('Could not find parent for new routing node');
|
||||
}
|
||||
|
||||
const lastRoute = routes[routes.length - 1];
|
||||
const lastRouteRef = lastRoute[routeReference];
|
||||
|
||||
const existingNode = parentNode.children.get(lastRouteRef);
|
||||
if (existingNode) {
|
||||
return existingNode.path === path;
|
||||
}
|
||||
|
||||
parentNode.children.set(lastRouteRef, new Node(path, parentNode));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute URL that represents this node in the routing tree, using
|
||||
* using the supplied concrete routes and ancestors of this node.
|
||||
*
|
||||
* The length of the provided routes array must match the depth of
|
||||
* the routing tree that this node is at, or an error will be thrown.
|
||||
*/
|
||||
resolve(routes: ConcreteRoute[]) {
|
||||
const parts = Array(routes.length);
|
||||
|
||||
let node = this as Node | undefined;
|
||||
for (let i = routes.length - 1; i >= 0; i--) {
|
||||
if (!node) {
|
||||
throw new Error('Route resolve missing required parent');
|
||||
}
|
||||
|
||||
const route = routes[i];
|
||||
parts[i] = route[resolveRoute](node.path);
|
||||
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
throw new Error('Route resolve did not reach root');
|
||||
}
|
||||
|
||||
return parts.join('/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry for resolving route refs into concrete string routes.
|
||||
*/
|
||||
export class RouteRefRegistry {
|
||||
private readonly root = new Node('', undefined);
|
||||
|
||||
/**
|
||||
* Register a new leaf path for a sequence of routes. All ancestor
|
||||
* routes must already exist.
|
||||
*/
|
||||
registerRoute(routes: ReferencedRoute[], path: string): boolean {
|
||||
return this.root.addNode(routes, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute path from a point in the routing tree.
|
||||
*
|
||||
* The route referenced by `from` must exist, and is the starting
|
||||
* point for the search, walking up the tree until a subtree that
|
||||
* matches the routes reference in `to` are found.
|
||||
*
|
||||
* If `from` is empty, the search starts and ends at the root node.
|
||||
* If `to` is empty, the route referenced by `from` will always be returned.
|
||||
*/
|
||||
resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined {
|
||||
// Keep track of the `from` routes and pop the last ones as we traverse up
|
||||
// the routing tree. The list of concrete routes that we're passing to
|
||||
// `node.resolve()` should only include the ones in the resolve path.
|
||||
const concreteStack = from.slice();
|
||||
|
||||
let fromNode = this.root.findNode(from);
|
||||
while (fromNode) {
|
||||
const resolvedNode = fromNode.findNode(to);
|
||||
if (resolvedNode) {
|
||||
return resolvedNode.resolve([rootRoute].concat(concreteStack, to));
|
||||
}
|
||||
|
||||
// Search at this level of the tree failed, move up to parent
|
||||
concreteStack.pop();
|
||||
fromNode = fromNode.parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types';
|
||||
export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef';
|
||||
export { createRouteRef } from './RouteRef';
|
||||
export type { MutableRouteRef } from './RouteRef';
|
||||
|
||||
@@ -16,7 +16,19 @@
|
||||
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
export const resolveRoute = Symbol('resolve-route');
|
||||
export const routeReference = Symbol('route-ref');
|
||||
|
||||
export type ReferencedRoute = {
|
||||
[routeReference]: unknown;
|
||||
};
|
||||
|
||||
export type ConcreteRoute = ReferencedRoute & {
|
||||
[resolveRoute](path: string): string;
|
||||
};
|
||||
|
||||
export type RouteRef = {
|
||||
// TODO(Rugvip): Remove path, look up via registry instead
|
||||
path: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
@@ -27,9 +39,3 @@ export type RouteRefConfig = {
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type RouteRefOverrideConfig = {
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
createApiFactory,
|
||||
configApiRef,
|
||||
UrlPatternDiscovery,
|
||||
samlAuthApiRef,
|
||||
SamlAuth,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
export const defaultApis = [
|
||||
@@ -132,4 +134,11 @@ export const defaultApis = [
|
||||
factory: ({ discoveryApi, oauthRequestApi }) =>
|
||||
OAuth2.create({ discoveryApi, oauthRequestApi }),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: samlAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -100,3 +100,15 @@ export const WithLink = () => (
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
export const Fixed = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with a fixed position fixed at the bottom of the page"
|
||||
variant="info"
|
||||
id="fixed_dismissable"
|
||||
fixed
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, ReactNode, useState, useEffect } from 'react';
|
||||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core-api';
|
||||
import { useObservable } from 'react-use';
|
||||
import classNames from 'classnames';
|
||||
@@ -27,12 +27,17 @@ import Close from '@material-ui/icons/Close';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
root: {
|
||||
position: 'relative',
|
||||
padding: theme.spacing(0),
|
||||
marginBottom: theme.spacing(6),
|
||||
marginTop: -theme.spacing(3),
|
||||
marginBottom: theme.spacing(0),
|
||||
marginTop: theme.spacing(0),
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
},
|
||||
// showing on top
|
||||
topPosition: {
|
||||
position: 'relative',
|
||||
marginBottom: theme.spacing(6),
|
||||
marginTop: -theme.spacing(3),
|
||||
zIndex: 'unset',
|
||||
},
|
||||
icon: {
|
||||
@@ -45,6 +50,10 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
message: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.banner.text,
|
||||
'& a': {
|
||||
color: theme.palette.banner.link,
|
||||
},
|
||||
},
|
||||
info: {
|
||||
backgroundColor: theme.palette.banner.info,
|
||||
@@ -58,9 +67,15 @@ type Props = {
|
||||
variant: 'info' | 'error';
|
||||
message: ReactNode;
|
||||
id: string;
|
||||
fixed?: boolean;
|
||||
};
|
||||
|
||||
export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
|
||||
export const DismissableBanner = ({
|
||||
variant,
|
||||
message,
|
||||
id,
|
||||
fixed = false,
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const notificationsStore = storageApi.forBucket('notifications');
|
||||
@@ -88,9 +103,13 @@ export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={
|
||||
fixed
|
||||
? { vertical: 'bottom', horizontal: 'center' }
|
||||
: { vertical: 'top', horizontal: 'center' }
|
||||
}
|
||||
open={!dismissedBanners.has(id)}
|
||||
classes={{ root: classes.root }}
|
||||
classes={{ root: classNames(classes.root, fixed && classes.topPosition) }}
|
||||
>
|
||||
<SnackbarContent
|
||||
classes={{
|
||||
|
||||
@@ -71,13 +71,7 @@ export const MetadataTable = ({
|
||||
dense?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Table>
|
||||
{!dense && (
|
||||
<colgroup>
|
||||
<col style={{ width: 'auto' }} />
|
||||
<col style={{ width: '100%' }} />
|
||||
</colgroup>
|
||||
)}
|
||||
<Table size={dense ? 'small' : 'medium'}>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
+13
@@ -56,3 +56,16 @@ export const Default = () => (
|
||||
</InfoCard>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const NotDenseTable = () => (
|
||||
<Wrapper>
|
||||
<InfoCard
|
||||
title="Not Dense Structured Metadata Table"
|
||||
subheader="Wrapped in InfoCard"
|
||||
>
|
||||
<div style={cardContentStyle}>
|
||||
<StructuredMetadataTable metadata={metadata} dense={false} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Component, Fragment, ReactElement } from 'react';
|
||||
import React, { Fragment, ReactElement } from 'react';
|
||||
import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core';
|
||||
import startCase from 'lodash/startCase';
|
||||
|
||||
@@ -142,17 +142,17 @@ const TableItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ComponentProps {
|
||||
type Props = {
|
||||
metadata: { [key: string]: any };
|
||||
dense?: boolean;
|
||||
options?: any;
|
||||
}
|
||||
};
|
||||
|
||||
export class StructuredMetadataTable extends Component<ComponentProps> {
|
||||
render() {
|
||||
const { metadata, dense, options } = this.props;
|
||||
const metadataItems = mapToItems(metadata, options || {});
|
||||
|
||||
return <MetadataTable dense={dense}>{metadataItems}</MetadataTable>;
|
||||
}
|
||||
}
|
||||
export const StructuredMetadataTable = ({
|
||||
metadata,
|
||||
dense = true,
|
||||
options,
|
||||
}: Props) => {
|
||||
const metadataItems = mapToItems(metadata, options || {});
|
||||
return <MetadataTable dense={dense}>{metadataItems}</MetadataTable>;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1 @@
|
||||
import {
|
||||
discoveryApiRef,
|
||||
UrlPatternDiscovery,
|
||||
createApiFactory,
|
||||
configApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
export const apis = [
|
||||
createApiFactory({
|
||||
api: discoveryApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) =>
|
||||
UrlPatternDiscovery.compile(
|
||||
`${configApi.getString('backend.baseUrl')}/{{ pluginId }}`,
|
||||
),
|
||||
}),
|
||||
];
|
||||
export const apis = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12
|
||||
FROM node:12-buster
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getRootLogger,
|
||||
useHotMemoize,
|
||||
notFoundHandler,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader, AppConfig } from '@backstage/config';
|
||||
import auth from './plugins/auth';
|
||||
@@ -37,7 +38,8 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, database, config };
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
return { logger, database, config, discovery };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ async function main() {
|
||||
apiRouter.use('/auth', await auth(authEnv))
|
||||
apiRouter.use('/identity', await identity(identityEnv))
|
||||
apiRouter.use('/techdocs', await techdocs(techdocsEnv))
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv, '/api/proxy'))
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv))
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
|
||||
@@ -5,6 +5,7 @@ export default async function createPlugin({
|
||||
logger,
|
||||
database,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({ logger, config, database });
|
||||
return await createRouter({ logger, config, database, discovery });
|
||||
}
|
||||
|
||||
@@ -2,9 +2,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 });
|
||||
}
|
||||
|
||||
+52
-23
@@ -18,8 +18,8 @@ import type { PluginEnvironment } from '../types';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
@@ -39,31 +39,60 @@ export default async function createPlugin({
|
||||
|
||||
const publishers = new Publishers();
|
||||
|
||||
const githubToken = config.getString('scaffolder.github.token');
|
||||
const repoVisibility = config.getString(
|
||||
'scaffolder.github.visibility',
|
||||
) as RepoVisibilityOptions;
|
||||
const githubConfig = config.getOptionalConfig('scaffolder.github');
|
||||
|
||||
const githubClient = new Octokit({ auth: githubToken });
|
||||
const githubPublisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
publishers.register('file', githubPublisher);
|
||||
publishers.register('github', githubPublisher);
|
||||
if (githubConfig) {
|
||||
try {
|
||||
const repoVisibility = githubConfig.getString(
|
||||
'visibility',
|
||||
) as RepoVisibilityOptions;
|
||||
|
||||
const githubToken = githubConfig.getString('token');
|
||||
const githubClient = new Octokit({ auth: githubToken });
|
||||
const githubPublisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
publishers.register('file', githubPublisher);
|
||||
publishers.register('github', githubPublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'github';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api');
|
||||
|
||||
if (gitLabConfig) {
|
||||
const gitLabToken = gitLabConfig.getString('token');
|
||||
const gitLabClient = new Gitlab({
|
||||
host: gitLabConfig.getOptionalString('baseUrl'),
|
||||
token: gitLabToken,
|
||||
});
|
||||
const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken);
|
||||
publishers.register('gitlab', gitLabPublisher);
|
||||
publishers.register('gitlab/api', gitLabPublisher);
|
||||
try {
|
||||
const gitLabToken = gitLabConfig.getString('token');
|
||||
const gitLabClient = new Gitlab({
|
||||
host: gitLabConfig.getOptionalString('baseUrl'),
|
||||
token: gitLabToken,
|
||||
});
|
||||
const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken);
|
||||
publishers.register('gitlab', gitLabPublisher);
|
||||
publishers.register('gitlab/api', gitLabPublisher);
|
||||
} catch (e) {
|
||||
const providerName = 'gitlab';
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
`Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Skipping ${providerName} scaffolding provider, ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dockerClient = new Docker();
|
||||
|
||||
@@ -13,6 +13,7 @@ import Docker from 'dockerode';
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
const generators = new Generators();
|
||||
const techdocsGenerator = new TechdocsGenerator(logger, config);
|
||||
@@ -37,5 +38,6 @@ export default async function createPlugin({
|
||||
dockerClient,
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,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;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ export const lightTheme = createTheme({
|
||||
banner: {
|
||||
info: '#2E77D0',
|
||||
error: '#E22134',
|
||||
text: '#FFFFFF',
|
||||
link: '#000000',
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#000000',
|
||||
@@ -100,6 +102,8 @@ export const darkTheme = createTheme({
|
||||
banner: {
|
||||
info: '#2E77D0',
|
||||
error: '#E22134',
|
||||
text: '#FFFFFF',
|
||||
link: '#000000',
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#FFFFFF',
|
||||
|
||||
@@ -64,6 +64,8 @@ type PaletteAdditions = {
|
||||
banner: {
|
||||
info: string;
|
||||
error: string;
|
||||
text: string;
|
||||
link: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../identity';
|
||||
import { createGithubProvider } from './github';
|
||||
import { createGitlabProvider } from './gitlab';
|
||||
import { createGoogleProvider } from './google';
|
||||
@@ -25,8 +22,7 @@ import { createOktaProvider } from './okta';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { createAuth0Provider } from './auth0';
|
||||
import { createMicrosoftProvider } from './microsoft';
|
||||
import { AuthProviderConfig, AuthProviderFactory } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import { AuthProviderFactory, AuthProviderFactoryOptions } from './types';
|
||||
|
||||
const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
google: createGoogleProvider,
|
||||
@@ -39,31 +35,14 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
oauth2: createOAuth2Provider,
|
||||
};
|
||||
|
||||
export const createAuthProviderRouter = (
|
||||
export function createAuthProvider(
|
||||
providerId: string,
|
||||
globalConfig: AuthProviderConfig,
|
||||
config: Config,
|
||||
logger: Logger,
|
||||
tokenIssuer: TokenIssuer,
|
||||
) => {
|
||||
options: AuthProviderFactoryOptions,
|
||||
) {
|
||||
const factory = factories[providerId];
|
||||
if (!factory) {
|
||||
throw Error(`No auth provider available for '${providerId}'`);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
const handler = factory({ globalConfig, config, logger, tokenIssuer });
|
||||
|
||||
router.get('/start', handler.start.bind(handler));
|
||||
router.get('/handler/frame', handler.frameHandler.bind(handler));
|
||||
router.post('/handler/frame', handler.frameHandler.bind(handler));
|
||||
if (handler.logout) {
|
||||
router.post('/logout', handler.logout.bind(handler));
|
||||
}
|
||||
if (handler.refresh) {
|
||||
router.get('/refresh', handler.refresh.bind(handler));
|
||||
}
|
||||
|
||||
return router;
|
||||
};
|
||||
return factory(options);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createAuthProviderRouter } from './factories';
|
||||
export { createAuthProvider } from './factories';
|
||||
|
||||
@@ -54,7 +54,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
// TODO: This flow doesn't issue an identity token that can be used to validate
|
||||
// the identity of the user in other backends, which we need in some form.
|
||||
done(undefined, {
|
||||
userId: profile.ID!,
|
||||
userId: profile.nameID!,
|
||||
profile: {
|
||||
email: profile.email!,
|
||||
displayName: profile.displayName as string,
|
||||
|
||||
@@ -19,16 +19,19 @@ import Router from 'express-promise-router';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { createAuthProviderRouter } from '../providers';
|
||||
import { createAuthProvider } from '../providers';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
NotFoundError,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
database: Knex;
|
||||
config: Config;
|
||||
basePath?: string;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
@@ -38,9 +41,7 @@ export async function createRouter(
|
||||
const logger = options.logger.child({ plugin: 'auth' });
|
||||
|
||||
const appUrl = options.config.getString('app.baseUrl');
|
||||
const backendUrl = options.config.getString('backend.baseUrl');
|
||||
// TODO(Rugvip): Replace with service discovery of external URL
|
||||
const authUrl = backendUrl + (options.basePath ?? '/api/auth');
|
||||
const authUrl = await options.discovery.getExternalBaseUrl('auth');
|
||||
|
||||
const keyDurationSeconds = 3600;
|
||||
|
||||
@@ -64,15 +65,26 @@ export async function createRouter(
|
||||
for (const providerId of providers) {
|
||||
logger.info(`Configuring provider, ${providerId}`);
|
||||
try {
|
||||
const providerConfig = providersConfig.getConfig(providerId);
|
||||
const providerRouter = createAuthProviderRouter(
|
||||
providerId,
|
||||
{ baseUrl: authUrl, appUrl },
|
||||
providerConfig,
|
||||
const provider = createAuthProvider(providerId, {
|
||||
globalConfig: { baseUrl: authUrl, appUrl },
|
||||
config: providersConfig.getConfig(providerId),
|
||||
logger,
|
||||
tokenIssuer,
|
||||
);
|
||||
router.use(`/${providerId}`, providerRouter);
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.get('/start', provider.start.bind(provider));
|
||||
r.get('/handler/frame', provider.frameHandler.bind(provider));
|
||||
r.post('/handler/frame', provider.frameHandler.bind(provider));
|
||||
if (provider.logout) {
|
||||
r.post('/logout', provider.logout.bind(provider));
|
||||
}
|
||||
if (provider.refresh) {
|
||||
r.get('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
|
||||
router.use(`/${providerId}`, r);
|
||||
} catch (e) {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
createServiceBuilder,
|
||||
useHotMemoize,
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
export interface ServerOptions {
|
||||
@@ -34,6 +35,7 @@ export async function startStandaloneServer(
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'auth-backend' });
|
||||
const config = ConfigReader.fromConfigs(await loadBackendConfig());
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
const knex = Knex({
|
||||
@@ -52,6 +54,7 @@ export async function startStandaloneServer(
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex')} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex('entities')
|
||||
.where({ namespace: null })
|
||||
.update({ namespace: 'default' });
|
||||
await knex('entities_search').update({
|
||||
key: knex.raw('LOWER(key)'),
|
||||
value: knex.raw('LOWER(value)'),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down() {};
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
|
||||
import { EntitiesCatalog } from './types';
|
||||
@@ -115,9 +115,13 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c1.entityByName.mockResolvedValueOnce(undefined);
|
||||
c2.entityByName.mockResolvedValueOnce(e2);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e2,
|
||||
);
|
||||
await expect(
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBe(e2);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -127,7 +131,11 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c2.entityByName.mockResolvedValueOnce(undefined);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(
|
||||
catalog.entityByName('k', undefined, 'n2'),
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
@@ -137,9 +145,13 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c1.entityByName.mockResolvedValueOnce(e1);
|
||||
c2.entityByName.mockRejectedValueOnce(new Error('boo'));
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e1,
|
||||
);
|
||||
await expect(
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBe(e1);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../database';
|
||||
import { EntitiesCatalog } from './types';
|
||||
@@ -72,14 +72,10 @@ export class CoalescedEntitiesCatalog implements EntitiesCatalog {
|
||||
return results.find(Boolean);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined> {
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const ops = this.inner.map(async catalog => {
|
||||
try {
|
||||
return await catalog.entityByName(kind, namespace, name);
|
||||
return await catalog.entityByName(name);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Inner entityByName call failed, ${e}`);
|
||||
return undefined;
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
addEntity: jest.fn(),
|
||||
updateEntity: jest.fn(),
|
||||
entities: jest.fn(),
|
||||
entity: jest.fn(),
|
||||
entityByName: jest.fn(),
|
||||
entityByUid: jest.fn(),
|
||||
removeEntity: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
@@ -61,12 +61,12 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
const catalog = new DatabaseEntitiesCatalog(db);
|
||||
const result = await catalog.addOrUpdateEntity(entity);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'name', values: ['c'] },
|
||||
{ key: 'namespace', values: ['d'] },
|
||||
]);
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
});
|
||||
expect(db.addEntity).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(entity);
|
||||
});
|
||||
@@ -146,18 +146,18 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
},
|
||||
};
|
||||
|
||||
db.entities.mockResolvedValue([{ entity: existing }]);
|
||||
db.entityByName.mockResolvedValue({ entity: existing });
|
||||
db.updateEntity.mockResolvedValue({ entity: existing });
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db);
|
||||
const result = await catalog.addOrUpdateEntity(added);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'name', values: ['c'] },
|
||||
{ key: 'namespace', values: ['d'] },
|
||||
]);
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
});
|
||||
expect(db.updateEntity).toHaveBeenCalledTimes(1);
|
||||
expect(db.updateEntity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
EntityName,
|
||||
generateUpdatedEntity,
|
||||
getEntityName,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
@@ -34,20 +36,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
|
||||
async entityByUid(uid: string): Promise<Entity | undefined> {
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
|
||||
const response = await this.database.transaction(tx =>
|
||||
this.database.entityByUid(tx, uid),
|
||||
);
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
return response?.entity;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined> {
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const response = await this.database.transaction(tx =>
|
||||
this.entityByNameInternal(tx, kind, name, namespace),
|
||||
this.database.entityByName(tx, name),
|
||||
);
|
||||
return response?.entity;
|
||||
}
|
||||
@@ -61,12 +58,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
// entity) existing entity, to know whether to update or add
|
||||
const existing = entity.metadata.uid
|
||||
? await this.database.entityByUid(tx, entity.metadata.uid)
|
||||
: await this.entityByNameInternal(
|
||||
tx,
|
||||
entity.kind,
|
||||
entity.metadata.name,
|
||||
entity.metadata.namespace,
|
||||
);
|
||||
: await this.database.entityByName(tx, getEntityName(entity));
|
||||
|
||||
// If it's an update, run the algorithm for annotation merging, updating
|
||||
// etag/generation, etc.
|
||||
@@ -113,25 +105,4 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async entityByNameInternal(
|
||||
tx: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const matches = await this.database.entities(tx, [
|
||||
{ key: 'kind', values: [kind] },
|
||||
{ key: 'name', values: [name] },
|
||||
{
|
||||
key: 'namespace',
|
||||
values:
|
||||
!namespace || namespace === 'default'
|
||||
? [null, 'default']
|
||||
: [namespace],
|
||||
},
|
||||
]);
|
||||
|
||||
return matches.length ? matches[0] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName, getEntityName } from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import type { EntitiesCatalog } from './types';
|
||||
|
||||
@@ -34,17 +34,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
return item ? lodash.cloneDeep(item) : undefined;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<Entity | undefined> {
|
||||
const item = this._entities.find(
|
||||
e =>
|
||||
kind === e.kind &&
|
||||
name === e.metadata.name &&
|
||||
namespace === e.metadata.namespace,
|
||||
);
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const item = this._entities.find(e => {
|
||||
const candidate = getEntityName(e);
|
||||
return (
|
||||
name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
|
||||
name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
|
||||
name.name.toLowerCase() === candidate.name.toLowerCase()
|
||||
);
|
||||
});
|
||||
return item ? lodash.cloneDeep(item) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName, Location } from '@backstage/catalog-model';
|
||||
import type { EntityFilters } from '../database';
|
||||
|
||||
//
|
||||
@@ -24,11 +24,7 @@ import type { EntityFilters } from '../database';
|
||||
export type EntitiesCatalog = {
|
||||
entities(filters?: EntityFilters): Promise<Entity[]>;
|
||||
entityByUid(uid: string): Promise<Entity | undefined>;
|
||||
entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined>;
|
||||
entityByName(name: EntityName): Promise<Entity | undefined>;
|
||||
addOrUpdateEntity(entity: Entity, locationId?: string): Promise<Entity>;
|
||||
removeEntityByUid(uid: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -395,5 +395,90 @@ describe('CommonDatabase', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters case insensitively)', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'A', kind: 'K1', metadata: { name: 'N' } },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k2',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: 'Some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k3',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await db.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await db.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await db.transaction(async tx =>
|
||||
db.entities(tx, [
|
||||
{ key: 'ApiVersioN', values: ['A'] },
|
||||
{ key: 'spEc.C', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.length).toEqual(3);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'K1' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k2' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k3' }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('can get entities case insensitively', async () => {
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k1',
|
||||
metadata: { name: 'n' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'B',
|
||||
kind: 'K2',
|
||||
metadata: { name: 'N', namespace: 'NS' },
|
||||
},
|
||||
];
|
||||
|
||||
await db.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await db.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const e1 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'k1', namespace: 'default', name: 'n' }),
|
||||
);
|
||||
expect(e1!.entity.metadata.name).toEqual('n');
|
||||
const e2 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'k2', namespace: 'nS', name: 'n' }),
|
||||
);
|
||||
expect(e2!.entity.metadata.name).toEqual('N');
|
||||
const e3 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'unknown', namespace: 'nS', name: 'n' }),
|
||||
);
|
||||
expect(e3).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
import {
|
||||
Entity,
|
||||
EntityMeta,
|
||||
entityMetaGeneratedFields,
|
||||
EntityName,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
ENTITY_META_GENERATED_FIELDS,
|
||||
generateEntityEtag,
|
||||
generateEntityUid,
|
||||
getEntityName,
|
||||
Location,
|
||||
} from '@backstage/catalog-model';
|
||||
import Knex from 'knex';
|
||||
@@ -172,7 +175,7 @@ export class CommonDatabase implements Database {
|
||||
let builder = tx<DbEntitiesRow>('entities');
|
||||
for (const [indexU, filter] of (filters ?? []).entries()) {
|
||||
const index = Number(indexU);
|
||||
const key = filter.key.replace('*', '%');
|
||||
const key = filter.key.toLowerCase().replace('*', '%');
|
||||
const keyOp = filter.key.includes('*') ? 'like' : '=';
|
||||
|
||||
let matchNulls = false;
|
||||
@@ -183,9 +186,9 @@ export class CommonDatabase implements Database {
|
||||
if (!value) {
|
||||
matchNulls = true;
|
||||
} else if (value.includes('*')) {
|
||||
matchLike.push(value.replace('*', '%'));
|
||||
matchLike.push(value.toLowerCase().replace('*', '%'));
|
||||
} else {
|
||||
matchIn.push(value);
|
||||
matchIn.push(value.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,16 +222,19 @@ export class CommonDatabase implements Database {
|
||||
return rows.map(row => this.toEntityResponse(row));
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByName(
|
||||
txOpaque: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace?: string,
|
||||
name: EntityName,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const tx = txOpaque as Knex.Transaction<any, any>;
|
||||
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
.where({ kind, name, namespace: namespace || null })
|
||||
.whereRaw(
|
||||
tx.raw(
|
||||
'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)',
|
||||
[name.kind, name.namespace, name.name],
|
||||
),
|
||||
)
|
||||
.select();
|
||||
|
||||
if (rows.length !== 1) {
|
||||
@@ -240,11 +246,13 @@ export class CommonDatabase implements Database {
|
||||
|
||||
async entityByUid(
|
||||
txOpaque: unknown,
|
||||
id: string,
|
||||
uid: string,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const tx = txOpaque as Knex.Transaction<any, any>;
|
||||
|
||||
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
.where({ id: uid })
|
||||
.select();
|
||||
|
||||
if (rows.length !== 1) {
|
||||
return undefined;
|
||||
@@ -381,36 +389,40 @@ export class CommonDatabase implements Database {
|
||||
tx: Knex.Transaction<any, any>,
|
||||
data: Entity,
|
||||
): Promise<void> {
|
||||
const newKind = data.kind;
|
||||
const newName = data.metadata.name;
|
||||
const newNamespace = data.metadata.namespace;
|
||||
const {
|
||||
kind: newKind,
|
||||
namespace: newNamespace,
|
||||
name: newName,
|
||||
} = getEntityName(data);
|
||||
const newKindNorm = this.normalize(newKind);
|
||||
const newNamespaceNorm = this.normalize(newNamespace);
|
||||
const newNameNorm = this.normalize(newName);
|
||||
const newNamespaceNorm = this.normalize(newNamespace || '');
|
||||
|
||||
for (const item of await this.entities(tx)) {
|
||||
if (data.metadata.uid === item.entity.metadata.uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldKind = item.entity.kind;
|
||||
const oldName = item.entity.metadata.name;
|
||||
const oldNamespace = item.entity.metadata.namespace;
|
||||
const {
|
||||
kind: oldKind,
|
||||
namespace: oldNamespace,
|
||||
name: oldName,
|
||||
} = getEntityName(item.entity);
|
||||
const oldKindNorm = this.normalize(oldKind);
|
||||
const oldNamespaceNorm = this.normalize(oldNamespace);
|
||||
const oldNameNorm = this.normalize(oldName);
|
||||
const oldNamespaceNorm = this.normalize(oldNamespace || '');
|
||||
|
||||
if (
|
||||
oldKindNorm === newKindNorm &&
|
||||
oldNameNorm === newNameNorm &&
|
||||
oldNamespaceNorm === newNamespaceNorm
|
||||
oldNamespaceNorm === newNamespaceNorm &&
|
||||
oldNameNorm === newNameNorm
|
||||
) {
|
||||
// Only throw if things were actually different - for completely equal
|
||||
// things, we let the database handle the conflict
|
||||
if (
|
||||
oldKind !== newKind ||
|
||||
oldName !== newName ||
|
||||
oldNamespace !== newNamespace
|
||||
oldNamespace !== newNamespace ||
|
||||
oldName !== newName
|
||||
) {
|
||||
const message = `Kind, namespace, name are too similar to an existing entity`;
|
||||
throw new ConflictError(message);
|
||||
@@ -431,9 +443,9 @@ export class CommonDatabase implements Database {
|
||||
api_version: entity.apiVersion,
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
namespace: entity.metadata.namespace || null,
|
||||
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
|
||||
metadata: JSON.stringify(
|
||||
lodash.omit(entity.metadata, ...entityMetaGeneratedFields),
|
||||
lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS),
|
||||
),
|
||||
spec: entity.spec ? JSON.stringify(entity.spec) : null,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { ENTITY_DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';
|
||||
import { buildEntitySearch, visitEntityPart } from './search';
|
||||
import type { DbEntitiesSearchRow } from './types';
|
||||
|
||||
@@ -95,6 +95,16 @@ describe('search', () => {
|
||||
{ entity_id: 'eid', key: 'root.list.a', value: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits lowercase version of keys and values', () => {
|
||||
const input = { theRoot: { listItems: [{ a: 'One' }, { a: 2 }] } };
|
||||
const output: DbEntitiesSearchRow[] = [];
|
||||
visitEntityPart('eid', '', input, output);
|
||||
expect(output).toEqual([
|
||||
{ entity_id: 'eid', key: 'theroot.listitems.a', value: 'one' },
|
||||
{ entity_id: 'eid', key: 'theroot.listitems.a', value: '2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEntitySearch', () => {
|
||||
@@ -108,11 +118,17 @@ describe('search', () => {
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.uid', value: null },
|
||||
{ entity_id: 'eid', key: 'apiVersion', value: 'a' },
|
||||
{
|
||||
entity_id: 'eid',
|
||||
key: 'metadata.namespace',
|
||||
value: ENTITY_DEFAULT_NAMESPACE,
|
||||
},
|
||||
{ entity_id: 'eid', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'eid', key: 'kind', value: 'b' },
|
||||
{ entity_id: 'eid', key: 'name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'uid', value: null },
|
||||
{ entity_id: 'eid', key: 'namespace', value: ENTITY_DEFAULT_NAMESPACE },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import type { DbEntitiesSearchRow } from './types';
|
||||
|
||||
// Search entries that start with these prefixes, also get a shorthand without
|
||||
@@ -26,7 +26,7 @@ const SHORTHAND_KEY_PREFIXES = [
|
||||
'spec.',
|
||||
];
|
||||
|
||||
// These are exluded in the generic loop, either because they do not make sense
|
||||
// These are excluded in the generic loop, either because they do not make sense
|
||||
// to index, or because they are special-case always inserted whether they are
|
||||
// null or not
|
||||
const SPECIAL_KEYS = [
|
||||
@@ -42,7 +42,7 @@ function toValue(current: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(current);
|
||||
return String(current).toLowerCase();
|
||||
}
|
||||
|
||||
// Helper for iterating through a nested structure and outputting a list of
|
||||
@@ -106,7 +106,12 @@ export function visitEntityPart(
|
||||
|
||||
// object
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
visitEntityPart(entityId, path ? `${path}.${key}` : key, value, output);
|
||||
visitEntityPart(
|
||||
entityId,
|
||||
(path ? `${path}.${key}` : key).toLowerCase(),
|
||||
value,
|
||||
output,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +146,16 @@ export function buildEntitySearch(
|
||||
},
|
||||
];
|
||||
|
||||
// Namespace not specified has the default value "default", so we want to
|
||||
// match on that as well
|
||||
if (!entity.metadata.namespace) {
|
||||
result.push({
|
||||
entity_id: entityId,
|
||||
key: 'metadata.namespace',
|
||||
value: toValue(ENTITY_DEFAULT_NAMESPACE),
|
||||
});
|
||||
}
|
||||
|
||||
// Visit the entire structure recursively
|
||||
visitEntityPart(entityId, '', entity, result);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity, Location } from '@backstage/catalog-model';
|
||||
import type { Entity, EntityName, Location } from '@backstage/catalog-model';
|
||||
|
||||
export type DbEntitiesRow = {
|
||||
id: string;
|
||||
@@ -129,11 +129,9 @@ export type Database = {
|
||||
|
||||
entities(tx: unknown, filters?: EntityFilters): Promise<DbEntityResponse[]>;
|
||||
|
||||
entity(
|
||||
entityByName(
|
||||
tx: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace?: string,
|
||||
name: EntityName,
|
||||
): Promise<DbEntityResponse | undefined>;
|
||||
|
||||
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { LocationUpdateStatus } from '../catalog/types';
|
||||
import { DatabaseLocationUpdateLogStatus } from '../database/types';
|
||||
@@ -200,12 +205,11 @@ describe('HigherOrderOperations', () => {
|
||||
target: 'thing',
|
||||
});
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Component',
|
||||
undefined,
|
||||
'c1',
|
||||
);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, {
|
||||
kind: 'Component',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c1',
|
||||
});
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { InputError } from '@backstage/backend-common';
|
||||
import {
|
||||
Entity,
|
||||
entityHasChanges,
|
||||
getEntityName,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -162,16 +163,14 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
const { entity } = item;
|
||||
|
||||
this.logger.debug(
|
||||
`Read entity kind="${entity.kind}" name="${
|
||||
entity.metadata.name
|
||||
}" namespace="${entity.metadata.namespace || ''}"`,
|
||||
`Read entity kind="${entity.kind}" namespace="${
|
||||
entity.metadata.namespace || ''
|
||||
}" name="${entity.metadata.name}"`,
|
||||
);
|
||||
|
||||
try {
|
||||
const previous = await this.entitiesCatalog.entityByName(
|
||||
entity.kind,
|
||||
entity.metadata.namespace,
|
||||
entity.metadata.name,
|
||||
getEntityName(entity),
|
||||
);
|
||||
|
||||
if (!previous) {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
import { YamlProcessor } from './processors/YamlProcessor';
|
||||
import { LocationReader, ReadLocationResult } from './types';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor';
|
||||
|
||||
// The max amount of nesting depth of generated work items
|
||||
const MAX_DEPTH = 10;
|
||||
@@ -85,6 +86,7 @@ export class LocationReaders implements LocationReader {
|
||||
new AzureApiReaderProcessor(config),
|
||||
new UrlReaderProcessor(),
|
||||
new YamlProcessor(),
|
||||
new ApiDefinitionAtLocationProcessor(),
|
||||
new EntityPolicyProcessor(entityPolicy),
|
||||
new LocationRefProcessor(),
|
||||
new AnnotateLocationEntityProcessor(),
|
||||
@@ -218,7 +220,12 @@ export class LocationReaders implements LocationReader {
|
||||
for (const processor of this.processors) {
|
||||
if (processor.processEntity) {
|
||||
try {
|
||||
current = await processor.processEntity(current, item.location, emit);
|
||||
current = await processor.processEntity(
|
||||
current,
|
||||
item.location,
|
||||
emit,
|
||||
this.readLocation.bind(this),
|
||||
);
|
||||
} catch (e) {
|
||||
const message = `Processor ${processor.constructor.name} threw an error while processing entity at ${item.location.type} ${item.location.target}, ${e}`;
|
||||
emit(result.generalError(item.location, message));
|
||||
@@ -248,4 +255,25 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocation(
|
||||
location: LocationSpec,
|
||||
): Promise<LocationProcessorResult> {
|
||||
let locationResult: LocationProcessorResult | undefined;
|
||||
|
||||
await this.handleLocation(
|
||||
{
|
||||
type: 'location',
|
||||
location,
|
||||
optional: false,
|
||||
},
|
||||
r => (locationResult = r),
|
||||
);
|
||||
|
||||
if (!locationResult) {
|
||||
throw new Error('No location loaded');
|
||||
}
|
||||
|
||||
return locationResult;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user