Merge branch 'master' of github.com:spotify/backstage into blam/persistant-storage

* 'master' of github.com:spotify/backstage: (97 commits)
  Update development-environment.md
  Make local start of catalog work
  Tweak the router tests, and fix one error
  Forgot one member of the higher order type
  package/app: move app config to yaml
  packages/cli: make cli read app config and inject into APP_CONFIG at compile-time
  packages/core: make AppConfigLoader return an array, and added defaultConfigLoader + tests
  github/workflows: use actions/cache@v2
  github/workflows: split cli build to skip more builds on windows
  plugins/auth-backend: docs for saml-idp
  plugins/auth-backend: added basic saml provider
  plugins/auth-backend: refactor to allow non-oauth providers
  Break out the location refresh loop as well
  plugins: remove mock-idp-backend
  plugins/auth-backend: add script for starting up saml test idp
  plugins/mock-idp-backend: add saml-idp + test command
  plugins/mock-idp-backend: generate some dev certs
  plugins: added initial mock-idp-backend
  Address comments and add tests
  Rename test to clarify what it is doing
  ...
This commit is contained in:
blam
2020-06-03 12:17:38 +02:00
220 changed files with 6640 additions and 6205 deletions
+65
View File
@@ -0,0 +1,65 @@
name: CLI Test Windows
# Building on windows is really slow, so this workflow is separate from cli.yml and only builds on changes
# to the cli itself. They're more likely to introduce issues on windows, compared to changes to core and yarn.lock.
on:
pull_request:
paths:
- '.github/workflows/cli-win.yml'
- 'packages/cli/**'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest]
node-version: [12.x]
env:
CI: true
NODE_OPTIONS: --max-old-space-size=4096
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: find location of global yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: use node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: yarn install
run: yarn install --frozen-lockfile
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: lint newly created app and plugin
run: yarn lint:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: test newly created app and plugin
run: yarn test:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: e2e test newly created app
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
PORT: 3001
BACKSTAGE_E2E_CLI_TEST: true
+4 -11
View File
@@ -6,7 +6,7 @@ on:
- '.github/workflows/cli.yml'
- 'packages/cli/**'
- 'packages/core/**'
- 'scripts/**'
- 'packages/core-api/**'
- 'yarn.lock'
jobs:
@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
node-version: [12.x]
env:
@@ -29,7 +29,7 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -43,15 +43,8 @@ jobs:
run: yarn install --frozen-lockfile
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation on Windows
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
if: runner.os == 'Windows'
run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: verify app and plugin creation on Linux
working-directory: ${{ runner.temp }}
if: runner.os == 'Linux'
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
+6 -3
View File
@@ -25,19 +25,19 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
@@ -71,6 +71,9 @@ jobs:
if: ${{ steps.yarn-lock.outcome == 'failure' }}
run: yarn lerna -- run build
- name: verify type dependencies
run: yarn lint:type-deps
- name: test changed packages
if: ${{ steps.yarn-lock.outcome == 'success' }}
run: yarn lerna -- run test --since origin/master -- --coverage
+6 -3
View File
@@ -24,19 +24,19 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
- name: cache build cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: .backstage-build-cache
key: build-cache-${{ github.sha }}
@@ -60,6 +60,9 @@ jobs:
- name: build
run: yarn build
- name: verify type dependencies
run: yarn lint:type-deps
- name: test
run: yarn lerna -- run test -- --coverage
+2 -2
View File
@@ -27,14 +27,14 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache global yarn cache
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: cache node_modules
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
+1 -2
View File
@@ -82,12 +82,11 @@ Take a look at the [Getting Started](docs/getting-started/README.md) guide to le
- [Getting Started](docs/getting-started/README.md)
- [Create a Backstage App](docs/create-an-app.md)
- [Architecture](docs/architecture-terminology.md)
- [Architecture](docs/architecture-terminology.md) ([Decisions](docs/architecture-decisions))
- [API references](docs/reference/README.md)
- [Designing for Backstage](docs/design.md)
- [Storybook - UI components](http://storybook.backstage.io)
- [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md)
- Using Backstage components (TODO)
## Contributing
+9
View File
@@ -0,0 +1,9 @@
app:
title: Backstage Example App
baseUrl: http://localhost:3000
backend:
baseUrl: http://localhost:7000
organization:
name: Spotify
@@ -0,0 +1,77 @@
# ADR005: Catalog Core Entities
| Created | Status |
| ---------- | ------ |
| 2020-05-29 | Open |
## Context
We want to standardize on a few core entities that we are tracking in the Backstage catalog. This allows us to build specific plugins around them.
## Decision
Backstage should eventually support the following core entities:
* **Components** are individual pieces of software
* **APIs** are the boundaries between different components
* **Resources** are physical or virtual infrastructure needed to operate a component
![Catalog Core Entities][catalog-core-entities]
For now, we'll start by only implementing support for the Component entity in the Backstage catalog. This can later be extended to APIs, Resources and other potentially useful entities.
### Component
A component is a piece of software, for example a mobile application feature, web site, backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. It can implement APIs for other components to consume. In turn it might depend on APIs implemented by other components, or resources that are attached to it at runtime.
Component entities are typically defined in YAML descriptor files next to the code of the component, and could look like this (actual schema will evolve):
```yaml
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: my-component-name
spec:
type: service
```
### API
APIs form an abstraction that allows large software ecosystems to scale. Thus, APIs are a first class citizen in the Backstage model and the primary way to discover existing functionality in the ecosystem.
APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (eg in Protobuf, GraphQL or similar), a data schema (eg in Avro, TFRecord or similar), or as code interfaces (eg framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top.
APIs are typically indexed from existing definitions in source control and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve):
```yaml
apiVersion: backstage.io/v1beta1
kind: API
metadata:
name: my-component-api
spec:
type: grpc
definition: >
service HelloService {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
message HelloRequest {
string greeting = 1;
}
message HelloResponse {
string reply = 1;
}
```
### Resource
Resources are the infrastructure your software needs to operate at runtime like Bigtable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and APIs will allow us to visualize and create tooling around them in Backstage.
Resources are typically indexed from declarative definitions (eg Terraform, GCP Config Connector, AWS Cloud Formation) and/or inventories from cloud providers (eg GCP Asset Inventory) and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve):
```yaml
apiVersion: backstage.io/v1beta1
kind: Resource
metadata:
name: my-component-db
spec:
type: gcp-spanner
url: spanner.googleapis.com/projects/prj/instances/my-component-db/databases/my-db
```
## Consequences
We will continue fleshing out support for the Component entity in the Backstage catalog.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,16 +1,28 @@
# Development Environment
This section describes how to get set up for doing development on the Backstage repository.
## Cloning the Repository
After you have cloned the Backstage repository, you should run the following commands
once to set things up for development:
```bash
$ yarn install # fetch dependency packages - may take a while
$ yarn tsc # does a first run of type generation and checks
```
## Serving the Example App
Open a terminal window and start the web app using the following commands from the project root:
Open a terminal window and start the web app by using the following command from the project root.
Make sure you have run the above mentioned commands first.
```bash
$ yarn install # may take a while
$ yarn start
```
The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
By default, backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`.
@@ -44,6 +56,7 @@ yarn build # Build published versions of packages, depends on tsc
yarn lint # lint packages that have changed since later commit on origin/master
yarn lint:all # lint all packages
yarn lint:type-deps # verify that @types/* dependencies are placed correctly in packages
yarn test # test packages that have changed since later commit on origin/master
yarn test:all # test all packages
+1
View File
@@ -15,6 +15,7 @@
"test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"lint:type-deps": "node scripts/check-type-dependencies.js",
"docker-build": "yarn bundle && docker build . -t spotify/backstage",
"create-plugin": "backstage-cli create-plugin",
"remove-plugin": "backstage-cli remove-plugin",
+7
View File
@@ -71,6 +71,13 @@
"pathRewrite": {
"^/circleci/api/": "/"
}
},
"/catalog/api": {
"target": "http://localhost:3003",
"changeOrigin": true,
"pathRewrite": {
"^/catalog/api/": "/"
}
}
}
}
+2 -8
View File
@@ -14,14 +14,9 @@
* limitations under the License.
*/
import {
createApp,
AlertDisplay,
OAuthRequestDialog,
LoginPage,
} from '@backstage/core';
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import Root from './components/Root';
import * as plugins from './plugins';
import apis from './apis';
@@ -40,7 +35,6 @@ const App: FC<{}> = () => (
<OAuthRequestDialog />
<Router>
<Root>
<Route key="login" path="/login" component={LoginPage} exact />
<AppComponent />
</Root>
</Router>
+20
View File
@@ -25,9 +25,11 @@ import {
featureFlagsApiRef,
FeatureFlags,
GoogleAuth,
GithubAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
} from '@backstage/core';
import {
@@ -38,6 +40,7 @@ import {
import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
@@ -63,6 +66,15 @@ builder.add(
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
techRadarApiRef,
new TechRadar({
@@ -71,4 +83,12 @@ builder.add(
}),
);
builder.add(
catalogApiRef,
new CatalogClient({
apiOrigin: 'http://localhost:3000',
basePath: '/catalog/api',
}),
);
export default builder.build() as ApiHolder;
+9 -4
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"build": "tsc",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess nodemon",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"nodemon -r esm\\\"",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
@@ -18,13 +18,15 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@backstage/catalog-model": "^0.1.1-alpha.6",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.6",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.6",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"helmet": "^3.22.0",
"knex": "^0.21.1",
@@ -43,6 +45,9 @@
"typescript": "^3.9.2"
},
"nodemonConfig": {
"watch": "./dist"
"watch": [
"./dist",
"node_modules/@backstage*"
]
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-auth-backend';
import { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
}
+27 -10
View File
@@ -21,22 +21,39 @@ import {
DatabaseManager,
DescriptorParsers,
LocationReaders,
IngestionModels,
runPeriodically,
HigherOrderOperations,
} from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
import { EntityPolicies } from '@backstage/catalog-model';
export default async function ({ logger, database }: PluginEnvironment) {
const reader = LocationReaders.create();
const parser = DescriptorParsers.create();
const db = await DatabaseManager.createDatabase(database, logger);
runPeriodically(
() => DatabaseManager.refreshLocations(db, reader, parser, logger),
10000,
export default async function createPlugin({
logger,
database,
}: PluginEnvironment) {
const ingestionModel = new IngestionModels(
new LocationReaders(),
new DescriptorParsers(),
new EntityPolicies(),
);
const db = await DatabaseManager.createDatabase(database, logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db, reader);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
ingestionModel,
logger,
);
return await createRouter({ entitiesCatalog, locationsCatalog, logger });
runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000);
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
}
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-identity-backend';
import { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from '@backstage/plugin-scaffolder-backend';
import type { PluginEnvironment } from '../types';
export default async function ({ logger }: PluginEnvironment) {
export default async function createPlugin({ logger }: PluginEnvironment) {
const storage = new DiskStorage({ logger });
const templater = new CookieCutter();
+1 -1
View File
@@ -17,6 +17,6 @@
import { createRouter } from '@backstage/plugin-sentry-backend';
import { Logger } from 'winston';
export default async function (logger: Logger) {
export default async function createPlugin(logger: Logger) {
return await createRouter(logger);
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+12
View File
@@ -0,0 +1,12 @@
# Catalog Model
Contains the core model types and validators/policies used by the Backstage catalog functionality.
This package will be imported both by the frontend and backend parts of the catalog,
as well as by others that want to consume catalog data.
## Links
- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog)
- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend)
- [The Backstage homepage](https://backstage.io)
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.6",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"lodash": "^4.17.15",
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
"yaml": "^1.9.2"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,90 @@
/*
* 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,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
import { ComponentV1beta1Policy } from './kinds';
import { EntityPolicy } from './types';
import { DefaultNamespaceEntityPolicy } from './entity/policies/DefaultNamespaceEntityPolicy';
// Helper that requires that all of a set of policies can be successfully
// applied
class AllEntityPolicies implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
let result = entity;
for (const policy of this.policies) {
result = await policy.enforce(entity);
}
return result;
}
}
// Helper that requires that at least one of a set of policies can be
// successfully applied
class AnyEntityPolicy implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
async enforce(entity: Entity): Promise<Entity> {
for (const policy of this.policies) {
try {
return await policy.enforce(entity);
} catch {
continue;
}
}
throw new Error(`The entity did not match any known policy`);
}
}
export class EntityPolicies implements EntityPolicy {
private readonly policy: EntityPolicy;
static defaultPolicies(): EntityPolicy {
return EntityPolicies.allOf([
EntityPolicies.allOf([
new SchemaValidEntityPolicy(),
new DefaultNamespaceEntityPolicy(),
new NoForeignRootFieldsEntityPolicy(),
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
]);
}
static allOf(policies: EntityPolicy[]): EntityPolicy {
return new AllEntityPolicies(policies);
}
static anyOf(policies: EntityPolicy[]): EntityPolicy {
return new AnyEntityPolicy(policies);
}
constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) {
this.policy = policy;
}
enforce(entity: Entity): Promise<Entity> {
return this.policy.enforce(entity);
}
}
+108
View File
@@ -0,0 +1,108 @@
/*
* 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 format envelope that's common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type Entity = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace + kind pair.
*/
name: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
@@ -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 type { Entity, EntityMeta } from './Entity';
export * from './policies';
@@ -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.
*/
import yaml from 'yaml';
import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
describe('DefaultNamespaceEntityPolicy', () => {
let withNamespace: any;
let withoutNamespace: any;
let policy: DefaultNamespaceEntityPolicy;
beforeEach(() => {
withoutNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: my-component-yay
`);
withNamespace = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: my-component-yay
namespace: my-home
`);
policy = new DefaultNamespaceEntityPolicy();
});
it('leaves untouched if it already has a namespace', async () => {
const result = policy.enforce(withNamespace);
await expect(result).resolves.toBe(withNamespace);
await expect(result).resolves.toEqual(
expect.objectContaining({
metadata: { name: 'my-component-yay', namespace: 'my-home' },
}),
);
});
it('adds namespace in different object if it did not have one', async () => {
const result = policy.enforce(withoutNamespace);
await expect(result).resolves.not.toBe(withoutNamespace);
await expect(result).resolves.toEqual(
expect.objectContaining({
metadata: { name: 'my-component-yay', namespace: 'default' },
}),
);
});
});
@@ -0,0 +1,38 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import lodash from 'lodash';
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
/**
* Sets a default namespace if none was set.
*/
export class DefaultNamespaceEntityPolicy implements EntityPolicy {
private readonly namespace: string;
constructor(namespace: string = 'default') {
this.namespace = namespace;
}
async enforce(entity: Entity): Promise<Entity> {
if (entity.metadata.namespace) {
return entity;
}
return lodash.merge({ metadata: { namespace: this.namespace } }, entity);
}
}
@@ -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 yaml from 'yaml';
import { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
describe('FieldFormatEntityPolicy', () => {
let data: any;
let policy: FieldFormatEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new FieldFormatEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad apiVersion', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
data.apiVersion = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad kind', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
data.kind = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('handles missing metadata gracefully', async () => {
delete data.medatata;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('handles missing spec gracefully', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad name', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name.*7/);
data.metadata.name = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/name.*aaaa/);
});
it('rejects bad namespace', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*7/);
data.metadata.namespace = 'a'.repeat(1000);
await expect(policy.enforce(data)).rejects.toThrow(/namespace.*aaaa/);
});
it('rejects bad label key', async () => {
data.metadata.labels['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad label value', async () => {
data.metadata.labels.a = 'a#b';
await expect(policy.enforce(data)).rejects.toThrow(/label.*a#b/i);
});
it('rejects bad annotation key', async () => {
data.metadata.annotations['a#b'] = 'value';
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*a#b/i);
});
it('rejects bad annotation value', async () => {
data.metadata.annotations.a = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*7/i);
});
});
@@ -0,0 +1,88 @@
/*
* 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 { makeValidator, Validators } from '../../validation';
import { Entity } from '../Entity';
/**
* Ensures that the format of individual fields of the entity envelope
* is valid.
*
* This does not take into account machine generated fields such as uid, etag
* and generation.
*/
export class FieldFormatEntityPolicy implements EntityPolicy {
private readonly validators: Validators;
constructor(validators: Validators = makeValidator()) {
this.validators = validators;
}
async enforce(entity: Entity): Promise<Entity> {
function require(
field: string,
value: any,
validator: (value: any) => boolean,
) {
if (value === undefined || value === null) {
throw new Error(`${field} must have a value`);
}
let isValid: boolean;
try {
isValid = validator(value);
} catch (e) {
throw new Error(`${field} could not be validated, ${e}`);
}
if (!isValid) {
throw new Error(`${field} "${value}" is not valid`);
}
}
function optional(
field: string,
value: any,
validator: (value: any) => boolean,
) {
return value === undefined || require(field, value, validator);
}
require('apiVersion', entity.apiVersion, this.validators.isValidApiVersion);
require('kind', entity.kind, this.validators.isValidKind);
require('metadata.name', entity.metadata.name, this.validators
.isValidEntityName);
optional(
'metadata.namespace',
entity.metadata.namespace,
this.validators.isValidNamespace,
);
for (const [k, v] of Object.entries(entity.metadata.labels ?? [])) {
require(`labels.${k}`, k, this.validators.isValidLabelKey);
require(`labels.${k}`, v, this.validators.isValidLabelValue);
}
for (const [k, v] of Object.entries(entity.metadata.annotations ?? [])) {
require(`annotations.${k}`, k, this.validators.isValidAnnotationKey);
require(`annotations.${k}`, v, this.validators.isValidAnnotationValue);
}
return entity;
}
}
@@ -0,0 +1,52 @@
/*
* 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 yaml from 'yaml';
import { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
describe('NoForeignRootFieldsEntityPolicy', () => {
let data: any;
let policy: NoForeignRootFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new NoForeignRootFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects unknown root fields', async () => {
data.spec2 = {};
await expect(policy.enforce(data)).rejects.toThrow(/spec2/i);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec'];
/**
* Ensures that there are no foreign root fields in the entity.
*/
export class NoForeignRootFieldsEntityPolicy implements EntityPolicy {
private readonly knownFields: string[];
constructor(knownFields: string[] = defaultKnownFields) {
this.knownFields = knownFields;
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of Object.keys(entity)) {
if (!this.knownFields.includes(field)) {
throw new Error(`Unknown field ${field}`);
}
}
return entity;
}
}
@@ -0,0 +1,64 @@
/*
* 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 yaml from 'yaml';
import { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
describe('ReservedFieldsEntityPolicy', () => {
let data: any;
let policy: ReservedFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new ReservedFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects reserved keys in the spec root', async () => {
data.spec.apiVersion = 'a/b';
await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i);
});
it('rejects reserved keys in labels', async () => {
data.metadata.labels.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i);
});
it('rejects reserved keys in annotations', async () => {
data.metadata.annotations.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(
/annotation.*apiVersion/i,
);
});
});
@@ -0,0 +1,66 @@
/*
* 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 { Entity } from '../Entity';
const DEFAULT_RESERVED_ENTITY_FIELDS = [
'apiVersion',
'kind',
'uid',
'etag',
'generation',
'name',
'namespace',
'labels',
'annotations',
'spec',
];
/**
* Ensures that fields are not given certain reserved names.
*/
export class ReservedFieldsEntityPolicy implements EntityPolicy {
private readonly reservedFields: string[];
constructor(fields?: string[]) {
this.reservedFields = [
...(fields ?? []),
...DEFAULT_RESERVED_ENTITY_FIELDS,
];
}
async enforce(entity: Entity): Promise<Entity> {
for (const field of this.reservedFields) {
if (entity.spec?.hasOwnProperty(field)) {
throw new Error(
`The spec may not contain the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata.labels?.hasOwnProperty(field)) {
throw new Error(
`A label may not have the field ${field}, because it has reserved meaning`,
);
}
if (entity.metadata.annotations?.hasOwnProperty(field)) {
throw new Error(
`An annotation may not have the field ${field}, because it has reserved meaning`,
);
}
}
return entity;
}
}
@@ -0,0 +1,176 @@
/*
* 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 yaml from 'yaml';
import { Entity } from '../Entity';
import { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
describe('SchemaValidEntityPolicy', () => {
let data: any;
let policy: SchemaValidEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
spec:
custom: stuff
`);
policy = new SchemaValidEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
//
// apiVersion and kind
//
it('rejects wrong root type', async () => {
await expect(policy.enforce((7 as unknown) as Entity)).rejects.toThrow(
/object/,
);
});
it('rejects missing apiVersion', async () => {
delete data.apiVersion;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects bad apiVersion type', async () => {
data.apiVersion = 7;
await expect(policy.enforce(data)).rejects.toThrow(/apiVersion/);
});
it('rejects missing kind', async () => {
delete data.kind;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
it('rejects bad kind type', async () => {
data.kind = 7;
await expect(policy.enforce(data)).rejects.toThrow(/kind/);
});
//
// metadata
//
it('rejects missing metadata', async () => {
delete data.metadata;
await expect(policy.enforce(data)).rejects.toThrow(/metadata/);
});
it('rejects bad metadata type', async () => {
data.metadata = 7;
await expect(policy.enforce(data)).rejects.toThrow(/metadata/);
});
it('accepts missing uid', async () => {
delete data.metadata.uid;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad uid type', async () => {
data.metadata.uid = 7;
await expect(policy.enforce(data)).rejects.toThrow(/uid/);
});
it('accepts missing etag', async () => {
delete data.metadata.etag;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad etag type', async () => {
data.metadata.etag = 7;
await expect(policy.enforce(data)).rejects.toThrow(/etag/);
});
it('accepts missing generation', async () => {
delete data.metadata.generation;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad generation type', async () => {
data.metadata.generation = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/generation/);
});
it('rejects missing name', async () => {
delete data.metadata.name;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
});
it('rejects bad name type', async () => {
data.metadata.name = 7;
await expect(policy.enforce(data)).rejects.toThrow(/name/);
});
it('accepts missing namespace', async () => {
delete data.metadata.namespace;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad namespace type', async () => {
data.metadata.namespace = 7;
await expect(policy.enforce(data)).rejects.toThrow(/namespace/);
});
it('accepts missing labels', async () => {
delete data.metadata.labels;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad labels type', async () => {
data.metadata.labels = 7;
await expect(policy.enforce(data)).rejects.toThrow(/labels/);
});
it('accepts missing annotations', async () => {
delete data.metadata.annotations;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects bad annotations type', async () => {
data.metadata.annotations = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotations/);
});
//
// spec
//
it('accepts missing spec', async () => {
delete data.spec;
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects non-object spec', async () => {
data.spec = 7;
await expect(policy.enforce(data)).rejects.toThrow(/spec/);
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yup from 'yup';
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_ENTITY_SCHEMA = yup.object({
apiVersion: yup.string().required(),
kind: yup.string().required(),
metadata: yup
.object({
uid: yup
.string()
.notRequired()
.test(
'metadata.uid',
'The uid must not be empty',
value => value === undefined || value.length > 0,
),
etag: yup
.string()
.notRequired()
.test(
'metadata.etag',
'The etag must not be empty',
value => value === undefined || value.length > 0,
),
generation: yup
.number()
.notRequired()
.test(
'metadata.generation',
'The generation must be an integer greater than zero',
value => value === undefined || (value === (value | 0) && value > 0),
),
name: yup.string().required(),
namespace: yup.string().notRequired(),
labels: yup.object<Record<string, string>>().notRequired(),
annotations: yup.object<Record<string, string>>().notRequired(),
})
.required(),
spec: yup.object({}).notRequired(),
});
/**
* Ensures that the entity spec is valid according to a schema.
*
* This should be the first policy in the list, to ensure that other downstream
* policies can work with a structure that is at least valid in therms of the
* typescript type.
*/
export class SchemaValidEntityPolicy implements EntityPolicy {
private readonly schema: yup.Schema<Entity>;
constructor(schema: yup.Schema<Entity> = DEFAULT_ENTITY_SCHEMA) {
this.schema = schema;
}
async enforce(entity: Entity): Promise<Entity> {
try {
return await this.schema.validate(entity, { strict: true });
} catch (e) {
throw new Error(`Malformed envelope, ${e}`);
}
}
}
@@ -0,0 +1,20 @@
/*
* 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 { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
+22
View File
@@ -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.
*/
export * from './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export * from './location';
export type { EntityPolicy } from './types';
export * from './validation';
@@ -15,19 +15,28 @@
*/
import * as yup from 'yup';
import { DescriptorEnvelope, KindParser, ParserError } from '../types';
import type { Entity, EntityMeta } from '../entity/Entity';
import type { EntityPolicy } from '../types';
export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
export interface ComponentV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
metadata: EntityMeta & {
name: string;
};
spec: {
type: string;
};
}
export class ComponentDescriptorV1beta1Parser implements KindParser {
export class ComponentV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentDescriptorV1beta1>>({
this.schema = yup.object<Partial<ComponentV1beta1>>({
metadata: yup
.object({
name: yup.string().required(),
@@ -41,23 +50,14 @@ export class ComponentDescriptorV1beta1Parser implements KindParser {
});
}
async tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined> {
async enforce(envelope: Entity): Promise<Entity> {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
return undefined;
throw new Error('Unsupported apiVersion / kind');
}
try {
return await this.schema.validate(envelope, { strict: true });
} catch (e) {
throw new ParserError(
`Malformed component, ${e}`,
envelope.metadata?.name,
);
}
return await this.schema.validate(envelope, { strict: true });
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 type {
ComponentV1beta1,
ComponentV1beta1 as Component,
} from './ComponentV1beta1';
export { ComponentV1beta1Policy } from './ComponentV1beta1';
@@ -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 type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
@@ -13,13 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const proxySettings = {
'/circleci/api': {
target: 'https://circleci.com/api/v1.1',
changeOrigin: true,
logLevel: 'debug',
pathRewrite: {
'^/circleci/api/': '/',
},
},
export type LocationSpec = {
type: string;
target: string;
};
export type Location = {
id: string;
} & LocationSpec;
@@ -0,0 +1,33 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yup from 'yup';
import { LocationSpec, Location } from './types';
export const locationSpecSchema = yup
.object<LocationSpec>({
type: yup.string().required(),
target: yup.string().required(),
})
.noUnknown();
export const locationSchema = yup
.object<Location>({
id: yup.string().required(),
type: yup.string().required(),
target: yup.string().required(),
})
.noUnknown();
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { LoginPage } from './LoginPage';
export {};
+32
View File
@@ -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.
*/
import type { Entity } from './entity/Entity';
/**
* A policy for validation or mutation to be applied to entities as they are
* entering the system.
*/
export type EntityPolicy = {
/**
* Applies validation or mutation on an entity.
*
* @param entity The entity, as validated/mutated so far in the policy tree
* @returns The incoming entity, or a mutated version of the same
* @throws An error if the entity should be rejected
*/
enforce(entity: Entity): Promise<Entity>;
};
@@ -0,0 +1,20 @@
/*
* 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 { CommonValidatorFunctions } from './CommonValidatorFunctions';
export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions';
export { makeValidator } from './makeValidator';
export type { Validators } from './types';
+1 -1
View File
@@ -32,7 +32,7 @@ module.exports = {
ecmaVersion: 2018,
sourceType: 'module',
},
ignorePatterns: ['**/dist/**', '**/build/**'],
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
+1 -1
View File
@@ -39,7 +39,7 @@ module.exports = {
version: 'detect',
},
},
ignorePatterns: ['**/dist/**', '**/build/**'],
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
+26 -6
View File
@@ -1,17 +1,37 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"exclude": ["**/*.test.*"],
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"experimentalDecorators": false,
"forceConsistentCasingInFileNames": true,
"importHelpers": false,
"incremental": true,
"target": "ES2019",
"isolatedModules": true,
"jsx": "react",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
"module": "ESNext",
"moduleResolution": "node",
"noEmit": false,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"pretty": true,
"removeComments": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
"sourceMap": false,
"strict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"stripInternal": true,
"target": "ES2019",
"types": ["node", "jest"]
}
}
+4 -1
View File
@@ -35,7 +35,7 @@
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/web-scripts": "^6.0.0",
"@spotify/eslint-config": "^7.0.1",
"@sucrase/webpack-loader": "^2.0.0",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
@@ -44,6 +44,7 @@
"css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"eslint": "^7.1.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-monorepo": "^0.2.1",
"fork-ts-checker-webpack-plugin": "^4.0.5",
@@ -74,9 +75,11 @@
"tar": "^6.0.1",
"ts-jest": "^26.0.0",
"ts-loader": "^7.0.4",
"typescript": "^3.9.3",
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3",
"yaml": "^1.10.0",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
+2
View File
@@ -16,10 +16,12 @@
import { buildBundle } from '../../lib/bundler';
import { Command } from 'commander';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
await buildBundle({
entry: 'src/index',
statsJsonEnabled: cmd.stats,
appConfig: await loadConfig(),
});
};
+2
View File
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+8 -2
View File
@@ -16,12 +16,18 @@
import { Command } from 'commander';
import { run } from '../lib/run';
import { paths } from '../lib/paths';
export default async (cmd: Command) => {
const args = ['lint', '--max-warnings=0', '--format=codeframe'];
const args = [
'--ext=js,jsx,ts,tsx',
'--max-warnings=0',
'--format=codeframe',
paths.targetDir,
];
if (cmd.fix) {
args.push('--fix');
}
await run('web-scripts', args);
await run('eslint', args);
};
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+18
View File
@@ -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 type { AppConfig } from './types';
export { loadConfig } from './loaders';
@@ -0,0 +1,41 @@
/*
* 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 { AppConfig } from './types';
import fs from 'fs-extra';
import yaml from 'yaml';
import { paths } from '../paths';
type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};
export async function loadConfig(
options: LoadConfigOptions = {},
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options;
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type AppConfig = any;
+6
View File
@@ -51,6 +51,12 @@ export function createConfig(
);
}
plugins.push(
new webpack.EnvironmentPlugin({
APP_CONFIG: options.appConfig,
}),
);
return {
mode: isDev ? 'development' : 'production',
profile: false,
+4
View File
@@ -15,16 +15,20 @@
*/
import { BundlingPathsOptions } from './paths';
import { AppConfig } from '../app-config';
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
appConfig: AppConfig[];
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
appConfig: AppConfig[];
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
appConfig: AppConfig[];
};
@@ -0,0 +1,5 @@
app:
title: Scaffolded Backstage App
organization:
name: Acme Corporation
+4 -3
View File
@@ -31,9 +31,7 @@
"@backstage/theme": "^0.1.1-alpha.6",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "^5.2.0",
@@ -46,6 +44,9 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,38 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
export type Config = {
getConfig(key: string): Config;
getConfigArray(key: string): Config[];
getNumber(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getStringArray(key: string): string[] | undefined;
};
// Using interface to make the ConfigApi name show up in docs
export interface ConfigApi extends Config {}
export const configApiRef = createApiRef<ConfigApi>({
id: 'core.config',
description: 'Used to access runtime configuration',
});
@@ -24,6 +24,7 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
@@ -0,0 +1,226 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from './ConfigReader';
const DATA = {
zero: 0,
one: 1,
true: true,
false: false,
null: null,
string: 'string',
emptyString: '',
strings: ['string1', 'string2'],
badStrings: ['string1', ''],
worseStrings: ['string1', 3] as string[],
worstStrings: ['string1', 'string2', {}] as string[],
nested: {
one: 1,
string: 'string',
strings: ['string1', 'string2'],
},
nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[],
};
function expectValidValues(config: ConfigReader) {
expect(config.getNumber('zero')).toBe(0);
expect(config.getNumber('one')).toBe(1);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
expect(config.getConfig('nested').getNumber('one')).toBe(1);
expect(config.getConfig('nested').getString('string')).toBe('string');
expect(config.getConfig('nested').getStringArray('strings')).toEqual([
'string1',
'string2',
]);
const [config1, config2, config3] = config.getConfigArray('nestlings');
expect(config1.getBoolean('boolean')).toBe(true);
expect(config2.getString('string')).toBe('string');
expect(config3.getNumber('number')).toBe(42);
}
function expectInvalidValues(config: ConfigReader) {
expect(() => config.getNumber('string')).toThrow(
'Invalid type in config for key string, got string, wanted number',
);
expect(() => config.getString('one')).toThrow(
'Invalid type in config for key one, got number, wanted string',
);
expect(() => config.getNumber('true')).toThrow(
'Invalid type in config for key true, got boolean, wanted number',
);
expect(() => config.getStringArray('null')).toThrow(
'Invalid type in config for key null, got null, wanted string-array',
);
expect(() => config.getString('emptyString')).toThrow(
'Invalid type in config for key emptyString, got empty-string, wanted string',
);
expect(() => config.getStringArray('badStrings')).toThrow(
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
);
expect(() => config.getStringArray('worseStrings')).toThrow(
'Invalid type in config for key worseStrings[1], got number, wanted string',
);
expect(() => config.getStringArray('worstStrings')).toThrow(
'Invalid type in config for key worstStrings[2], got object, wanted string',
);
expect(() => config.getConfig('one')).toThrow(
'Invalid type in config for key one, got number, wanted object',
);
expect(() => config.getConfigArray('one')).toThrow(
'Invalid type in config for key one, got number, wanted object-array',
);
}
describe('ConfigReader', () => {
it('should read empty config with valid keys', () => {
const config = new ConfigReader({});
expect(config.getString('x')).toBeUndefined();
expect(config.getString('x_x')).toBeUndefined();
expect(config.getString('x-X')).toBeUndefined();
expect(config.getString('x0')).toBeUndefined();
expect(config.getString('X-x2')).toBeUndefined();
expect(config.getString('x0_x0')).toBeUndefined();
expect(config.getString('x_x-x_x')).toBeUndefined();
});
it('should throw on invalid keys', () => {
const config = new ConfigReader({});
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
expect(() => config.getString('(')).toThrow(/^Invalid config key/);
expect(() => config.getString('z-_')).toThrow(/^Invalid config key/);
expect(() => config.getString('-')).toThrow(/^Invalid config key/);
expect(() => config.getString('.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a..a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a...')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a._')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/);
});
it('should read valid values', () => {
const config = new ConfigReader(DATA);
expectValidValues(config);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
expectInvalidValues(config);
});
});
describe('ConfigReader with fallback', () => {
it('should behave as if without fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expect(config.getString('x')).toBeUndefined();
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
});
it('should read values from itself', () => {
const config = new ConfigReader(DATA, new ConfigReader({}));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from a fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from multiple levels of fallbacks', () => {
const config = new ConfigReader(
{},
new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))),
);
expectValidValues(config);
expectInvalidValues(config);
});
it('should read merged objects', () => {
const a = {
merged: {
x: 'x',
z: 'z1',
arr: ['a', 'b'],
config: { d: 'd' },
configs: [{ a: 'a' }],
},
};
const b = {
merged: {
y: 'y',
z: 'z2',
arr: ['c'],
config: { e: 'e' },
configs: [{ b: 'b' }],
},
};
const config = new ConfigReader(a, new ConfigReader(b));
expect(config.getString('merged.x')).toBe('x');
expect(config.getString('merged.y')).toBe('y');
expect(config.getString('merged.z')).toBe('z1');
expect(config.getConfig('merged').getString('x')).toBe('x');
expect(config.getConfig('merged').getString('y')).toBe('y');
expect(config.getConfig('merged').getString('z')).toBe('z1');
expect(config.getString('merged.config.d')).toBe('d');
expect(config.getString('merged.config.e')).toBe('e');
expect(config.getConfig('merged').getString('config.d')).toBe('d');
expect(config.getConfig('merged').getString('config.e')).toBe('e');
expect(config.getConfig('merged').getConfig('config').getString('d')).toBe(
'd',
);
expect(config.getConfig('merged').getConfig('config').getString('e')).toBe(
'e',
);
// Arrays are not merged
expect(config.getStringArray('merged.arr')).toEqual(['a', 'b']);
expect(config.getConfig('merged').getStringArray('arr')).toEqual([
'a',
'b',
]);
// Config arrays aren't merged either
expect(config.getConfigArray('merged.configs').length).toBe(1);
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(
config.getConfigArray('merged.configs')[0].getString('b'),
).toBeUndefined();
// Config arrays aren't merged either
expect(config.getConfig('merged').getConfigArray('configs').length).toBe(1);
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('a'),
).toBe('a');
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('b'),
).toBeUndefined();
});
});
@@ -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 { ConfigApi, Config } from '../../definitions/ConfigApi';
import { AppConfig } from '../../../app';
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
type JsonObject = { [key in string]: JsonValue };
type JsonArray = JsonValue[];
type JsonValue = JsonObject | JsonArray | number | string | boolean | null;
function isObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function typeOf(value: JsonValue | undefined): string {
if (value === null) {
return 'null';
} else if (Array.isArray(value)) {
return 'array';
}
const type = typeof value;
if (type === 'number' && isNaN(value as number)) {
return 'nan';
}
return type;
}
function typeErrorMessage(key: string, got: string, wanted: string) {
return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`;
}
function validateString(
key: string,
value: JsonValue | undefined,
): value is string {
if (typeof value === 'string' && value.length > 0) {
return true;
}
if (value === '') {
throw new TypeError(typeErrorMessage(key, 'empty-string', 'string'));
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'string'));
}
return false;
}
export class ConfigReader implements ConfigApi {
static nullReader = new ConfigReader({});
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
}
// Merge together all configs info a single config with recursive fallback
// readers, giving the first config object in the array the highest priority.
return configs.reduceRight((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined);
}
constructor(
private readonly data: JsonObject,
private readonly fallback?: ConfigApi,
) {}
getConfig(key: string): Config {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getConfig(key);
if (isObject(value)) {
return new ConfigReader(value, fallbackConfig);
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
}
return fallbackConfig ?? ConfigReader.nullReader;
}
getConfigArray(key: string): Config[] {
const values = this.readValue(key);
if (Array.isArray(values)) {
return values.map((value, index) => {
if (isObject(value)) {
return new ConfigReader(value);
}
throw new TypeError(
typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'),
);
});
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'object-array'),
);
}
return this.fallback?.getConfigArray(key) ?? [];
}
getNumber(key: string): number | undefined {
const value = this.readValue(key);
if (typeof value === 'number' && !isNaN(value)) {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'number'));
}
return this.fallback?.getNumber(key);
}
getBoolean(key: string): boolean | undefined {
const value = this.readValue(key);
if (typeof value === 'boolean') {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean'));
}
return this.fallback?.getBoolean(key);
}
getString(key: string): string | undefined {
const value = this.readValue(key);
if (validateString(key, value)) {
return value;
}
return this.fallback?.getString(key);
}
getStringArray(key: string): string[] | undefined {
const values = this.readValue(key);
if (Array.isArray(values)) {
for (const [index, value] of values.entries()) {
const iKey = `${key}[${index}]`;
if (!validateString(iKey, value)) {
throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string'));
}
}
return values as string[];
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'string-array'),
);
}
return this.fallback?.getStringArray(key);
}
private readValue(key: string): JsonValue | undefined {
const parts = key.split('.');
let value: JsonValue | undefined = this.data;
for (const part of parts) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid config key '${key}'`);
}
if (isObject(value)) {
value = value[part];
} else {
value = undefined;
}
}
return value;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ConfigReader } from './ConfigReader';
@@ -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.
*/
import GithubAuth from './GithubAuth';
describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token' });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
});
@@ -0,0 +1,111 @@
/*
* 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 GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type GithubAuthResponse = {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
const DEFAULT_PROVIDER = {
id: 'github',
title: 'Github',
icon: GithubIcon,
};
class GithubAuth implements OAuthApi {
static create({
apiOrigin,
basePath,
environment = 'dev',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
accessToken: res.accessToken,
scopes: GithubAuth.normalizeScope(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['user']),
sessionScopes: session => session.scopes,
});
return new GithubAuth(sessionManager);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
const normalizedScopes = GithubAuth.normalizeScope(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
if (session) {
return session.accessToken;
}
return '';
}
async logout() {
await this.sessionManager.removeSession();
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -14,7 +14,5 @@
* limitations under the License.
*/
export * from './CommonValidatorFunctions';
export * from './KubernetesValidatorFunctions';
export * from './makeValidator';
export * from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -13,14 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { ComponentFactory } from './component';
import { MockComponentFactory } from './mock-factory';
const componentFactory: ComponentFactory = MockComponentFactory;
export const withMockStore = (Component: React.ElementType) => {
return (props: any) => (
<Component {...props} componentFactory={componentFactory} />
);
export type GithubSession = {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
@@ -15,3 +15,4 @@
*/
export * from './google';
export * from './github';
@@ -22,5 +22,6 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
+35 -12
View File
@@ -17,7 +17,7 @@
import React, { ComponentType, FC } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents } from './types';
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
@@ -31,8 +31,11 @@ import {
AppTheme,
AppThemeSelector,
appThemeApiRef,
configApiRef,
ConfigReader,
} from '../apis';
import { ApiAggregator } from '../apis/ApiAggregator';
import { useAsync } from 'react-use';
type FullAppOptions = {
apis: ApiHolder;
@@ -40,6 +43,7 @@ type FullAppOptions = {
plugins: BackstagePlugin[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
};
export class PrivateAppImpl implements BackstageApp {
@@ -48,6 +52,7 @@ export class PrivateAppImpl implements BackstageApp {
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
constructor(options: FullAppOptions) {
this.apis = options.apis;
@@ -55,6 +60,7 @@ export class PrivateAppImpl implements BackstageApp {
this.plugins = options.plugins;
this.components = options.components;
this.themes = options.themes;
this.configLoader = options.configLoader;
}
getApis(): ApiHolder {
@@ -141,18 +147,35 @@ export class PrivateAppImpl implements BackstageApp {
}
getProvider(): ComponentType<{}> {
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
]);
const apis = new ApiAggregator(this.apis, appApis);
const Provider: FC<{}> = ({ children }) => {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(this.configLoader);
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
const Provider: FC<{}> = ({ children }) => (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
let childNode = children;
if (hasConfig && config.loading) {
const { Progress } = this.components;
childNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = this.components;
childNode = <BootErrorPage step="load-config" error={config.error} />;
}
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
]);
const apis = new ApiAggregator(this.apis, appApis);
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{childNode}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
@@ -49,10 +49,6 @@ function resolveTheme(
}
const useShouldPreferDarkTheme = () => {
if (!window.matchMedia) {
return false;
}
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
@@ -74,12 +70,16 @@ const useShouldPreferDarkTheme = () => {
export const AppThemeProvider: FC<{}> = ({ children }) => {
const appThemeApi = useApi(appThemeApiRef);
const shouldPreferDark = useShouldPreferDarkTheme();
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
// Browser feature detection won't change over time, so ignore lint rule
const shouldPreferDark = Boolean(window.matchMedia)
? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks
: false;
const appTheme = resolveTheme(
themeId,
shouldPreferDark,
+31
View File
@@ -20,10 +20,30 @@ import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme } from '../apis/definitions';
export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
};
/**
* TBD
*/
export type AppConfig = any;
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
export type AppOptions = {
/**
* A holder of all APIs available in the app.
@@ -68,6 +88,17 @@ export type AppOptions = {
* ```
*/
themes?: AppTheme[];
/**
* A function that loads in App configuration that will be accessible via
* the ConfigApi.
*
* Defaults to an empty config.
*
* TODO(Rugvip): Omitting this should instead default to loading in configuration
* that was packaged by the backstage-cli and default docker container boot script.
*/
configLoader?: AppConfigLoader;
};
export type BackstageApp = {
@@ -15,4 +15,5 @@
*/
export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
export { StaticAuthSessionManager } from './StaticAuthSessionManager';
export * from './types';
+1
View File
@@ -16,3 +16,4 @@
export * from './types';
export { createRouteRef } from './RouteRef';
export type { MutableRouteRef } from './RouteRef';
+7 -6
View File
@@ -33,13 +33,8 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/react": "^16.9",
"@types/react-sparklines": "^1.7.0",
"@types/zen-observable": "^0.8.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
@@ -61,6 +56,12 @@
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,74 @@
/*
* 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 { defaultConfigLoader } from './createApp';
describe('defaultConfigLoader', () => {
afterEach(() => {
delete process.env.APP_CONFIG;
});
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
]);
});
it('fails to load invalid missing config', async () => {
await expect(defaultConfigLoader()).rejects.toThrow(
'No static configuration provided',
);
});
it('fails to load invalid static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: { my: 'invalid-config' } as any,
});
await expect(defaultConfigLoader()).rejects.toThrow(
'Static configuration has invalid format',
);
});
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
});
+66 -2
View File
@@ -14,18 +14,60 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import privateExports, {
AppOptions,
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
AppConfig,
} from '@backstage/core-api';
import { BrowserRouter as Router } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
const { PrivateAppImpl } = privateExports;
/**
* The default config loader, which expects that config is available at compile-time
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
* returned by the config loader.
*
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
* which can be rewritten at runtime to contain an additional JSON config object.
* If runtime config is present, it will be placed first in the config array, overriding
* other config values.
*/
export const defaultConfigLoader: AppConfigLoader = async (
// This string may be replaced at runtime to provide additional config.
// It should be replaced by a JSON-serialized config object.
// It's a param so we can test it, but at runtime this will always fall back to default.
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
) => {
const appConfig = process.env.APP_CONFIG;
if (!appConfig) {
throw new Error('No static configuration provided');
}
if (!Array.isArray(appConfig)) {
throw new Error('Static configuration has invalid format');
}
const configs = (appConfig.slice() as unknown) as AppConfig[];
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
return configs;
};
// createApp is defined in core, and not core-api, since we need access
// to the components inside core to provide defaults.
// The actual implementation of the app class still lives in core-api,
@@ -38,12 +80,26 @@ export function createApp(options?: AppOptions) {
const DefaultNotFoundPage = () => (
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
);
const DefaultBootErrorPage: FC<BootErrorPageProps> = ({ step, error }) => {
let message = '';
if (step === 'load-config') {
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
}
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
return (
<Router>
<ErrorPage status="501" statusMessage={message} />
</Router>
);
};
const apis = options?.apis ?? ApiRegistry.from([]);
const icons = { ...defaultSystemIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
NotFoundErrorPage: DefaultNotFoundPage,
BootErrorPage: DefaultBootErrorPage,
Progress: Progress,
...options?.components,
};
const themes = options?.themes ?? [
@@ -60,8 +116,16 @@ export function createApp(options?: AppOptions) {
theme: darkTheme,
},
];
const configLoader = options?.configLoader ?? defaultConfigLoader;
const app = new PrivateAppImpl({ apis, icons, plugins, components, themes });
const app = new PrivateAppImpl({
apis,
icons,
plugins,
components,
themes,
configLoader,
});
app.verify();
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
@@ -33,16 +33,14 @@ const minProps = {
describe('<CodeSnippet />', () => {
it('renders text without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<CodeSnippet {...minProps} />));
expect(getByText(/"Hello"/)).toBeInTheDocument();
expect(getByText(/"World"/)).toBeInTheDocument();
});
it('renders without line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} />),
wrapInTestApp(<CodeSnippet {...minProps} />),
);
expect(queryByText('1')).not.toBeInTheDocument();
expect(queryByText('2')).not.toBeInTheDocument();
@@ -51,7 +49,7 @@ describe('<CodeSnippet />', () => {
it('renders with line numbers', () => {
const { queryByText } = render(
wrapInThemedTestApp(<CodeSnippet {...minProps} showLineNumbers />),
wrapInTestApp(<CodeSnippet {...minProps} showLineNumbers />),
);
expect(queryByText(/1/)).toBeInTheDocument();
expect(queryByText(/2/)).toBeInTheDocument();
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CopyTextButton from './CopyTextButton';
import {
ApiRegistry,
@@ -57,7 +57,7 @@ const apiRegistry = ApiRegistry.from([
describe('<CopyTextButton />', () => {
it('renders without exploding', () => {
const { getByDisplayValue } = render(
wrapInThemedTestApp(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
@@ -69,7 +69,7 @@ describe('<CopyTextButton />', () => {
it('displays tooltip on click', async () => {
document.execCommand = jest.fn();
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
@@ -16,7 +16,7 @@
import React from 'react';
// import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
import { renderWithEffects, wrapInThemedTestApp } from '@backstage/test-utils';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
// import { createSetting } from 'shared/apis/settings';
import DismissableBanner from './DismissableBanner';
@@ -30,7 +30,7 @@ describe('<DismissableBanner />', () => {
*/
const rendered = await renderWithEffects(
wrapInThemedTestApp(
wrapInTestApp(
<DismissableBanner
variant="info"
// setting={mockSetting}
@@ -31,6 +31,7 @@ const useStyles = makeStyles((theme: Theme) => ({
marginTop: -theme.spacing(3),
display: 'flex',
flexFlow: 'row nowrap',
zIndex: 'unset',
},
icon: {
fontSize: 20,
@@ -141,9 +141,9 @@ export const FeatureCalloutCircular: FC<Props> = ({
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update);
};
}, []);
}, [update]);
useLayoutEffect(update, [wrapperRef.current]);
useLayoutEffect(update, [wrapperRef.current, update]);
if (!show) {
return <>{children}</>;
@@ -51,27 +51,30 @@ function addRootElement(rootElem: Element): void {
export function usePortal(id: string): HTMLElement {
const rootElemRef = useRef<HTMLElement | null>(null);
useEffect(function setupElement() {
// Look for existing target dom element to append to
const existingParent = document.querySelector(`#${id}`);
// Parent is either a new root or the existing dom element
const parentElem = existingParent || createRootElement(id);
useEffect(
function setupElement() {
// Look for existing target dom element to append to
const existingParent = document.querySelector(`#${id}`);
// Parent is either a new root or the existing dom element
const parentElem = existingParent || createRootElement(id);
// If there is no existing DOM element, add a new one.
if (!existingParent) {
addRootElement(parentElem);
}
// Add the detached element to the parent
parentElem.appendChild(rootElemRef.current!);
return function removeElement() {
rootElemRef.current!.remove();
if (parentElem.childNodes.length === -1) {
parentElem.remove();
// If there is no existing DOM element, add a new one.
if (!existingParent) {
addRootElement(parentElem);
}
};
}, []);
// Add the detached element to the parent
parentElem.appendChild(rootElemRef.current!);
return function removeElement() {
rootElemRef.current!.remove();
if (parentElem.childNodes.length === -1) {
parentElem.remove();
}
};
},
[id],
);
/**
* It's important we evaluate this lazily:
@@ -45,7 +45,7 @@ function useCalloutHasBeenSeen(
const markSeen = useCallback(() => {
setState(featureId, true);
}, [featureId]);
}, [setState, featureId]);
return { seen: states[featureId] === true, markSeen };
}
@@ -16,7 +16,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInThemedTestApp } from '@backstage/test-utils';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
@@ -34,7 +34,7 @@ describe('<HorizontalScrollGrid />', () => {
it('renders without exploding', () => {
const rendered = render(
wrapInThemedTestApp(
wrapInTestApp(
<HorizontalScrollGrid>
<Grid item>item1</Grid>
<Grid item>item2</Grid>
@@ -69,7 +69,7 @@ describe('<HorizontalScrollGrid />', () => {
};
const rendered = await renderWithEffects(
wrapInThemedTestApp(
wrapInTestApp(
<HorizontalScrollGrid style={{ maxWidth: 300 }}>
<Grid item style={{ minWidth: 200 }}>
item1
@@ -16,29 +16,27 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import { Lifecycle } from './Lifecycle';
describe('<Lifecycle />', () => {
it('renders Alpha with shorthand', async () => {
const { getByText } = render(
wrapInThemedTestApp(<Lifecycle alpha shorthand />),
);
const { getByText } = render(wrapInTestApp(<Lifecycle alpha shorthand />));
expect(getByText('α')).toBeInTheDocument();
});
it('renders Alpha without shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle alpha />));
const { getByText } = render(wrapInTestApp(<Lifecycle alpha />));
expect(getByText('Alpha')).toBeInTheDocument();
});
it('renders Beta with shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle shorthand />));
const { getByText } = render(wrapInTestApp(<Lifecycle shorthand />));
expect(getByText('β')).toBeInTheDocument();
});
it('renders Beta without shorthand', async () => {
const { getByText } = render(wrapInThemedTestApp(<Lifecycle />));
const { getByText } = render(wrapInTestApp(<Lifecycle />));
expect(getByText('Beta')).toBeInTheDocument();
});
});
@@ -16,37 +16,33 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import CircleProgress, { getProgressColor } from './CircleProgress';
describe('<CircleProgress />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<CircleProgress value={10} fractional={false} />),
wrapInTestApp(<CircleProgress value={10} fractional={false} />),
);
getByText('10%');
});
it('handles fractional prop', () => {
const { getByText } = render(
wrapInThemedTestApp(<CircleProgress value={0.1} fractional />),
wrapInTestApp(<CircleProgress value={0.1} fractional />),
);
getByText('10%');
});
it('handles max prop', () => {
const { getByText } = render(
wrapInThemedTestApp(
<CircleProgress value={1} max={10} fractional={false} />,
),
wrapInTestApp(<CircleProgress value={1} max={10} fractional={false} />),
);
getByText('1%');
});
it('handles unit prop', () => {
const { getByText } = render(
wrapInThemedTestApp(
<CircleProgress value={10} fractional={false} unit="m" />,
),
wrapInTestApp(<CircleProgress value={10} fractional={false} unit="m" />),
);
getByText('10m');
});
@@ -29,6 +29,7 @@ type Props = {
};
const HorizontalProgress: FC<Props> = ({ value }) => {
const theme = useTheme<BackstageTheme>();
if (isNaN(value)) {
return null;
}
@@ -36,7 +37,6 @@ const HorizontalProgress: FC<Props> = ({ value }) => {
if (percent > 100) {
percent = 100;
}
const theme = useTheme<BackstageTheme>();
const strokeColor = getProgressColor(theme.palette, percent, false, 100);
return (
<Tooltip title={`${percent}%`}>
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import ProgressCard from './ProgressCard';
@@ -24,32 +24,26 @@ const minProps = { title: 'Tingle upgrade', progress: 0.12 };
describe('<ProgressCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...minProps} />));
expect(getByText(/Tingle.*/)).toBeInTheDocument();
});
it('renders progress and title', () => {
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...minProps} />));
expect(getByText(/Tingle.*/)).toBeInTheDocument();
expect(getByText(/12%.*/)).toBeInTheDocument();
});
it('does not render deepLink', () => {
const { queryByText } = render(
wrapInThemedTestApp(<ProgressCard {...minProps} />),
wrapInTestApp(<ProgressCard {...minProps} />),
);
expect(queryByText('View more')).not.toBeInTheDocument();
});
it('handles invalid numbers', () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' };
const { getByText } = render(
wrapInThemedTestApp(<ProgressCard {...badProps} />),
);
const { getByText } = render(wrapInTestApp(<ProgressCard {...badProps} />));
expect(getByText(/N\/A.*/)).toBeInTheDocument();
});
});
@@ -17,7 +17,7 @@
/* eslint-disable jest/no-disabled-tests */
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import TrendLine from '.';
@@ -25,7 +25,7 @@ describe('TrendLine', () => {
describe('when no data is present', () => {
it('renders null without throwing', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[]} title="sparkline" />),
);
expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument();
});
@@ -34,7 +34,7 @@ describe('TrendLine', () => {
describe('when one datapoint is present', () => {
it('renders as a straight line', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -43,7 +43,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes above the success threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.95]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.95]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -52,7 +52,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes within the the warning threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.65]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.65]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -61,7 +61,7 @@ describe('TrendLine', () => {
describe.skip('when the data finishes within the the error threshold', () => {
it('renders with the correct color', () => {
const rendered = render(
wrapInThemedTestApp(<TrendLine data={[0.5, 0.4]} title="sparkline" />),
wrapInTestApp(<TrendLine data={[0.5, 0.4]} title="sparkline" />),
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { wrapInTestApp } from '@backstage/test-utils';
import WarningPanel from './WarningPanel';
@@ -24,15 +24,13 @@ const minProps = { title: 'Mock title', message: 'Some more info' };
describe('<WarningPanel />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<WarningPanel {...minProps} />),
);
const { getByText } = render(wrapInTestApp(<WarningPanel {...minProps} />));
expect(getByText('Mock title')).toBeInTheDocument();
});
it('renders message and children', () => {
const { getByText } = render(
wrapInThemedTestApp(<WarningPanel {...minProps}>children</WarningPanel>),
wrapInTestApp(<WarningPanel {...minProps}>children</WarningPanel>),
);
expect(getByText('Some more info')).toBeInTheDocument();
expect(getByText('children')).toBeInTheDocument();

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