Merge branch 'master' into master
@@ -0,0 +1,21 @@
|
||||
---
|
||||
'@backstage/backend-common': minor
|
||||
'@backstage/cli': minor
|
||||
'@backstage/config-loader': minor
|
||||
'example-backend': patch
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
**BREAKING CHANGE**
|
||||
|
||||
The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed.
|
||||
Instead, the CLI and backend process now accept one or more `--config` flags to load config files.
|
||||
|
||||
Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded.
|
||||
If passing any `--config <path>` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one.
|
||||
|
||||
The old behaviour of for example `APP_ENV=development` can be replicated using the following flags:
|
||||
|
||||
```bash
|
||||
--config ../../app-config.yaml --config ../../app-config.development.yaml
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-model': minor
|
||||
---
|
||||
|
||||
Enable adding locations for config files that does not yet exist by adding a flag to api request
|
||||
@@ -6,6 +6,16 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re
|
||||
|
||||
## Next Release
|
||||
|
||||
### @backstage/cli
|
||||
|
||||
- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config <path>` arguments.
|
||||
|
||||
### @backstage/backend-common
|
||||
|
||||
- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config <path>` arguments.
|
||||
|
||||
## v0.1.1-alpha.25
|
||||
|
||||
> Collect changes for the next release below
|
||||
|
||||
### @backstage/cli
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
app:
|
||||
baseUrl: http://localhost:3000
|
||||
|
||||
backend:
|
||||
baseUrl: http://localhost:7000
|
||||
listen:
|
||||
port: 7000
|
||||
cors:
|
||||
origin: http://localhost:3000
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
credentials: true
|
||||
csp:
|
||||
connect-src: ["'self'", 'http:', 'https:']
|
||||
@@ -1,6 +1,6 @@
|
||||
app:
|
||||
title: Backstage Example App
|
||||
baseUrl: http://localhost:7000
|
||||
baseUrl: http://localhost:3000
|
||||
googleAnalyticsTrackingId: # UA-000000-0
|
||||
|
||||
backend:
|
||||
@@ -10,8 +10,12 @@ backend:
|
||||
database:
|
||||
client: sqlite3
|
||||
connection: ':memory:'
|
||||
cors:
|
||||
origin: http://localhost:3000
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
credentials: true
|
||||
csp:
|
||||
connect-src: ["'self'", 'https:']
|
||||
connect-src: ["'self'", 'http:', 'https:']
|
||||
|
||||
# See README.md in the proxy-backend plugin for information on the configuration format
|
||||
proxy:
|
||||
|
||||
@@ -28,6 +28,9 @@ spec:
|
||||
image: example-backend:latest
|
||||
imagePullPolicy: Never
|
||||
|
||||
command: [node, packages/backend]
|
||||
args: [--config, app-config.yaml, --config, k8s-config.yaml]
|
||||
|
||||
env:
|
||||
# We set this to development to make the backend start with incomplete configuration. In a production
|
||||
# deployment you will want to make sure that you have a full configuration, and remove any plugins that
|
||||
@@ -35,10 +38,6 @@ spec:
|
||||
- name: NODE_ENV
|
||||
value: development
|
||||
|
||||
# This makes us load in `app-config.production.yaml` if there is one.
|
||||
- name: APP_ENV
|
||||
value: production
|
||||
|
||||
# This makes it possible for the app to reach the backend when serving through `kubectl proxy`
|
||||
# If you expose the service using for example an ingress controller, you should
|
||||
# switch this out or remove it.
|
||||
@@ -54,8 +53,8 @@ spec:
|
||||
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /usr/src/app/app-config.local.yaml
|
||||
subPath: app-config.local.yaml
|
||||
mountPath: /usr/src/app/k8s-config.yaml
|
||||
subPath: k8s-config.yaml
|
||||
|
||||
resources:
|
||||
limits:
|
||||
@@ -77,7 +76,7 @@ spec:
|
||||
name: backstage-config
|
||||
items:
|
||||
- key: app-config
|
||||
path: app-config.local.yaml
|
||||
path: k8s-config.yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
|
||||
@@ -15,11 +15,11 @@ allowing for customization.
|
||||
|
||||
## Supplying Configuration
|
||||
|
||||
Configuration is stored in `app-config.yaml` files, with support for suffixes
|
||||
such as `app-config.production.yaml` to override values for specific
|
||||
environments. The configuration files themselves contain plain YAML, but with
|
||||
support for loading in secrets from various sources using for example `$env` and
|
||||
`$file` keys.
|
||||
Configuration is stored in YAML files where the defaults are `app-config.yaml`
|
||||
and `app-config.local.yaml` for local overrides. Other sets of files can by
|
||||
loaded by passing `--config <path>` flags. The configuration files themselves
|
||||
contain plain YAML, but with support for loading in secrets from various sources
|
||||
using for example `$env` and `$file` keys.
|
||||
|
||||
It is also possible to supply configuration through environment variables, for
|
||||
example `APP_CONFIG_app_baseUrl=https://staging.example.com`. However these
|
||||
|
||||
@@ -55,20 +55,28 @@ picked up by the serve tasks of `@backstage/cli` for local development, and are
|
||||
injected by the entrypoint of the nginx container serving the frontend in a
|
||||
production build.
|
||||
|
||||
## File Resolution
|
||||
## Configuration Files
|
||||
|
||||
It is possible to have multiple configuration files, both to support different
|
||||
environments, but also to define configuration that is local to specific
|
||||
packages.
|
||||
packages. The configuration files to load are selected using a `--config <path>`
|
||||
flag, and it is possible to load any number of files. Paths are relative to the
|
||||
working directory of the executed process, for example `package/backend`. This
|
||||
means that to select a config file in the repo root when running the backend,
|
||||
you would use `--config ../../my-config.yaml`.
|
||||
|
||||
All `app-config.yaml` files inside the monorepo root and package root are
|
||||
considered, as are files with additional `local` and environment affixes such as
|
||||
`development`, for example `app-config.local.yaml`,
|
||||
`app-config.production.yaml`, and `app-config.development.local.yaml`. Which
|
||||
environment config files are loaded is determined by the `APP_ENV` environment
|
||||
variable, or `NODE_ENV` if it is not set. Local configuration files are always
|
||||
loaded, but are meant for local development overrides and should typically be
|
||||
`.gitignore`'d.
|
||||
If no `config` flags are specified, the default behavior is to load
|
||||
`app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root.
|
||||
In the provided project setup, `app-config.local.yaml` is `.gitignore`'d, making
|
||||
it a good place to add config overrides and secrets for local development.
|
||||
|
||||
Note that if any config flags are provided, the default `app-config.yaml` files
|
||||
are NOT loaded. To include them you need to explicitly include them with a flag,
|
||||
for example:
|
||||
|
||||
```
|
||||
yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml
|
||||
```
|
||||
|
||||
All loaded configuration files are merged together using the following rules:
|
||||
|
||||
@@ -84,10 +92,10 @@ order:
|
||||
|
||||
- Configuration from the `APP_CONFIG_` environment variables has the highest
|
||||
priority, followed by files.
|
||||
- Files inside package directories have higher priority than those in the root
|
||||
directory.
|
||||
- Files with environment affixes have higher priority than ones without.
|
||||
- Files with the `local` affix have higher priority than ones without.
|
||||
- Files loaded with config flags are ordered by priority, where the last flag
|
||||
has the highest priority.
|
||||
- If no config flags are provided, `app-config.local.yaml` has higher priority
|
||||
than `app-config.yaml`.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -82,7 +82,9 @@ for companies to adopt. This involves (something like) the following work items.
|
||||
- “Solidify” work and “Mkdocs stabilization” work that has come out of our Q3
|
||||
end-to-end work.
|
||||
- Improve/simplify the get up and running process.
|
||||
- Introduce doc template Software Templates.
|
||||
- Introduce new documentation templates.
|
||||
- Extend the already existing docs-template to have options of different
|
||||
documentation types.
|
||||
- Enable companies to choose their own storage (S3 for example).
|
||||
- Enable companies to choose their own source code hosting provider (GitHub,
|
||||
GitLab, and so).
|
||||
|
||||
@@ -228,9 +228,6 @@ future.
|
||||
- [`app-config.yaml`](https://github.com/spotify/backstage/tree/master/app-config.yaml) -
|
||||
Configuration for the app, both frontend and backend
|
||||
|
||||
- [`app-config.development.yaml`](https://github.com/spotify/backstage/tree/master/app-config.development.yaml) -
|
||||
Used for overriding configuration when developing locally.
|
||||
|
||||
- [`catalog-info.yaml`](https://github.com/spotify/backstage/tree/master/catalog-info.yaml) -
|
||||
Description of Backstage in the Backstage Entity format.
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: New Cost Insights plugin: The engineer’s solution to taming cloud costs
|
||||
author: Janisa Anandamohan
|
||||
authorURL: https://twitter.com/janisa_a
|
||||
---
|
||||
|
||||
How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/spotify/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources.
|
||||
|
||||
<iframe width="780" height="440" src="https://www.youtube.com/embed/YLAd5hdXR_Q" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage.
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
## Why put a cost management tool in the hands of engineers?
|
||||
|
||||
Engineers are closest to the metal in terms of knowing why a specific feature, product, or service is using cloud resources. So they’re in the best position to understand how costs impact ongoing development (and vice versa).
|
||||
|
||||
If you manage costs top-down from a 10,000-foot view of your cloud infrastructure, you’re likely making decisions far removed from products, especially in larger organizations. Set a broad cost-cutting goal, and you could be creating unintended consequences — curtailing spending at the expense of growth or experimentation.
|
||||
|
||||
## Ground-level intelligence, data-driven solutions
|
||||
|
||||
Our hypothesis at Spotify was, if you bring spending data into an engineer’s everyday development workflow, they’ll naturally look for cost optimizations just like they look for any other optimization. And the cost optimizations will be more efficient and effective, because the decisions are informed at the ground level.
|
||||
|
||||
The problem is that most cloud platforms don’t provide cost data at a granular enough level to make those decisions. And the bigger your organization (say, two-thousand-microservices and four-thousand-data-pipelines big, like Spotify), then the less you can attribute these large, fuzzy numbers to the right team, let alone a shipping product or internal service.
|
||||
|
||||
That’s where Cost Insights comes in. Instead of making cost management and product development separate departments on the org chart, Backstage brings them together — with a level of detail and specificity engineers relate and respond to.
|
||||
|
||||
## How to turn dollars into sense
|
||||
|
||||
It’s not enough to make costs visible. To be useful, the numbers need to be relevant, relatable, and actionable. In other words, not just cost information, but insights. There are several ways the plugin puts data from your cloud provider in a more helpful context.
|
||||
|
||||
### Use business metrics to evaluate costs
|
||||
|
||||
Cost Insights will show you trends at a glance and also let you compare costs quarter over quarter. More importantly, you can also evaluate costs against business metrics that you care most about. In the example below, should the upward slope shown in the first screen be cause for worry? Perhaps not — if you switch views, you’ll see that cost per daily average user (DAU) is actually going down. Exactly what you hope to see as you scale.
|
||||
|
||||

|
||||
_(Note: Screens are examples; they do not show real data.)_
|
||||
|
||||
### Illustrate costs with relatable, real-world comparisons
|
||||
|
||||
In addition to dollar amounts, Cost Insights allows teams to visualize and convert cost overages into more relatable terms. In the example below, we equate the growth in costs for virtual machine instances (100% increase) to developer time spent (about 1 engineer). We use this particular comparison in the plugin because we found it resonated with our own engineers — providing a useful perspective for spending increases. You can configure what the “cost of an engineer” means to your organization. Or engineers can build in their own comparisons — cups of coffee, carbon offset credits, electric luxury vehicles — whatever makes costs more tangible for them.
|
||||
|
||||

|
||||
_(Note: Screens are examples; they do not show real data.)_
|
||||
|
||||
### Tie spending to specific products and resources
|
||||
|
||||
The more detailed the cost data, the more relevant, actionable, and helpful it is. Cost Insights allows you to attribute costs to products and resources in a way that makes sense to your engineers. For example, here we see a breakdown of data processing costs by individual pipelines. This allows your team to target optimizations more precisely.
|
||||
|
||||

|
||||
_(Note: Screens are examples; they do not show real data.)_
|
||||
|
||||
## Driving down costs without slowing down development
|
||||
|
||||
When it comes to cutting costs, we actually want to guard against over-optimization. Growth and costs can go hand in hand. The trick is knowing when one is out of balance and needs addressing. Our product highlights when there’s been a large increase in spending, so that engineers are thinking about cost only when they must and aren’t distracted from their set goals and priorities.
|
||||
|
||||
Engineers can then determine for themselves if the time invested in an optimization was valuable compared to the costs saved. Cost Insights puts the decision in our engineers’ hands for them to choose when to focus on growth efforts and when to focus on cost. Control, as ever, remains with our developers, where we think it belongs.
|
||||
|
||||
## Getting started
|
||||
|
||||
You can begin working with the Cost Insights plugin today on [GitHub](https://github.com/spotify/backstage/tree/master/plugins/cost-insights). We include an example client with static data in the expected format. The `CostInsightsApi` should talk with a cloud billing backend that aggregates billing data from your cloud provider.
|
||||
|
||||
The current release of Cost Insights includes:
|
||||
|
||||
- Daily cost graph by team or billing account
|
||||
- Cost comparisons against configurable business metrics (including an option for Daily Active Users)
|
||||
- Insights panels — configurable for the cloud products your company uses
|
||||
- Cost alerts and recommendations
|
||||
- Selectable time periods for month-over-month or quarter-over-quarter comparisons
|
||||
- Conversion of cost growth into “cost of average engineer” to help optimization trade-off decisions
|
||||
|
||||
Our hope is to help other companies translate their cloud cost in a relatable way for their engineers to better understand their impact and accurately identify their opportunities for optimizations.
|
||||
|
||||
And if you’re interested in contributing to our outstanding issues, you can find them in the issues queue, filtered under the [‘cost-insights’ label](https://github.com/spotify/backstage/labels/cost-insights).
|
||||
|
||||
## Ready for DevSecCostOpsPlus (and whatever’s next)
|
||||
|
||||
There’s DevOps, there’s DevSecOps, and then there’s Backstage: one frontend for all your infrastructure. From building, testing, and deploying to monitoring and security — Backstage helps you manage your entire tech organization and provides a seamless developer experience for engineers, from end to end to end. And now that also extends to cost management for your cloud infrastructure and tooling. Happy building and [happy optimizing](https://github.com/spotify/backstage/tree/master/plugins/cost-insights).
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -78,7 +78,41 @@ const Background = props => {
|
||||
</Block.Container>
|
||||
</Block>
|
||||
|
||||
<Block className="stripe-bottom bg-black-grey">
|
||||
<Block className="stripe bg-black-grey">
|
||||
<Block.Container style={{ justifyContent: 'flex-start' }}>
|
||||
<Block.TextBox>
|
||||
<Block.Title>Control cloud costs</Block.Title>
|
||||
<Block.Paragraph>
|
||||
How do you control cloud costs while maintaining the speed and
|
||||
independence of your development teams? With the{' '}
|
||||
<a href="https://backstage.io/plugins">Cost Insights plugin</a>{' '}
|
||||
for Backstage, managing cloud costs becomes just another part of
|
||||
an engineer’s daily development process. They get a clear view of
|
||||
their spending — and can decide for themselves how they want to
|
||||
optimize it. Learn more about the{' '}
|
||||
<a href="https://backstage.io/blog/2020/10/22/cost-insights-plugin">
|
||||
Cost Insights plugin
|
||||
</a>
|
||||
.
|
||||
</Block.Paragraph>
|
||||
<Block.LinkButton href="https://youtu.be/YLAd5hdXR_Q">
|
||||
Watch now
|
||||
</Block.LinkButton>
|
||||
</Block.TextBox>
|
||||
<Block.MediaFrame>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/YLAd5hdXR_Q"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</Block.MediaFrame>
|
||||
</Block.Container>
|
||||
</Block>
|
||||
|
||||
<Block className="stripe bg-black">
|
||||
<Block.Container style={{ justifyContent: 'flex-start' }}>
|
||||
<Block.TextBox>
|
||||
<Block.Title id="techdocs-demo">
|
||||
@@ -96,7 +130,7 @@ const Background = props => {
|
||||
</a>
|
||||
.
|
||||
</Block.Paragraph>
|
||||
<Block.LinkButton href={'https://youtu.be/mOLCgdPw1iA'}>
|
||||
<Block.LinkButton href="https://youtu.be/mOLCgdPw1iA">
|
||||
Watch now
|
||||
</Block.LinkButton>
|
||||
</Block.TextBox>
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
"@backstage/cli-common": "^0.1.1-alpha.25",
|
||||
"@backstage/config": "^0.1.1-alpha.25",
|
||||
"@backstage/config-loader": "^0.1.1-alpha.25",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.25",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"express-prom-bundle": "^6.1.0",
|
||||
"express-promise-router": "^3.0.3",
|
||||
@@ -44,8 +46,8 @@
|
||||
"knex": "^0.21.1",
|
||||
"lodash": "^4.17.15",
|
||||
"logform": "^2.1.1",
|
||||
"minimist": "^1.2.5",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"prom-client": "^12.0.0",
|
||||
"selfsigned": "^1.10.7",
|
||||
"stoppable": "^1.1.0",
|
||||
@@ -63,8 +65,8 @@
|
||||
"@backstage/cli": "^0.1.1-alpha.25",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/http-errors": "^1.6.3",
|
||||
"@types/minimist": "^1.2.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
@@ -72,8 +74,7 @@
|
||||
"get-port": "^5.1.1",
|
||||
"http-errors": "^1.7.3",
|
||||
"jest": "^26.0.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"msw": "^0.21.2",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -14,24 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import parseArgs from 'minimist';
|
||||
import { Logger } from 'winston';
|
||||
import { findPaths } from '@backstage/cli-common';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
type Options = {
|
||||
logger: Logger;
|
||||
// process.argv or any other overrides
|
||||
argv: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Load configuration for a Backend
|
||||
*/
|
||||
export async function loadBackendConfig(options: Options): Promise<Config> {
|
||||
const args = parseArgs(options.argv);
|
||||
const configOpts: string[] = [args.config ?? []].flat();
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const paths = findPaths(__dirname);
|
||||
const configs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
configRoot: paths.targetRoot,
|
||||
configPaths: configOpts.map(opt => resolvePath(opt)),
|
||||
shouldReadSecrets: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,14 +19,13 @@ import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { AzureUrlReader } from './AzureUrlReader';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
describe('AzureUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
|
||||
beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => worker.close());
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
@@ -41,7 +40,6 @@ describe('AzureUrlReader', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
afterEach(() => worker.resetHandlers());
|
||||
|
||||
const createConfig = (token?: string) =>
|
||||
new ConfigReader(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
@@ -76,7 +76,7 @@ export class AzureUrlReader implements UrlReader {
|
||||
|
||||
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
|
||||
if (response.ok && response.status !== 203) {
|
||||
return response.buffer();
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -19,14 +19,14 @@ import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { BitbucketUrlReader } from './BitbucketUrlReader';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
describe('BitbucketUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
|
||||
beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => worker.close());
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
@@ -41,7 +41,6 @@ describe('BitbucketUrlReader', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
afterEach(() => worker.resetHandlers());
|
||||
|
||||
const createConfig = (username?: string, appPassword?: string) =>
|
||||
new ConfigReader(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
import { NotFoundError } from '../errors';
|
||||
@@ -84,7 +84,7 @@ export class BitbucketUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return response.buffer();
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { UrlReader } from './types';
|
||||
|
||||
@@ -31,7 +31,7 @@ export class FetchUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return response.buffer();
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `could not read ${url}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import fetch, { HeadersInit, RequestInit, Response } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
|
||||
@@ -219,7 +219,7 @@ export class GithubUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return response.buffer();
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -19,14 +19,14 @@ import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { GitlabUrlReader } from './GitlabUrlReader';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
describe('GitlabUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
|
||||
beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => worker.close());
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
@@ -44,7 +44,6 @@ describe('GitlabUrlReader', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
afterEach(() => worker.resetHandlers());
|
||||
|
||||
const createConfig = (token?: string) =>
|
||||
new ConfigReader(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
@@ -77,7 +77,7 @@ export class GitlabUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return response.buffer();
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -64,7 +64,10 @@ function makeCreateEnv(config: Config) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await loadBackendConfig({ logger: getRootLogger() });
|
||||
const config = await loadBackendConfig({
|
||||
argv: process.argv,
|
||||
logger: getRootLogger(),
|
||||
});
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
|
||||
|
||||
@@ -143,13 +143,14 @@ export type EntityRelation = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds the relationship data for entities
|
||||
* Holds the relation data for entities.
|
||||
*/
|
||||
export type EntityRelationSpec = {
|
||||
/**
|
||||
* The source entity of this relation.
|
||||
*/
|
||||
source: EntityName;
|
||||
|
||||
/**
|
||||
* The type of the relation.
|
||||
*/
|
||||
|
||||
@@ -44,3 +44,4 @@ export type {
|
||||
UserEntityV1alpha1 as UserEntity,
|
||||
UserEntityV1alpha1,
|
||||
} from './UserEntityV1alpha1';
|
||||
export * from './relations';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
Naming rules for relations in priority order:
|
||||
|
||||
1. Use at most two words. One main verb and a specifier, e.g. "ownerOf"
|
||||
2. Reading out "<source-kind> <type> <target-kind>" should make sense in English.
|
||||
3. Maintain symmetry between pairs, e.g. "ownedBy" and "ownerOf" rather than "owns".
|
||||
*/
|
||||
|
||||
/**
|
||||
* An ownership relation where the owner is usually an organizational
|
||||
* entity (user or group), and the other entity can be anything.
|
||||
*/
|
||||
export const RELATION_OWNED_BY = 'ownedBy';
|
||||
export const RELATION_OWNER_OF = 'ownerOf';
|
||||
|
||||
/**
|
||||
* A relation with an API entity, typically from a component or system
|
||||
*/
|
||||
export const RELATION_CONSUMES_API = 'consumesApi';
|
||||
export const RELATION_PROVIDES_API = 'providesApi';
|
||||
|
||||
/**
|
||||
* A relation denoting a dependency on another entity.
|
||||
*/
|
||||
export const RELATION_DEPENDS_ON = 'dependsOn';
|
||||
export const RELATION_DEPENDENCY_OF = 'dependencyOf';
|
||||
|
||||
/**
|
||||
* A parent/child relation to build up a tree, used for example to describe
|
||||
* the organizational structure between groups.
|
||||
*/
|
||||
export const RELATION_PARENT_OF = 'parentOf';
|
||||
export const RELATION_CHILD_OF = 'childOf';
|
||||
|
||||
/**
|
||||
* A membership relation, typically for users in a group.
|
||||
*/
|
||||
export const RELATION_MEMBER_OF = 'memberOf';
|
||||
export const RELATION_HAS_MEMBER = 'hasMember';
|
||||
@@ -17,6 +17,10 @@
|
||||
export type LocationSpec = {
|
||||
type: string;
|
||||
target: string;
|
||||
// When using repo importer plugin, location is being created before the component yaml file is merged to the main branch.
|
||||
// This flag is then set to indicate that the file can be not present.
|
||||
// default value: 'required'.
|
||||
presence?: 'optional' | 'required';
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const locationSpecSchema = yup
|
||||
.object<LocationSpec>({
|
||||
type: yup.string().required(),
|
||||
target: yup.string().required(),
|
||||
presence: yup.string(),
|
||||
})
|
||||
.noUnknown()
|
||||
.required();
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
"@types/http-proxy": "^1.17.4",
|
||||
"@types/inquirer": "^7.3.1",
|
||||
"@types/mini-css-extract-plugin": "^0.9.1",
|
||||
"@types/mock-fs": "^4.13.0",
|
||||
"@types/node": "^13.7.2",
|
||||
"@types/ora": "^3.2.0",
|
||||
"@types/react-dev-utils": "^9.0.4",
|
||||
|
||||
@@ -15,27 +15,15 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
await buildBundle({
|
||||
entry: 'src/index',
|
||||
parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
|
||||
statsJsonEnabled: cmd.stats,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
...(await loadCliConfig(cmd.config)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,26 +15,14 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'src/index',
|
||||
checksEnabled: cmd.check,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
...(await loadCliConfig(cmd.config)),
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -14,28 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { Command } from 'commander';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { serveBackend } from '../../lib/bundler/backend';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
const waitForExit = await serveBackend({
|
||||
entry: 'src/index',
|
||||
checksEnabled: cmd.check,
|
||||
inspectEnabled: cmd.inspect,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -15,24 +15,13 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env:
|
||||
cmd.env ?? process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
shouldReadSecrets: cmd.withSecrets ?? false,
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
const { config } = await loadCliConfig(cmd.config, cmd.withSecrets ?? false);
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
const flatConfig = ConfigReader.fromConfigs(appConfigs).get();
|
||||
const flatConfig = config.get();
|
||||
|
||||
if (cmd.format === 'json') {
|
||||
process.stdout.write(`${JSON.stringify(flatConfig, null, 2)}\n`);
|
||||
|
||||
@@ -16,14 +16,20 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import mockFs from 'mock-fs';
|
||||
import os from 'os';
|
||||
import del from 'del';
|
||||
import { createTemporaryPluginFolder, movePlugin } from './createPlugin';
|
||||
|
||||
const id = 'testPluginMock';
|
||||
|
||||
describe('createPlugin', () => {
|
||||
afterAll(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('createPluginFolder', () => {
|
||||
it('should create a temporary plugin directory in the correct place', async () => {
|
||||
const id = 'testPlugin';
|
||||
const tempDir = path.join(os.tmpdir(), id);
|
||||
try {
|
||||
await createTemporaryPluginFolder(tempDir);
|
||||
@@ -35,35 +41,27 @@ describe('createPlugin', () => {
|
||||
});
|
||||
|
||||
it('should not create a temporary plugin directory if it already exists', async () => {
|
||||
const id = 'testPlugin';
|
||||
const tempDir = path.join(os.tmpdir(), id);
|
||||
try {
|
||||
await createTemporaryPluginFolder(tempDir);
|
||||
await expect(fs.pathExists(tempDir)).resolves.toBe(true);
|
||||
await expect(createTemporaryPluginFolder(tempDir)).rejects.toThrow(
|
||||
/Failed to create temporary plugin directory/,
|
||||
);
|
||||
} finally {
|
||||
await del(tempDir, { force: true });
|
||||
}
|
||||
mockFs({
|
||||
[id]: {},
|
||||
});
|
||||
|
||||
await expect(createTemporaryPluginFolder(id)).rejects.toThrow(
|
||||
/Failed to create temporary plugin directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePlugin', () => {
|
||||
it('should move the temporary plugin directory to its final place', async () => {
|
||||
const id = 'testPlugin';
|
||||
const tempDir = path.join(os.tmpdir(), id);
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
|
||||
const pluginDir = path.join(rootDir, 'plugins', id);
|
||||
try {
|
||||
await createTemporaryPluginFolder(tempDir);
|
||||
await movePlugin(tempDir, pluginDir, id);
|
||||
await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
|
||||
expect(pluginDir).toMatch(path.join('', 'plugins', id));
|
||||
} finally {
|
||||
await del(tempDir, { force: true });
|
||||
await del(rootDir, { force: true });
|
||||
}
|
||||
mockFs({
|
||||
[id]: {},
|
||||
});
|
||||
const tempDir = id;
|
||||
const pluginDir = `/test-temp/plugins/${id}`;
|
||||
|
||||
await movePlugin(tempDir, pluginDir, id);
|
||||
await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
|
||||
expect(pluginDir).toMatch(path.join('', 'plugins', id));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,25 @@ import { CommanderStatic } from 'commander';
|
||||
import { exitWithError } from '../lib/errors';
|
||||
|
||||
export function registerCommands(program: CommanderStatic) {
|
||||
const configOption = [
|
||||
'--config <path>',
|
||||
'Config files to load instead of app-config.yaml',
|
||||
(opt: string, opts: string[]) => [...opts, opt],
|
||||
Array<string>(),
|
||||
] as const;
|
||||
|
||||
program
|
||||
.command('app:build')
|
||||
.description('Build an app for a production release')
|
||||
.option('--stats', 'Write bundle stats to output directory')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./app/build').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('app:serve')
|
||||
.description('Serve an app for local development')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./app/serve').then(m => m.default)));
|
||||
|
||||
program
|
||||
@@ -50,6 +59,8 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.description('Start local development server with HMR for the backend')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option('--inspect', 'Enable debugger')
|
||||
// We don't actually use the config in the CLI, just pass them on to the NodeJS process
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./backend/dev').then(m => m.default)));
|
||||
|
||||
program
|
||||
@@ -89,12 +100,14 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.command('plugin:serve')
|
||||
.description('Serves the dev/ folder of a plugin')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./plugin/serve').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('plugin:export')
|
||||
.description('Exports the dev/ folder of a plugin')
|
||||
.option('--stats', 'Write bundle stats to output directory')
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./plugin/export').then(m => m.default)));
|
||||
|
||||
program
|
||||
@@ -131,14 +144,11 @@ export function registerCommands(program: CommanderStatic) {
|
||||
program
|
||||
.command('config:print')
|
||||
.option('--with-secrets', 'Include secrets in the printed configuration')
|
||||
.option(
|
||||
'--env <env>',
|
||||
'The environment to print configuration for [APP_ENV or NODE_ENV or development]',
|
||||
)
|
||||
.option(
|
||||
'--format <format>',
|
||||
'Format to print the configuration in, either json or yaml [yaml]',
|
||||
)
|
||||
.option(...configOption)
|
||||
.description('Print the app configuration for the current package')
|
||||
.action(lazy(() => import('./config/print').then(m => m.default)));
|
||||
|
||||
|
||||
@@ -15,25 +15,13 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
await buildBundle({
|
||||
entry: 'dev/index',
|
||||
statsJsonEnabled: cmd.stats,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
...(await loadCliConfig(cmd.config)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,26 +15,14 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig({
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
rootPaths: [paths.targetRoot, paths.targetDir],
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'dev/index',
|
||||
checksEnabled: cmd.check,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
...(await loadCliConfig(cmd.config)),
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -16,13 +16,9 @@
|
||||
|
||||
import fse from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import mockFs from 'mock-fs';
|
||||
import { paths } from '../../lib/paths';
|
||||
import {
|
||||
addExportStatement,
|
||||
capitalize,
|
||||
createTemporaryPluginFolder,
|
||||
} from '../create-plugin/createPlugin';
|
||||
import { addExportStatement, capitalize } from '../create-plugin/createPlugin';
|
||||
import { addCodeownersEntry } from '../../lib/codeowners';
|
||||
import {
|
||||
removeReferencesFromAppPackage,
|
||||
@@ -35,7 +31,7 @@ import {
|
||||
const BACKSTAGE = `@backstage`;
|
||||
const testPluginName = 'yarn-test-package';
|
||||
const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`;
|
||||
const tempDir = path.join(os.tmpdir(), 'remove-plugin-test');
|
||||
const tempDir = '/remove-plugin-test';
|
||||
|
||||
const removeEmptyLines = (file: string): string =>
|
||||
file.split(/\r?\n/).filter(Boolean).join('\n');
|
||||
@@ -47,13 +43,24 @@ const createTestPackageFile = async (
|
||||
// Copy contents of package file for test
|
||||
const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8'));
|
||||
|
||||
packageFileContent.dependencies[testPluginPackage] = '0.1.0';
|
||||
fse.createFileSync(testFilePath);
|
||||
fse.writeFileSync(
|
||||
testFilePath,
|
||||
`${JSON.stringify(packageFileContent, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
const testFileContent = {
|
||||
...packageFileContent,
|
||||
dependencies: {
|
||||
...packageFileContent.dependencies,
|
||||
[testPluginPackage]: '0.1.0',
|
||||
},
|
||||
};
|
||||
|
||||
mockFs({
|
||||
'/packages': {
|
||||
app: {
|
||||
'package.json': `${JSON.stringify(packageFileContent, null, 2)}\n`,
|
||||
},
|
||||
},
|
||||
[tempDir]: {
|
||||
[testFilePath]: `${JSON.stringify(testFileContent, null, 2)}\n`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -62,93 +69,126 @@ const createTestPluginFile = async (
|
||||
pluginsFilePath: string,
|
||||
) => {
|
||||
// Copy contents of package file for test
|
||||
fse.copyFileSync(pluginsFilePath, testFilePath);
|
||||
const pluginsFileContent = fse.readFileSync(pluginsFilePath);
|
||||
|
||||
mockFs({
|
||||
[tempDir]: {
|
||||
[testFilePath]: `${pluginsFileContent}\n`,
|
||||
[pluginsFilePath]: `${pluginsFileContent}\n`,
|
||||
},
|
||||
'/packages': {
|
||||
app: {
|
||||
src: {
|
||||
'plugin.ts': `${pluginsFileContent}\n`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pluginNameCapitalized = testPluginName
|
||||
.split('-')
|
||||
.map(name => capitalize(name))
|
||||
.join('');
|
||||
const exportStatement = `export { plugin as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`;
|
||||
await addExportStatement(testFilePath, exportStatement);
|
||||
const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`;
|
||||
await addExportStatement(path.join(tempDir, testFilePath), exportStatement);
|
||||
};
|
||||
|
||||
const mkTestPluginDir = (testDirPath: string) => {
|
||||
fse.mkdirSync(testDirPath);
|
||||
for (let i = 0; i < 50; i++)
|
||||
fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`));
|
||||
const dirPath = `/${testDirPath}`;
|
||||
|
||||
const pluginFiles: { [index: number]: string } = {};
|
||||
for (let i = 0; i < 50; i++) {
|
||||
pluginFiles[i] = '';
|
||||
}
|
||||
|
||||
mockFs({
|
||||
[dirPath]: pluginFiles,
|
||||
});
|
||||
};
|
||||
|
||||
describe('removePlugin', () => {
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
// Create temporary directory for all tests
|
||||
createTemporaryPluginFolder(tempDir);
|
||||
const appPath = paths.resolveTargetRoot('packages', 'app');
|
||||
mockFs({
|
||||
[tempDir]: {
|
||||
'package.json': mockFs.load(path.join(appPath, 'package.json')),
|
||||
src: {
|
||||
'plugin.ts': mockFs.load(path.join(appPath, 'src', 'plugins.ts')),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Remove temporary directory
|
||||
fse.removeSync(tempDir);
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('Remove Plugin Dependencies', () => {
|
||||
const appPath = paths.resolveTargetRoot('packages', 'app');
|
||||
const githubDir = paths.resolveTargetRoot('.github');
|
||||
|
||||
it('removes plugin references from /packages/app/package.json', async () => {
|
||||
// Set up test
|
||||
const packageFilePath = path.join(appPath, 'package.json');
|
||||
const testFilePath = path.join(tempDir, 'test.json');
|
||||
const packageFilePath = path.join(tempDir, 'package.json');
|
||||
const testFilePath = 'test.json';
|
||||
createTestPackageFile(testFilePath, packageFilePath);
|
||||
try {
|
||||
await removeReferencesFromAppPackage(testFilePath, testPluginName);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(testFilePath, 'utf8'),
|
||||
);
|
||||
const packageFileContent = removeEmptyLines(
|
||||
fse.readFileSync(packageFilePath, 'utf8'),
|
||||
);
|
||||
expect(testFileContent).toBe(packageFileContent);
|
||||
} finally {
|
||||
fse.removeSync(testFilePath);
|
||||
}
|
||||
});
|
||||
await removeReferencesFromAppPackage(
|
||||
path.join(tempDir, testFilePath),
|
||||
testPluginName,
|
||||
);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(path.join(tempDir, testFilePath), 'utf8'),
|
||||
);
|
||||
|
||||
it('removes plugin exports from /packages/app/src/package.json', async () => {
|
||||
const testFilePath = path.join(tempDir, 'test.ts');
|
||||
const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts');
|
||||
await createTestPluginFile(testFilePath, pluginsFilePaths);
|
||||
try {
|
||||
await removeReferencesFromPluginsFile(testFilePath, testPluginName);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(testFilePath, 'utf8'),
|
||||
);
|
||||
const pluginsFileContent = removeEmptyLines(
|
||||
fse.readFileSync(pluginsFilePaths, 'utf8'),
|
||||
);
|
||||
expect(testFileContent).toBe(pluginsFileContent);
|
||||
} finally {
|
||||
fse.removeSync(testFilePath);
|
||||
}
|
||||
const packageFileContent = removeEmptyLines(
|
||||
fse.readFileSync('/packages/app/package.json', 'utf8'),
|
||||
);
|
||||
expect(testFileContent).toBe(packageFileContent);
|
||||
});
|
||||
it('removes plugin exports from /packages/app/src/packacge.json', async () => {
|
||||
const testFilePath = 'test.ts';
|
||||
const pluginsFilePaths = path.join(tempDir, 'src/plugin.ts');
|
||||
createTestPluginFile(testFilePath, pluginsFilePaths);
|
||||
await removeReferencesFromPluginsFile(
|
||||
path.join(tempDir, testFilePath),
|
||||
testPluginName,
|
||||
);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(path.join(tempDir, testFilePath), 'utf8'),
|
||||
);
|
||||
const pluginsFileContent = removeEmptyLines(
|
||||
fse.readFileSync('/packages/app/src/plugin.ts', 'utf8'),
|
||||
);
|
||||
expect(testFileContent).toBe(pluginsFileContent);
|
||||
});
|
||||
|
||||
it('removes codeOwners references', async () => {
|
||||
const testFilePath = path.join(tempDir, 'test');
|
||||
const testFileName = 'test';
|
||||
const testFilePath = path.join(tempDir, testFileName);
|
||||
const codeownersPath = path.join(githubDir, 'CODEOWNERS');
|
||||
try {
|
||||
fse.copySync(codeownersPath, testFilePath);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(testFilePath, 'utf8'),
|
||||
);
|
||||
const codeOwnersFileContent = removeEmptyLines(
|
||||
fse.readFileSync(codeownersPath, 'utf8'),
|
||||
);
|
||||
await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [
|
||||
'@thisIsAtestTeam',
|
||||
'test@gmail.com',
|
||||
]);
|
||||
await removePluginFromCodeOwners(testFilePath, testPluginName);
|
||||
expect(testFileContent).toBe(codeOwnersFileContent);
|
||||
} finally {
|
||||
if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath);
|
||||
}
|
||||
const mockedCodeownersPath = '/.github/CODEOWNERS';
|
||||
|
||||
mockFs({
|
||||
[tempDir]: {
|
||||
[testFileName]: '',
|
||||
},
|
||||
'/.github': {
|
||||
CODEOWNERS: mockFs.load(codeownersPath),
|
||||
},
|
||||
});
|
||||
fse.copySync(mockedCodeownersPath, testFilePath);
|
||||
const testFileContent = removeEmptyLines(
|
||||
fse.readFileSync(testFilePath, 'utf8'),
|
||||
);
|
||||
const codeOwnersFileContent = removeEmptyLines(
|
||||
fse.readFileSync(mockedCodeownersPath, 'utf8'),
|
||||
);
|
||||
await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [
|
||||
'@thisIsAtestTeam',
|
||||
'test@gmail.com',
|
||||
]);
|
||||
await removePluginFromCodeOwners(testFilePath, testPluginName);
|
||||
expect(testFileContent).toBe(codeOwnersFileContent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,34 +201,35 @@ describe('removePlugin', () => {
|
||||
|
||||
describe('Removes Plugin Directory', () => {
|
||||
it('removes plugin directory from /plugins', async () => {
|
||||
try {
|
||||
mkTestPluginDir(testDirPath);
|
||||
expect(fse.existsSync(testDirPath)).toBeTruthy();
|
||||
await removePluginDirectory(testDirPath);
|
||||
expect(fse.existsSync(testDirPath)).toBeFalsy();
|
||||
} finally {
|
||||
if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath);
|
||||
}
|
||||
mkTestPluginDir(testDirPath);
|
||||
expect(fse.existsSync(testDirPath)).toBeTruthy();
|
||||
await removePluginDirectory(testDirPath);
|
||||
expect(fse.existsSync(testDirPath)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Removes System Link', () => {
|
||||
it('removes system link from @backstage', async () => {
|
||||
const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage');
|
||||
const testSymLinkPath = path.join(
|
||||
scopedDir,
|
||||
`plugin-${testPluginName}`,
|
||||
);
|
||||
try {
|
||||
mkTestPluginDir(testDirPath);
|
||||
fse.ensureSymlinkSync(testSymLinkPath, testDirPath);
|
||||
const symLink = `plugin-${testPluginName}`;
|
||||
const testSymLinkPath = `/node_modules/@backstage/${symLink}`;
|
||||
const mockedTestDirPath = path.join('/plugins', testPluginName);
|
||||
|
||||
await removeSymLink(testSymLinkPath);
|
||||
expect(fse.existsSync(testSymLinkPath)).toBeFalsy();
|
||||
} finally {
|
||||
if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath);
|
||||
if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath);
|
||||
}
|
||||
mockFs({
|
||||
'/plugins': {
|
||||
[testPluginName]: {},
|
||||
},
|
||||
'/node_modules': {
|
||||
'@backstage': {
|
||||
[symLink]: mockFs.symlink({
|
||||
path: mockedTestDirPath,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fse.existsSync(testSymLinkPath)).toBeTruthy();
|
||||
await removeSymLink(testSymLinkPath);
|
||||
expect(fse.existsSync(testSymLinkPath)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,9 @@
|
||||
import webpack from 'webpack';
|
||||
import { createBackendConfig } from './config';
|
||||
import { resolveBundlingPaths } from './paths';
|
||||
import { ServeOptions } from './types';
|
||||
import { BackendServeOptions } from './types';
|
||||
|
||||
export async function serveBackend(
|
||||
options: ServeOptions & {
|
||||
inspectEnabled: boolean;
|
||||
},
|
||||
) {
|
||||
export async function serveBackend(options: BackendServeOptions) {
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const config = await createBackendConfig(paths, {
|
||||
...options,
|
||||
|
||||
@@ -27,10 +27,6 @@ export type BundlingOptions = {
|
||||
parallel?: ParallelOption;
|
||||
};
|
||||
|
||||
export type BackendBundlingOptions = Omit<BundlingOptions, 'baseUrl'> & {
|
||||
inspectEnabled: boolean;
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
config: Config;
|
||||
@@ -43,3 +39,15 @@ export type BuildOptions = BundlingPathsOptions & {
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type BackendBundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
parallel?: ParallelOption;
|
||||
inspectEnabled: boolean;
|
||||
};
|
||||
|
||||
export type BackendServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
inspectEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from './paths';
|
||||
|
||||
export async function loadCliConfig(
|
||||
configArgs: string[],
|
||||
shouldReadSecrets: boolean = false,
|
||||
) {
|
||||
const configPaths = configArgs.map(arg => paths.resolveTarget(arg));
|
||||
|
||||
const appConfigs = await loadConfig({
|
||||
shouldReadSecrets,
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
|
||||
configRoot: paths.targetRoot,
|
||||
configPaths,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
return {
|
||||
appConfigs,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
};
|
||||
}
|
||||
@@ -15,39 +15,41 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import os from 'os';
|
||||
import del from 'del';
|
||||
import { templatingTask } from './tasks';
|
||||
|
||||
describe('templatingTask', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should template a directory with mix of regular files and templates', async () => {
|
||||
// Set up a testing template directory
|
||||
const tmplDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
|
||||
await fs.ensureDir(resolvePath(tmplDir, 'sub'));
|
||||
await fs.writeFile(resolvePath(tmplDir, 'test.txt'), 'testing');
|
||||
await fs.writeFile(
|
||||
resolvePath(tmplDir, 'sub/version.txt.hbs'),
|
||||
'version: {{version}}',
|
||||
);
|
||||
// Testing template directory
|
||||
const tmplDir = 'test-tmpl';
|
||||
|
||||
// Set up a temporary dest dir to write the template to
|
||||
const destDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
|
||||
// Temporary dest dir to write the template to
|
||||
const destDir = 'test-dest';
|
||||
|
||||
try {
|
||||
await templatingTask(tmplDir, destDir, {
|
||||
version: '0.0.0',
|
||||
});
|
||||
mockFs({
|
||||
[tmplDir]: {
|
||||
sub: {
|
||||
'version.txt.hbs': 'version: {{version}}',
|
||||
},
|
||||
'test.txt': 'testing',
|
||||
},
|
||||
[destDir]: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'),
|
||||
).resolves.toBe('testing');
|
||||
await expect(
|
||||
fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'),
|
||||
).resolves.toBe('version: 0.0.0');
|
||||
} finally {
|
||||
await del(tmplDir, { force: true });
|
||||
await del(destDir, { force: true });
|
||||
}
|
||||
await templatingTask(tmplDir, destDir, {
|
||||
version: '0.0.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'),
|
||||
).resolves.toBe('testing');
|
||||
await expect(
|
||||
fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'),
|
||||
).resolves.toBe('version: 0.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,14 +29,14 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"winston": "^3.2.1",
|
||||
"node-fetch": "^2.6.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{backstageVersion}}",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2",
|
||||
"msw": "^0.20.5"
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
*/
|
||||
|
||||
export {};
|
||||
global.fetch = require('node-fetch');
|
||||
|
||||
@@ -36,13 +36,14 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{backstageVersion}}",
|
||||
"@backstage/dev-utils": "^{{backstageVersion}}",
|
||||
"@backstage/test-utils": "^{{backstageVersion}}",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
"msw": "^0.21.2",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -5,18 +5,13 @@ import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
|
||||
describe('ExampleComponent', () => {
|
||||
const server = setupServer();
|
||||
// Enable API mocking before tests.
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
|
||||
// Reset any runtime request handlers we may add during the tests.
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
// Disable API mocking after the tests are done.
|
||||
afterAll(() => server.close())
|
||||
// Enable sane handlers for network requests
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
// setup mock response
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -3,18 +3,13 @@ import { render } from '@testing-library/react';
|
||||
import ExampleFetchComponent from './ExampleFetchComponent';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
describe('ExampleFetchComponent', () => {
|
||||
const server = setupServer();
|
||||
// Enable API mocking before tests.
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
|
||||
// Reset any runtime request handlers we may add during the tests.
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
// Disable API mocking after the tests are done.
|
||||
afterAll(() => server.close())
|
||||
|
||||
// Enable sane handlers for network requests
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
// setup mock response
|
||||
beforeEach(() => {
|
||||
server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({}))))
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import '@testing-library/jest-dom';
|
||||
global.fetch = require('node-fetch');
|
||||
import 'cross-fetch/polyfill'
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { resolveStaticConfig } from './resolver';
|
||||
export { readConfigFile } from './reader';
|
||||
export { readEnvConfig } from './env';
|
||||
export { readSecret } from './secrets';
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* 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 mockFs from 'mock-fs';
|
||||
import { resolveStaticConfig } from './resolver';
|
||||
|
||||
function normalizePaths(paths: string[]) {
|
||||
return paths.map(p =>
|
||||
p
|
||||
.replace(/^[a-z]:/i, '')
|
||||
.split('\\')
|
||||
.join('/'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('resolveStaticConfig', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should resolve no files for empty roots', async () => {
|
||||
mockFs({});
|
||||
const resolved = await resolveStaticConfig({
|
||||
env: 'development',
|
||||
rootPaths: [],
|
||||
});
|
||||
|
||||
expect(normalizePaths(resolved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should resolve a single app-config', async () => {
|
||||
mockFs({ '/repo/app-config.yaml': '' });
|
||||
const resolved = await resolveStaticConfig({
|
||||
env: 'development',
|
||||
rootPaths: ['/repo'],
|
||||
});
|
||||
|
||||
expect(normalizePaths(resolved)).toEqual(['/repo/app-config.yaml']);
|
||||
});
|
||||
|
||||
it('should resolve a app-configs in different directories', async () => {
|
||||
mockFs({
|
||||
'/repo/app-config.yaml': '',
|
||||
'/repo/packages/a/app-config.yaml': '',
|
||||
});
|
||||
const resolved = await resolveStaticConfig({
|
||||
env: 'development',
|
||||
rootPaths: [
|
||||
'/repo',
|
||||
'/other-repo',
|
||||
'/repo/packages/a',
|
||||
'/repo/packages/b',
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalizePaths(resolved)).toEqual([
|
||||
'/repo/app-config.yaml',
|
||||
'/repo/packages/a/app-config.yaml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve env and local configs', async () => {
|
||||
mockFs({
|
||||
'/repo/app-config.yaml': '',
|
||||
'/repo/app-config.local.yaml': '',
|
||||
'/repo/app-config.production.yaml': '',
|
||||
'/repo/app-config.production.local.yaml': '',
|
||||
'/repo/app-config.development.local.yaml': '',
|
||||
'/repo/packages/a/app-config.development.yaml': '',
|
||||
'/repo/packages/a/app-config.local.yaml': '',
|
||||
});
|
||||
const resolved = await resolveStaticConfig({
|
||||
env: 'development',
|
||||
rootPaths: ['/repo', '/repo/packages/a'],
|
||||
});
|
||||
|
||||
expect(normalizePaths(resolved)).toEqual([
|
||||
'/repo/app-config.yaml',
|
||||
'/repo/app-config.local.yaml',
|
||||
'/repo/app-config.development.local.yaml',
|
||||
'/repo/packages/a/app-config.local.yaml',
|
||||
'/repo/packages/a/app-config.development.yaml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves suffixed configs in the correct order', async () => {
|
||||
mockFs({
|
||||
'/repo/app-config.yaml': '',
|
||||
'/repo/app-config.local.yaml': '',
|
||||
'/repo/app-config.production.yaml': '',
|
||||
'/repo/app-config.production.local.yaml': '',
|
||||
});
|
||||
|
||||
const resolved = await resolveStaticConfig({
|
||||
env: 'production',
|
||||
rootPaths: ['/repo'],
|
||||
});
|
||||
|
||||
expect(normalizePaths(resolved)).toEqual([
|
||||
'/repo/app-config.yaml',
|
||||
'/repo/app-config.local.yaml',
|
||||
'/repo/app-config.production.yaml',
|
||||
'/repo/app-config.production.local.yaml',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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 { resolve as resolvePath } from 'path';
|
||||
import { pathExists } from 'fs-extra';
|
||||
|
||||
type ResolveOptions = {
|
||||
// Root paths to search for config files. Config from earlier paths has lower priority.
|
||||
rootPaths: string[];
|
||||
// The environment that we're loading config for, e.g. 'development', 'production'.
|
||||
env: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves all configuration files that should be loaded in the given environment.
|
||||
*
|
||||
* For each root directory, search for the default app-config.yaml, along with suffixed
|
||||
* APP_ENV and local variants, e.g. app-config.production.yaml or app-config.development.local.yaml
|
||||
*
|
||||
* The priority order of config loaded through suffixes is `env > local > none`, meaning that
|
||||
* for example app-config.development.yaml has higher priority than `app-config.local.yaml`.
|
||||
*
|
||||
*/
|
||||
export async function resolveStaticConfig(
|
||||
options: ResolveOptions,
|
||||
): Promise<string[]> {
|
||||
const filePaths = [
|
||||
`app-config.yaml`,
|
||||
`app-config.local.yaml`,
|
||||
`app-config.${options.env}.yaml`,
|
||||
`app-config.${options.env}.local.yaml`,
|
||||
];
|
||||
|
||||
const resolvedPaths = [];
|
||||
|
||||
for (const rootPath of options.rootPaths) {
|
||||
for (const filePath of filePaths) {
|
||||
const path = resolvePath(rootPath, filePath);
|
||||
if (await pathExists(path)) {
|
||||
resolvedPaths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedPaths;
|
||||
}
|
||||
@@ -38,10 +38,31 @@ describe('loadConfig', () => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('load config from default path', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
configRoot: '/root',
|
||||
configPaths: [],
|
||||
env: 'production',
|
||||
shouldReadSecrets: false,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
context: 'app-config.yaml',
|
||||
data: {
|
||||
app: {
|
||||
title: 'Example App',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads config without secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
rootPaths: ['/root'],
|
||||
configRoot: '/root',
|
||||
configPaths: ['/root/app-config.yaml'],
|
||||
env: 'production',
|
||||
shouldReadSecrets: false,
|
||||
}),
|
||||
@@ -60,7 +81,8 @@ describe('loadConfig', () => {
|
||||
it('loads config with secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
rootPaths: ['/root'],
|
||||
configRoot: '/root',
|
||||
configPaths: ['/root/app-config.yaml'],
|
||||
env: 'production',
|
||||
shouldReadSecrets: true,
|
||||
}),
|
||||
@@ -80,7 +102,11 @@ describe('loadConfig', () => {
|
||||
it('loads development config without secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
rootPaths: ['/root'],
|
||||
configRoot: '/root',
|
||||
configPaths: [
|
||||
'/root/app-config.yaml',
|
||||
'/root/app-config.development.yaml',
|
||||
],
|
||||
env: 'development',
|
||||
shouldReadSecrets: false,
|
||||
}),
|
||||
@@ -105,7 +131,11 @@ describe('loadConfig', () => {
|
||||
it('loads development config with secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
rootPaths: ['/root'],
|
||||
configRoot: '/root',
|
||||
configPaths: [
|
||||
'/root/app-config.yaml',
|
||||
'/root/app-config.development.yaml',
|
||||
],
|
||||
env: 'development',
|
||||
shouldReadSecrets: true,
|
||||
}),
|
||||
|
||||
@@ -15,20 +15,18 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import { resolve as resolvePath, dirname, isAbsolute } from 'path';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import {
|
||||
resolveStaticConfig,
|
||||
readConfigFile,
|
||||
readEnvConfig,
|
||||
readSecret,
|
||||
} from './lib';
|
||||
import { readConfigFile, readEnvConfig, readSecret } from './lib';
|
||||
|
||||
export type LoadConfigOptions = {
|
||||
// Root paths to search for config files. Config from earlier paths has lower priority.
|
||||
rootPaths: string[];
|
||||
// The root directory of the config loading context. Used to find default configs.
|
||||
configRoot: string;
|
||||
|
||||
// The environment that we're loading config for, e.g. 'development', 'production'.
|
||||
// Absolute paths to load config files from. Configs from earlier paths have lower priority.
|
||||
configPaths: string[];
|
||||
|
||||
// TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes.
|
||||
env: string;
|
||||
|
||||
// Whether to read secrets or omit them, defaults to false.
|
||||
@@ -77,13 +75,37 @@ export async function loadConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<AppConfig[]> {
|
||||
const configs = [];
|
||||
const { configRoot } = options;
|
||||
const configPaths = options.configPaths.slice();
|
||||
|
||||
const configPaths = await resolveStaticConfig(options);
|
||||
// If no paths are provided, we default to reading
|
||||
// `app-config.yaml` and, if it exists, `app-config.local.yaml`
|
||||
if (configPaths.length === 0) {
|
||||
configPaths.push(resolvePath(configRoot, 'app-config.yaml'));
|
||||
|
||||
const localConfig = resolvePath(configRoot, 'app-config.local.yaml');
|
||||
if (await fs.pathExists(localConfig)) {
|
||||
configPaths.push(localConfig);
|
||||
}
|
||||
|
||||
const envFile = `app-config.${options.env}.yaml`;
|
||||
if (await fs.pathExists(resolvePath(configRoot, envFile))) {
|
||||
console.error(
|
||||
`Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`,
|
||||
);
|
||||
console.error(
|
||||
`To load the config file, use --config <path>, listing every config file that you want to load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const secretPaths = new Set<string>();
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
if (!isAbsolute(configPath)) {
|
||||
throw new Error(`Config load path is not absolute: '${configPath}'`);
|
||||
}
|
||||
const config = await readConfigFile(
|
||||
configPath,
|
||||
new Context({
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.25",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.25",
|
||||
"@backstage/theme": "^0.1.1-alpha.25",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -49,7 +50,8 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -19,8 +19,9 @@ import { DefaultAuthConnector } from './DefaultAuthConnector';
|
||||
import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi';
|
||||
import * as loginPopup from '../loginPopup';
|
||||
import { UrlPatternDiscovery } from '../../apis';
|
||||
|
||||
const anyFetch = fetch as any;
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
|
||||
const defaultOptions = {
|
||||
discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'),
|
||||
@@ -39,19 +40,25 @@ const defaultOptions = {
|
||||
};
|
||||
|
||||
describe('DefaultAuthConnector', () => {
|
||||
const server = setupServer();
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
anyFetch.resetMocks();
|
||||
});
|
||||
|
||||
it('should refresh a session', async () => {
|
||||
anyFetch.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
idToken: 'mock-id-token',
|
||||
accessToken: 'mock-access-token',
|
||||
scopes: 'a b c',
|
||||
expiresInSeconds: '60',
|
||||
}),
|
||||
server.use(
|
||||
rest.get('*', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
idToken: 'mock-id-token',
|
||||
accessToken: 'mock-access-token',
|
||||
scopes: 'a b c',
|
||||
expiresInSeconds: '60',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const helper = new DefaultAuthConnector<any>(defaultOptions);
|
||||
@@ -64,7 +71,11 @@ describe('DefaultAuthConnector', () => {
|
||||
});
|
||||
|
||||
it('should handle failure to refresh session', async () => {
|
||||
anyFetch.mockRejectOnce(new Error('Network NOPE'));
|
||||
server.use(
|
||||
rest.get('*', (_req, res, ctx) =>
|
||||
res(ctx.status(500, 'Error: Network NOPE')),
|
||||
),
|
||||
);
|
||||
|
||||
const helper = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
@@ -73,7 +84,7 @@ describe('DefaultAuthConnector', () => {
|
||||
});
|
||||
|
||||
it('should handle failure response when refreshing session', async () => {
|
||||
anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' });
|
||||
server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE'))));
|
||||
|
||||
const helper = new DefaultAuthConnector(defaultOptions);
|
||||
await expect(helper.refreshSession()).rejects.toThrow(
|
||||
|
||||
@@ -15,5 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
import 'cross-fetch/polyfill';
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,36 +1 @@
|
||||
<svg width="693" height="425" viewBox="0 0 693 425" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" fill="black" fill-opacity="0.05"/>
|
||||
<g filter="url(#filter0_d)">
|
||||
<path d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V107.892C116 113.124 120.246 117.365 125.484 117.365H567.437C572.675 117.365 576.921 113.124 576.921 107.892V79.473C576.921 74.2412 572.675 70 567.437 70Z" fill="#9E9E9E"/>
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="116" y="70" width="461" height="277">
|
||||
<path d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V337.138C116 342.37 120.246 346.611 125.484 346.611H567.437C572.675 346.611 576.921 342.37 576.921 337.138V79.473C576.921 74.2412 572.675 70 567.437 70Z" fill="#404040"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M577 96.5244H116V347H577V96.5244Z" fill="#EEEEEE"/>
|
||||
<path opacity="0.4" d="M129.278 87.0483C131.373 87.0483 133.071 85.3525 133.071 83.2606C133.071 81.1687 131.373 79.4729 129.278 79.4729C127.182 79.4729 125.484 81.1687 125.484 83.2606C125.484 85.3525 127.182 87.0483 129.278 87.0483Z" fill="#D9D9D9"/>
|
||||
<path opacity="0.4" d="M142.762 87.0483C144.857 87.0483 146.555 85.3525 146.555 83.2606C146.555 81.1687 144.857 79.4729 142.762 79.4729C140.667 79.4729 138.968 81.1687 138.968 83.2606C138.968 85.3525 140.667 87.0483 142.762 87.0483Z" fill="#D9D9D9"/>
|
||||
<path opacity="0.3" d="M155.833 87.0483C157.928 87.0483 159.626 85.3525 159.626 83.2606C159.626 81.1687 157.928 79.4729 155.833 79.4729C153.738 79.4729 152.039 81.1687 152.039 83.2606C152.039 85.3525 153.738 87.0483 155.833 87.0483Z" fill="#D9D9D9"/>
|
||||
<rect x="116" y="96" width="27" height="251" fill="#616161"/>
|
||||
<rect x="143" y="96" width="434" height="31" fill="#D9D9D9"/>
|
||||
<rect x="153" y="136" width="60" height="7" rx="3.5" fill="white"/>
|
||||
<rect x="153" y="148" width="118" height="7" rx="3.5" fill="white"/>
|
||||
<rect x="515" y="136" width="52" height="16" rx="2" fill="#BDBDBD"/>
|
||||
<rect x="154.5" y="166.5" width="121" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
|
||||
<rect x="292.5" y="166.5" width="128" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
|
||||
<rect x="437.5" y="166.5" width="128" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
|
||||
<rect x="154.5" y="276.5" width="197" height="78" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
|
||||
<rect x="368.5" y="276.5" width="197" height="78" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="98" y="54" width="500.921" height="316.611" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dx="2" dy="4"/>
|
||||
<feGaussianBlur stdDeviation="10"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="693" height="425" fill="none" viewBox="0 0 693 425"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><path fill="#9E9E9E" d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V107.892C116 113.124 120.246 117.365 125.484 117.365H567.437C572.675 117.365 576.921 113.124 576.921 107.892V79.473C576.921 74.2412 572.675 70 567.437 70Z"/><mask id="mask0" width="461" height="277" x="116" y="70" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V337.138C116 342.37 120.246 346.611 125.484 346.611H567.437C572.675 346.611 576.921 342.37 576.921 337.138V79.473C576.921 74.2412 572.675 70 567.437 70Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M577 96.5244H116V347H577V96.5244Z"/><path fill="#D9D9D9" d="M129.278 87.0483C131.373 87.0483 133.071 85.3525 133.071 83.2606C133.071 81.1687 131.373 79.4729 129.278 79.4729C127.182 79.4729 125.484 81.1687 125.484 83.2606C125.484 85.3525 127.182 87.0483 129.278 87.0483Z" opacity=".4"/><path fill="#D9D9D9" d="M142.762 87.0483C144.857 87.0483 146.555 85.3525 146.555 83.2606C146.555 81.1687 144.857 79.4729 142.762 79.4729C140.667 79.4729 138.968 81.1687 138.968 83.2606C138.968 85.3525 140.667 87.0483 142.762 87.0483Z" opacity=".4"/><path fill="#D9D9D9" d="M155.833 87.0483C157.928 87.0483 159.626 85.3525 159.626 83.2606C159.626 81.1687 157.928 79.4729 155.833 79.4729C153.738 79.4729 152.039 81.1687 152.039 83.2606C152.039 85.3525 153.738 87.0483 155.833 87.0483Z" opacity=".3"/><rect width="27" height="251" x="116" y="96" fill="#616161"/><rect width="434" height="31" x="143" y="96" fill="#D9D9D9"/><rect width="60" height="7" x="153" y="136" fill="#fff" rx="3.5"/><rect width="118" height="7" x="153" y="148" fill="#fff" rx="3.5"/><rect width="52" height="16" x="515" y="136" fill="#BDBDBD" rx="2"/><rect width="121" height="94" x="154.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="128" height="94" x="292.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="128" height="94" x="437.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="197" height="78" x="154.5" y="276.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="197" height="78" x="368.5" y="276.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/></g></g><defs><filter id="filter0_d" width="500.921" height="316.611" x="98" y="54" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
@@ -1,44 +1 @@
|
||||
<svg width="693" height="425" viewBox="0 0 693 425" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" fill="black" fill-opacity="0.05"/>
|
||||
<g filter="url(#filter0_d)">
|
||||
<rect x="122" y="70" width="461" height="286" rx="10" fill="#F8F8F8"/>
|
||||
<rect x="150" y="96" width="55" height="7" rx="3.5" fill="#D9D9D9"/>
|
||||
<rect x="150" y="135" width="42" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="150" y="174" width="65" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="150" y="213" width="60" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="150" y="252" width="84" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="150" y="291" width="42" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="150" y="330" width="65" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="96" width="35" height="7" rx="3.5" fill="#D9D9D9"/>
|
||||
<rect x="282" y="135" width="102" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="174" width="77" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="213" width="93" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="252" width="42" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="291" width="69" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="282" y="330" width="97" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="96" width="92" height="7" rx="3.5" fill="#D9D9D9"/>
|
||||
<rect x="422" y="135" width="62" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="174" width="21" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="213" width="39" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="252" width="112" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="291" width="65" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<rect x="422" y="330" width="30" height="7" rx="3.5" fill="#BDBDBD"/>
|
||||
<line x1="138" y1="118.5" x2="567" y2="118.5" stroke="#EEEEEE"/>
|
||||
<line x1="138" y1="157.5" x2="567" y2="157.5" stroke="#EEEEEE"/>
|
||||
<line x1="138" y1="196.5" x2="567" y2="196.5" stroke="#EEEEEE"/>
|
||||
<line x1="138" y1="235.5" x2="567" y2="235.5" stroke="#EEEEEE"/>
|
||||
<line x1="138" y1="274.5" x2="567" y2="274.5" stroke="#EEEEEE"/>
|
||||
<line x1="138" y1="313.5" x2="567" y2="313.5" stroke="#EEEEEE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="112" y="62" width="485" height="310" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dx="2" dy="4"/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="693" height="425" fill="none" viewBox="0 0 693 425"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><rect width="461" height="286" x="122" y="70" fill="#F8F8F8" rx="10"/><rect width="55" height="7" x="150" y="96" fill="#D9D9D9" rx="3.5"/><rect width="42" height="7" x="150" y="135" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="150" y="174" fill="#BDBDBD" rx="3.5"/><rect width="60" height="7" x="150" y="213" fill="#BDBDBD" rx="3.5"/><rect width="84" height="7" x="150" y="252" fill="#BDBDBD" rx="3.5"/><rect width="42" height="7" x="150" y="291" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="150" y="330" fill="#BDBDBD" rx="3.5"/><rect width="35" height="7" x="282" y="96" fill="#D9D9D9" rx="3.5"/><rect width="102" height="7" x="282" y="135" fill="#BDBDBD" rx="3.5"/><rect width="77" height="7" x="282" y="174" fill="#BDBDBD" rx="3.5"/><rect width="93" height="7" x="282" y="213" fill="#BDBDBD" rx="3.5"/><rect width="42" height="7" x="282" y="252" fill="#BDBDBD" rx="3.5"/><rect width="69" height="7" x="282" y="291" fill="#BDBDBD" rx="3.5"/><rect width="97" height="7" x="282" y="330" fill="#BDBDBD" rx="3.5"/><rect width="92" height="7" x="422" y="96" fill="#D9D9D9" rx="3.5"/><rect width="62" height="7" x="422" y="135" fill="#BDBDBD" rx="3.5"/><rect width="21" height="7" x="422" y="174" fill="#BDBDBD" rx="3.5"/><rect width="39" height="7" x="422" y="213" fill="#BDBDBD" rx="3.5"/><rect width="112" height="7" x="422" y="252" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="422" y="291" fill="#BDBDBD" rx="3.5"/><rect width="30" height="7" x="422" y="330" fill="#BDBDBD" rx="3.5"/><line x1="138" x2="567" y1="118.5" y2="118.5" stroke="#EEE"/><line x1="138" x2="567" y1="157.5" y2="157.5" stroke="#EEE"/><line x1="138" x2="567" y1="196.5" y2="196.5" stroke="#EEE"/><line x1="138" x2="567" y1="235.5" y2="235.5" stroke="#EEE"/><line x1="138" x2="567" y1="274.5" y2="274.5" stroke="#EEE"/><line x1="138" x2="567" y1="313.5" y2="313.5" stroke="#EEE"/></g><defs><filter id="filter0_d" width="485" height="310" x="112" y="62" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="267" height="172" fill="none" viewBox="0 0 267 172"><g filter="url(#filter0_d)"><rect width="139" height="104.906" x="10" y="50.165" fill="#EEE" rx="5"/></g><mask id="mask0" width="121" height="98" x="19" y="58" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="9.179" height="70.156" x="19.835" y="85.571" fill="#fff" rx="4.59"/><rect width="9.179" height="78.679" x="38.194" y="77.047" fill="#fff" rx="4.59"/><rect width="9.179" height="97.693" x="56.552" y="58.033" fill="#fff" rx="4.59"/><rect width="9.179" height="81.957" x="74.91" y="73.769" fill="#fff" rx="4.59"/><rect width="9.179" height="60.321" x="93.269" y="95.406" fill="#fff" rx="4.59"/><rect width="9.179" height="74.09" x="111.627" y="81.637" fill="#fff" rx="4.59"/><rect width="9.179" height="93.104" x="129.986" y="62.623" fill="#fff" rx="4.59"/></mask><g mask="url(#mask0)"><rect width="139" height="100.316" x="10.656" y="50.165" fill="#C4C4C4"/></g><g filter="url(#filter1_d)"><rect width="144" height="108.679" x="109" y="8" fill="#EEE" rx="5"/></g><path fill="#D9D9D9" d="M173.85 62.1192C144.607 37.3215 129.993 65.1991 120.077 80.5984V106.585H241.923V25.7384C208.172 24.5834 212.569 94.9538 173.85 62.1192Z"/><path stroke="#9E9E9E" stroke-linecap="round" stroke-linejoin="round" d="M120.077 80.5984C129.993 65.1991 144.607 37.3215 173.85 62.1192C212.569 94.9539 208.172 24.5834 241.923 25.7384"/><defs><filter id="filter0_d" width="163" height="128.906" x="0" y="42.165" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter><filter id="filter1_d" width="168" height="132.679" x="99" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="267" height="172" fill="none" viewBox="0 0 267 172"><g filter="url(#filter0_d)"><rect width="139" height="104.906" x="10" y="50.165" fill="#EEE" rx="5"/></g><mask id="mask0" width="121" height="98" x="19" y="58" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="9.179" height="70.156" x="19.835" y="85.571" fill="#fff" rx="4.59"/><rect width="9.179" height="78.679" x="38.194" y="77.047" fill="#fff" rx="4.59"/><rect width="9.179" height="97.693" x="56.552" y="58.033" fill="#fff" rx="4.59"/><rect width="9.179" height="81.957" x="74.91" y="73.769" fill="#fff" rx="4.59"/><rect width="9.179" height="60.321" x="93.269" y="95.406" fill="#fff" rx="4.59"/><rect width="9.179" height="74.09" x="111.627" y="81.637" fill="#fff" rx="4.59"/><rect width="9.179" height="93.104" x="129.986" y="62.623" fill="#fff" rx="4.59"/></mask><g mask="url(#mask0)"><rect width="139" height="100.316" x="10.656" y="50.165" fill="#C4C4C4"/></g><g filter="url(#filter1_d)"><rect width="144" height="108.679" x="109" y="8" fill="#EEE" rx="5"/></g><path fill="#D9D9D9" d="M173.85 62.1192C144.607 37.3215 129.993 65.1991 120.077 80.5984V106.585H241.923V25.7384C208.172 24.5834 212.569 94.9538 173.85 62.1192Z"/><path stroke="#9E9E9E" stroke-linecap="round" stroke-linejoin="round" d="M120.077 80.5984C129.993 65.1991 144.607 37.3215 173.85 62.1192C212.569 94.9539 208.172 24.5834 241.923 25.7384"/><defs><filter id="filter0_d" width="163" height="128.906" x="0" y="42.165" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter><filter id="filter1_d" width="168" height="132.679" x="99" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -45,7 +45,9 @@ export const SidebarPinStateContext = createContext<SidebarPinStateContextType>(
|
||||
);
|
||||
|
||||
export const SidebarPage: FC<{}> = props => {
|
||||
const [isPinned, setIsPinned] = useState(LocalStorage.getSidebarPinState());
|
||||
const [isPinned, setIsPinned] = useState(() =>
|
||||
LocalStorage.getSidebarPinState(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
LocalStorage.setSidebarPinState(isPinned);
|
||||
|
||||
@@ -15,5 +15,3 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
app:
|
||||
baseUrl: http://localhost:3000
|
||||
|
||||
backend:
|
||||
baseUrl: http://localhost:7000
|
||||
listen:
|
||||
port: 7000
|
||||
cors:
|
||||
origin: http://localhost:3000
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
credentials: true
|
||||
csp:
|
||||
connect-src: ["'self'", 'http:', 'https:']
|
||||
@@ -1,6 +1,6 @@
|
||||
app:
|
||||
title: Scaffolded Backstage App
|
||||
baseUrl: http://localhost:7000
|
||||
baseUrl: http://localhost:3000
|
||||
|
||||
organization:
|
||||
name: My Company
|
||||
@@ -10,7 +10,11 @@ backend:
|
||||
listen:
|
||||
port: 7000
|
||||
csp:
|
||||
connect-src: ["'self'", 'https:']
|
||||
connect-src: ["'self'", 'http:', 'https:']
|
||||
cors:
|
||||
origin: http://localhost:3000
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
credentials: true
|
||||
{{#if dbTypeSqlite}}
|
||||
database:
|
||||
client: sqlite3
|
||||
|
||||
@@ -42,7 +42,10 @@ function makeCreateEnv(config: Config) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await loadBackendConfig({logger: getRootLogger()});
|
||||
const config = await loadBackendConfig({
|
||||
argv: process.argv,
|
||||
logger: getRootLogger(),
|
||||
});
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
@@ -52,16 +55,16 @@ async function main() {
|
||||
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
|
||||
|
||||
const apiRouter = Router();
|
||||
apiRouter.use('/catalog', await catalog(catalogEnv))
|
||||
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv))
|
||||
apiRouter.use('/auth', await auth(authEnv))
|
||||
apiRouter.use('/techdocs', await techdocs(techdocsEnv))
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv))
|
||||
apiRouter.use('/catalog', await catalog(catalogEnv));
|
||||
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
|
||||
apiRouter.use('/auth', await auth(authEnv));
|
||||
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv));
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
.loadConfig(config)
|
||||
.addRouter('/api', apiRouter)
|
||||
.addRouter('/api', apiRouter);
|
||||
|
||||
await service.start().catch(err => {
|
||||
console.log(err);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"commander": "^6.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"node-fetch": "^2.6.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"pgtools": "^0.3.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^8.6.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import fetch from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import handlebars from 'handlebars';
|
||||
import killTree from 'tree-kill';
|
||||
import { resolve as resolvePath, join as joinPath } from 'path';
|
||||
|
||||
@@ -17,7 +17,7 @@ FROM python:3.8-alpine
|
||||
|
||||
RUN apk update && apk --no-cache add gcc musl-dev openjdk11-jdk curl graphviz ttf-dejavu fontconfig
|
||||
|
||||
RUN curl -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download > /opt/plantuml.jar
|
||||
RUN curl -o plantuml.jar -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download && echo "c789ace48347c43073232b1458badc5810c01fe8 plantuml.jar" | sha1sum -c - && mv plantuml.jar /opt/plantuml.jar
|
||||
RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.8
|
||||
|
||||
# Create script to call plantuml.jar from a location in path
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/react": "^16.9",
|
||||
"msw": "^0.21.3",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
export * from './apis';
|
||||
export { default as mockBreakpoint } from './mockBreakpoint';
|
||||
export { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
export * from './msw';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 const msw = {
|
||||
setupDefaultHandlers: (worker: {
|
||||
listen: (t: any) => void;
|
||||
close: () => void;
|
||||
resetHandlers: () => void;
|
||||
}) => {
|
||||
beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => worker.close());
|
||||
afterEach(() => worker.resetHandlers());
|
||||
},
|
||||
};
|
||||
@@ -49,9 +49,8 @@
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9",
|
||||
"@types/swagger-ui-react": "^3.23.3",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
"msw": "^0.21.2",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -15,5 +15,3 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"knex": "^0.21.1",
|
||||
"moment": "^2.26.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"passport": "^0.4.1",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-gitlab2": "^5.0.0",
|
||||
@@ -48,6 +47,7 @@
|
||||
"passport-saml": "^1.3.3",
|
||||
"uuid": "^8.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,14 +55,12 @@
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-github2": "^1.2.4",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5"
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ConflictError,
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'auth-backend' });
|
||||
const config = await loadBackendConfig({ logger });
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
|
||||
@@ -14,6 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
export {};
|
||||
|
||||
@@ -28,7 +28,7 @@ exports.up = async function up(knex) {
|
||||
.inTable('entities')
|
||||
.onDelete('CASCADE')
|
||||
.notNullable()
|
||||
.comment('The originating entity of the relation');
|
||||
.comment('The entity that provided the relation');
|
||||
table
|
||||
.string('source_full_name')
|
||||
.notNullable()
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"@backstage/config": "^0.1.1-alpha.25",
|
||||
"@octokit/graphql": "^4.5.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"codeowners-utils": "^1.0.2",
|
||||
"core-js": "^3.6.5",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
@@ -36,7 +36,6 @@
|
||||
"ldapjs": "^2.2.0",
|
||||
"lodash": "^4.17.15",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"p-limit": "^3.0.2",
|
||||
"sqlite3": "^5.0.0",
|
||||
"uuid": "^8.0.0",
|
||||
@@ -47,6 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.25",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.25",
|
||||
"@types/core-js": "^2.5.4",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
"@types/ldapjs": "^1.0.9",
|
||||
@@ -54,8 +54,7 @@
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.29.8",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"msw": "^0.21.2",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getVoidLogger } from '@backstage/backend-common';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Database, DatabaseManager } from '../database';
|
||||
import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
|
||||
import { EntityUpsertRequest } from './types';
|
||||
|
||||
describe('DatabaseEntitiesCatalog', () => {
|
||||
let db: jest.Mocked<Database>;
|
||||
@@ -46,7 +47,7 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
db.transaction.mockImplementation(async f => f('tx'));
|
||||
});
|
||||
|
||||
describe('addOrUpdateEntity', () => {
|
||||
describe('batchAddOrUpdateEntities', () => {
|
||||
it('adds when no given uid and no matching by name', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -58,19 +59,25 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
};
|
||||
|
||||
db.entities.mockResolvedValue([]);
|
||||
db.addEntities.mockResolvedValue([{ entity }]);
|
||||
db.addEntities.mockResolvedValue([
|
||||
{ entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } },
|
||||
]);
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger());
|
||||
const result = await catalog.addOrUpdateEntity(entity);
|
||||
const result = await catalog.batchAddOrUpdateEntities([
|
||||
{ entity, relations: [] },
|
||||
]);
|
||||
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
'metadata.namespace': 'd',
|
||||
'metadata.name': ['c'],
|
||||
});
|
||||
expect(db.setRelations).toHaveBeenCalledTimes(1);
|
||||
expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []);
|
||||
expect(db.addEntities).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(entity);
|
||||
expect(result).toEqual([{ entityId: 'u' }]);
|
||||
});
|
||||
|
||||
it('updates when given uid', async () => {
|
||||
@@ -82,9 +89,11 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'b',
|
||||
},
|
||||
};
|
||||
|
||||
db.entityByUid.mockResolvedValue({
|
||||
const existing = {
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
@@ -95,14 +104,28 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'a',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
db.entities.mockResolvedValue([existing]);
|
||||
db.entityByUid.mockResolvedValue(existing);
|
||||
db.updateEntity.mockResolvedValue({ entity });
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger());
|
||||
const result = await catalog.addOrUpdateEntity(entity);
|
||||
const result = await catalog.batchAddOrUpdateEntities([
|
||||
{ entity, relations: [] },
|
||||
]);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(0);
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
'metadata.namespace': 'd',
|
||||
'metadata.name': ['c'],
|
||||
});
|
||||
expect(db.entityByName).not.toHaveBeenCalled();
|
||||
expect(db.entityByUid).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u');
|
||||
expect(db.updateEntity).toHaveBeenCalledTimes(1);
|
||||
@@ -114,17 +137,22 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
uid: 'u',
|
||||
etag: 'e',
|
||||
generation: 1,
|
||||
etag: expect.any(String),
|
||||
generation: 2,
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'b',
|
||||
},
|
||||
},
|
||||
},
|
||||
'e',
|
||||
1,
|
||||
);
|
||||
expect(result).toBe(entity);
|
||||
expect(db.setRelations).toHaveBeenCalledTimes(1);
|
||||
expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []);
|
||||
expect(result).toEqual([{ entityId: 'u' }]);
|
||||
});
|
||||
|
||||
it('update when no given uid and matching by name', async () => {
|
||||
@@ -135,25 +163,42 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'b',
|
||||
},
|
||||
};
|
||||
const existing: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
uid: 'u',
|
||||
etag: 'e',
|
||||
generation: 1,
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
const existing = {
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
uid: 'u',
|
||||
etag: 'e',
|
||||
generation: 1,
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'a',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
db.entityByName.mockResolvedValue({ entity: existing });
|
||||
db.updateEntity.mockResolvedValue({ entity: existing });
|
||||
db.entities.mockResolvedValue([existing]);
|
||||
db.entityByName.mockResolvedValue(existing);
|
||||
db.updateEntity.mockResolvedValue(existing);
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger());
|
||||
const result = await catalog.addOrUpdateEntity(added);
|
||||
const result = await catalog.batchAddOrUpdateEntities([
|
||||
{ entity: added, relations: [] },
|
||||
]);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
'metadata.namespace': 'd',
|
||||
'metadata.name': ['c'],
|
||||
});
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
@@ -169,51 +214,95 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
uid: 'u',
|
||||
etag: 'e',
|
||||
generation: 1,
|
||||
etag: expect.any(String),
|
||||
generation: 2,
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'b',
|
||||
},
|
||||
},
|
||||
},
|
||||
'e',
|
||||
1,
|
||||
);
|
||||
expect(result).toEqual(existing);
|
||||
expect(result).toEqual([{ entityId: 'u' }]);
|
||||
});
|
||||
|
||||
it('should not update if entity is unchanged', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
uid: 'u',
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
spec: {
|
||||
x: 'a',
|
||||
},
|
||||
};
|
||||
|
||||
db.entities.mockResolvedValue([{ entity }]);
|
||||
db.entityByUid.mockResolvedValue({ entity });
|
||||
db.updateEntity.mockResolvedValue({ entity });
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger());
|
||||
const result = await catalog.batchAddOrUpdateEntities([
|
||||
{ entity, relations: [] },
|
||||
]);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
'metadata.namespace': 'd',
|
||||
'metadata.name': ['c'],
|
||||
});
|
||||
expect(db.entityByName).not.toHaveBeenCalled();
|
||||
expect(db.entityByUid).not.toHaveBeenCalled();
|
||||
expect(db.updateEntity).not.toHaveBeenCalled();
|
||||
expect(db.setRelations).toHaveBeenCalledTimes(1);
|
||||
expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []);
|
||||
expect(result).toEqual([{ entityId: 'u' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchAddOrUpdateEntities', () => {
|
||||
it('both adds and updates', async () => {
|
||||
const catalog = new DatabaseEntitiesCatalog(
|
||||
await DatabaseManager.createTestDatabase(),
|
||||
getVoidLogger(),
|
||||
);
|
||||
const entities: Entity[] = [];
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
const entities: EntityUpsertRequest[] = [];
|
||||
for (let i = 0; i < 300; ++i) {
|
||||
entities.push({
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: `n${i}` },
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: `n${i}` },
|
||||
},
|
||||
relations: [],
|
||||
});
|
||||
}
|
||||
|
||||
await catalog.batchAddOrUpdateEntities(entities);
|
||||
const afterFirst = await catalog.entities();
|
||||
expect(afterFirst.length).toBe(500);
|
||||
expect(afterFirst.length).toBe(300);
|
||||
|
||||
entities[40].metadata.op = 'changed';
|
||||
entities[40].entity.metadata.op = 'changed';
|
||||
entities.push({
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: `n500`, op: 'added' },
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: `n300`, op: 'added' },
|
||||
},
|
||||
relations: [],
|
||||
});
|
||||
|
||||
await catalog.batchAddOrUpdateEntities(entities);
|
||||
const afterSecond = await catalog.entities();
|
||||
expect(afterSecond.length).toBe(501);
|
||||
expect(afterSecond.length).toBe(301);
|
||||
expect(afterSecond.find(e => e.metadata.op === 'changed')).toBeDefined();
|
||||
expect(afterSecond.find(e => e.metadata.op === 'added')).toBeDefined();
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,11 @@ import limiterFactory from 'p-limit';
|
||||
import { Logger } from 'winston';
|
||||
import type { Database, DbEntityResponse, EntityFilters } from '../database';
|
||||
import { durationText } from '../util/timing';
|
||||
import type { EntitiesCatalog } from './types';
|
||||
import type {
|
||||
EntitiesCatalog,
|
||||
EntityUpsertRequest,
|
||||
EntityUpsertResponse,
|
||||
} from './types';
|
||||
|
||||
type BatchContext = {
|
||||
kind: string;
|
||||
@@ -63,7 +67,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
return items.map(i => i.entity);
|
||||
}
|
||||
|
||||
async addOrUpdateEntity(
|
||||
private async addOrUpdateEntity(
|
||||
entity: Entity,
|
||||
locationId?: string,
|
||||
): Promise<Entity> {
|
||||
@@ -96,15 +100,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
});
|
||||
}
|
||||
|
||||
async addEntities(entities: Entity[], locationId?: string): Promise<void> {
|
||||
await this.database.transaction(async tx => {
|
||||
await this.database.addEntities(
|
||||
tx,
|
||||
entities.map(entity => ({ locationId, entity })),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async removeEntityByUid(uid: string): Promise<void> {
|
||||
return await this.database.transaction(async tx => {
|
||||
const entityResponse = await this.database.entityByUid(tx, uid);
|
||||
@@ -138,27 +133,31 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
* @param entities Some entities
|
||||
* @param locationId The location that they all belong to
|
||||
*/
|
||||
async batchAddOrUpdateEntities(entities: Entity[], locationId?: string) {
|
||||
async batchAddOrUpdateEntities(
|
||||
requests: EntityUpsertRequest[],
|
||||
locationId?: string,
|
||||
): Promise<EntityUpsertResponse[]> {
|
||||
// Group the entities by unique kind+namespace combinations
|
||||
const entitiesByKindAndNamespace = groupBy(entities, entity => {
|
||||
const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => {
|
||||
const name = getEntityName(entity);
|
||||
return `${name.kind}:${name.namespace}`.toLowerCase();
|
||||
});
|
||||
|
||||
const limiter = limiterFactory(BATCH_CONCURRENCY);
|
||||
const tasks: Promise<void>[] = [];
|
||||
const tasks: Promise<EntityUpsertResponse[]>[] = [];
|
||||
|
||||
for (const groupEntities of Object.values(entitiesByKindAndNamespace)) {
|
||||
const { kind, namespace } = getEntityName(groupEntities[0]);
|
||||
for (const groupRequests of Object.values(entitiesByKindAndNamespace)) {
|
||||
const { kind, namespace } = getEntityName(groupRequests[0].entity);
|
||||
|
||||
// Go through the new entities in reasonable chunk sizes (sometimes,
|
||||
// sources produce tens of thousands of entities, and those are too large
|
||||
// batch sizes to reasonably send to the database)
|
||||
for (const batch of chunk(groupEntities, BATCH_SIZE)) {
|
||||
for (const batch of chunk(groupRequests, BATCH_SIZE)) {
|
||||
tasks.push(
|
||||
limiter(async () => {
|
||||
const first = serializeEntityRef(batch[0]);
|
||||
const last = serializeEntityRef(batch[batch.length - 1]);
|
||||
const first = serializeEntityRef(batch[0].entity);
|
||||
const last = serializeEntityRef(batch[batch.length - 1].entity);
|
||||
const modifiedEntityIds: EntityUpsertResponse[] = [];
|
||||
this.logger.debug(
|
||||
`Considering batch ${first}-${last} (${batch.length} entries)`,
|
||||
);
|
||||
@@ -167,12 +166,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
const context = { kind, namespace, locationId };
|
||||
for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) {
|
||||
try {
|
||||
const { toAdd, toUpdate } = await this.analyzeBatch(
|
||||
const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch(
|
||||
batch,
|
||||
context,
|
||||
);
|
||||
if (toAdd.length) await this.batchAdd(toAdd, context);
|
||||
if (toUpdate.length) await this.batchUpdate(toUpdate, context);
|
||||
if (toAdd.length) {
|
||||
modifiedEntityIds.push(
|
||||
...(await this.batchAdd(toAdd, context)),
|
||||
);
|
||||
}
|
||||
if (toUpdate.length) {
|
||||
modifiedEntityIds.push(
|
||||
...(await this.batchUpdate(toUpdate, context)),
|
||||
);
|
||||
}
|
||||
// TODO(Rugvip): We currently always update relations, but we
|
||||
// likely want to figure out a way to avoid that
|
||||
for (const { entity, relations } of toIgnore) {
|
||||
const entityId = entity.metadata.uid!;
|
||||
await this.setRelations(entityId, relations);
|
||||
modifiedEntityIds.push({ entityId });
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) {
|
||||
@@ -184,16 +199,18 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedEntityIds;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
return (await Promise.all(tasks)).flat();
|
||||
}
|
||||
|
||||
// Set the relations originating from an entity using the DB layer
|
||||
async setRelations(
|
||||
private async setRelations(
|
||||
originatingEntityId: string,
|
||||
relations: EntityRelationSpec[],
|
||||
): Promise<void> {
|
||||
@@ -207,15 +224,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
// produce the list of entities to be added, and the list of entities to be
|
||||
// updated
|
||||
private async analyzeBatch(
|
||||
newEntities: Entity[],
|
||||
requests: EntityUpsertRequest[],
|
||||
{ kind, namespace }: BatchContext,
|
||||
): Promise<{
|
||||
toAdd: Entity[];
|
||||
toUpdate: Entity[];
|
||||
toAdd: EntityUpsertRequest[];
|
||||
toUpdate: EntityUpsertRequest[];
|
||||
toIgnore: EntityUpsertRequest[];
|
||||
}> {
|
||||
const markTimestamp = process.hrtime();
|
||||
|
||||
const names = newEntities.map(e => e.metadata.name);
|
||||
const names = requests.map(({ entity }) => entity.metadata.name);
|
||||
const oldEntities = await this.entities({
|
||||
kind: kind,
|
||||
'metadata.namespace': namespace,
|
||||
@@ -226,18 +244,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
oldEntities.map(e => [e.metadata.name, e]),
|
||||
);
|
||||
|
||||
const toAdd: Entity[] = [];
|
||||
const toUpdate: Entity[] = [];
|
||||
const toAdd: EntityUpsertRequest[] = [];
|
||||
const toUpdate: EntityUpsertRequest[] = [];
|
||||
const toIgnore: EntityUpsertRequest[] = [];
|
||||
|
||||
for (const newEntity of newEntities) {
|
||||
for (const request of requests) {
|
||||
const newEntity = request.entity;
|
||||
const oldEntity = oldEntitiesByName.get(newEntity.metadata.name);
|
||||
if (!oldEntity) {
|
||||
toAdd.push(newEntity);
|
||||
toAdd.push(request);
|
||||
} else if (entityHasChanges(oldEntity, newEntity)) {
|
||||
// TODO(freben): This currently uses addOrUpdateEntity under the hood,
|
||||
// but should probably calculate the end result entity right here
|
||||
// instead and call a dedicated batch update database method instead
|
||||
toUpdate.push(newEntity);
|
||||
toUpdate.push(request);
|
||||
} else {
|
||||
toIgnore.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,33 +269,60 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
} entities to update in ${durationText(markTimestamp)}`,
|
||||
);
|
||||
|
||||
return { toAdd, toUpdate };
|
||||
return { toAdd, toUpdate, toIgnore };
|
||||
}
|
||||
|
||||
// Efficiently adds the given entities to storage, under the assumption that
|
||||
// they do not conflict with any existing entities
|
||||
private async batchAdd(entities: Entity[], { locationId }: BatchContext) {
|
||||
private async batchAdd(
|
||||
requests: EntityUpsertRequest[],
|
||||
{ locationId }: BatchContext,
|
||||
): Promise<EntityUpsertResponse[]> {
|
||||
const markTimestamp = process.hrtime();
|
||||
|
||||
await this.addEntities(entities, locationId);
|
||||
const res = await this.database.transaction(
|
||||
async tx =>
|
||||
await this.database.addEntities(
|
||||
tx,
|
||||
requests.map(({ entity }) => ({ locationId, entity })),
|
||||
),
|
||||
);
|
||||
|
||||
const entityIds = res.map(({ entity }) => ({
|
||||
entityId: entity.metadata.uid!,
|
||||
}));
|
||||
|
||||
for (const [index, { entityId }] of entityIds.entries()) {
|
||||
await this.setRelations(entityId, requests[index].relations);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Added ${entities.length} entities in ${durationText(markTimestamp)}`,
|
||||
`Added ${requests.length} entities in ${durationText(markTimestamp)}`,
|
||||
);
|
||||
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
// Efficiently updates the given entities into storage, under the assumption
|
||||
// that there already exist entities with the same names
|
||||
private async batchUpdate(entities: Entity[], { locationId }: BatchContext) {
|
||||
private async batchUpdate(
|
||||
requests: EntityUpsertRequest[],
|
||||
{ locationId }: BatchContext,
|
||||
): Promise<EntityUpsertResponse[]> {
|
||||
const markTimestamp = process.hrtime();
|
||||
|
||||
const responseIds: EntityUpsertResponse[] = [];
|
||||
// TODO(freben): Still not batched
|
||||
for (const entity of entities) {
|
||||
await this.addOrUpdateEntity(entity, locationId);
|
||||
for (const entity of requests) {
|
||||
const res = await this.addOrUpdateEntity(entity.entity, locationId);
|
||||
const entityId = res.metadata.uid!;
|
||||
responseIds.push({ entityId });
|
||||
await this.setRelations(entityId, entity.relations);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Updated ${entities.length} entities in ${durationText(markTimestamp)}`,
|
||||
`Updated ${requests.length} entities in ${durationText(markTimestamp)}`,
|
||||
);
|
||||
|
||||
return responseIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,17 @@ import type { EntityFilters } from '../database';
|
||||
// Entities
|
||||
//
|
||||
|
||||
export type EntityUpsertRequest = {
|
||||
entity: Entity;
|
||||
relations: EntityRelationSpec[];
|
||||
};
|
||||
|
||||
export type EntityUpsertResponse = {
|
||||
entityId: string;
|
||||
};
|
||||
|
||||
export type EntitiesCatalog = {
|
||||
entities(filters?: EntityFilters): Promise<Entity[]>;
|
||||
addOrUpdateEntity(entity: Entity, locationId?: string): Promise<Entity>;
|
||||
addEntities(entities: Entity[], locationId?: string): Promise<void>;
|
||||
removeEntityByUid(uid: string): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -34,15 +41,9 @@ export type EntitiesCatalog = {
|
||||
* @param locationId The location that they all belong to
|
||||
*/
|
||||
batchAddOrUpdateEntities(
|
||||
entities: Entity[],
|
||||
entities: EntityUpsertRequest[],
|
||||
locationId?: string,
|
||||
): Promise<void>;
|
||||
|
||||
// Same as the DB layer
|
||||
setRelations(
|
||||
entityUid: string,
|
||||
relations: EntityRelationSpec[],
|
||||
): Promise<void>;
|
||||
): Promise<EntityUpsertResponse[]>;
|
||||
};
|
||||
|
||||
//
|
||||
|
||||
@@ -31,10 +31,7 @@ describe('HigherOrderOperations', () => {
|
||||
beforeAll(() => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
addOrUpdateEntity: jest.fn(),
|
||||
addEntities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
setRelations: jest.fn(),
|
||||
batchAddOrUpdateEntities: jest.fn(),
|
||||
};
|
||||
locationsCatalog = {
|
||||
@@ -69,7 +66,10 @@ describe('HigherOrderOperations', () => {
|
||||
};
|
||||
locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x));
|
||||
locationsCatalog.locations.mockResolvedValue([]);
|
||||
locationReader.read.mockResolvedValue({ entities: [], errors: [] });
|
||||
locationReader.read.mockResolvedValue({
|
||||
entities: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const result = await higherOrderOperation.addLocation(spec);
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('HigherOrderOperations', () => {
|
||||
expect(locationsCatalog.locations).toBeCalledTimes(1);
|
||||
expect(locationReader.read).toBeCalledTimes(1);
|
||||
expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' });
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled();
|
||||
expect(locationsCatalog.addLocation).toBeCalledTimes(1);
|
||||
expect(locationsCatalog.addLocation).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -109,7 +109,10 @@ describe('HigherOrderOperations', () => {
|
||||
data: location,
|
||||
},
|
||||
]);
|
||||
locationReader.read.mockResolvedValue({ entities: [], errors: [] });
|
||||
locationReader.read.mockResolvedValue({
|
||||
entities: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const result = await higherOrderOperation.addLocation(spec);
|
||||
|
||||
@@ -118,7 +121,7 @@ describe('HigherOrderOperations', () => {
|
||||
expect(locationsCatalog.locations).toBeCalledTimes(1);
|
||||
expect(locationReader.read).toBeCalledTimes(1);
|
||||
expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' });
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled();
|
||||
expect(locationsCatalog.addLocation).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -136,7 +139,7 @@ describe('HigherOrderOperations', () => {
|
||||
|
||||
locationsCatalog.locations.mockResolvedValue([]);
|
||||
locationReader.read.mockResolvedValue({
|
||||
entities: [{ entity, location }],
|
||||
entities: [{ entity, location, relations: [] }],
|
||||
errors: [{ error: new Error('abcd'), location }],
|
||||
});
|
||||
|
||||
@@ -144,7 +147,7 @@ describe('HigherOrderOperations', () => {
|
||||
/abcd/,
|
||||
);
|
||||
expect(locationsCatalog.locations).toBeCalledTimes(1);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled();
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled();
|
||||
expect(locationsCatalog.addLocation).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -159,7 +162,7 @@ describe('HigherOrderOperations', () => {
|
||||
|
||||
expect(locationsCatalog.locations).toHaveBeenCalledTimes(1);
|
||||
expect(locationReader.read).not.toHaveBeenCalled();
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can update a single location where a matching entity did not exist', async () => {
|
||||
@@ -179,15 +182,18 @@ describe('HigherOrderOperations', () => {
|
||||
metadata: { name: 'c1' },
|
||||
spec: { type: 'service' },
|
||||
};
|
||||
const entityId = 'xyz123';
|
||||
|
||||
locationsCatalog.locations.mockResolvedValue([
|
||||
{ currentStatus: locationStatus, data: location },
|
||||
]);
|
||||
locationReader.read.mockResolvedValue({
|
||||
entities: [{ entity: desc, location }],
|
||||
entities: [{ entity: desc, location, relations: [] }],
|
||||
errors: [],
|
||||
});
|
||||
entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue(undefined);
|
||||
entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([
|
||||
{ entityId },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
higherOrderOperation.refreshAllLocations(),
|
||||
@@ -200,9 +206,13 @@ describe('HigherOrderOperations', () => {
|
||||
target: 'thing',
|
||||
});
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[expect.objectContaining({ metadata: { name: 'c1' } })],
|
||||
expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
entity: expect.objectContaining({ metadata: { name: 'c1' } }),
|
||||
relations: [],
|
||||
}),
|
||||
],
|
||||
'123',
|
||||
);
|
||||
});
|
||||
@@ -229,11 +239,11 @@ describe('HigherOrderOperations', () => {
|
||||
{ currentStatus: locationStatus, data: location },
|
||||
]);
|
||||
locationReader.read.mockResolvedValue({
|
||||
entities: [{ entity: desc, location }],
|
||||
entities: [{ entity: desc, location, relations: [] }],
|
||||
errors: [],
|
||||
});
|
||||
entitiesCatalog.entities.mockResolvedValue([]);
|
||||
entitiesCatalog.addEntities.mockResolvedValue(undefined);
|
||||
entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
higherOrderOperation.refreshAllLocations(),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
@@ -78,7 +78,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
|
||||
// Read the location fully, bailing on any errors
|
||||
const readerOutput = await this.locationReader.read(spec);
|
||||
if (readerOutput.errors.length) {
|
||||
if (!(spec.presence === 'optional') && readerOutput.errors.length) {
|
||||
const item = readerOutput.errors[0];
|
||||
throw item.error;
|
||||
}
|
||||
@@ -91,16 +91,20 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
if (!previousLocation) {
|
||||
await this.locationsCatalog.addLocation(location);
|
||||
}
|
||||
const outputEntities: Entity[] = [];
|
||||
for (const entity of readerOutput.entities) {
|
||||
const out = await this.entitiesCatalog.addOrUpdateEntity(
|
||||
entity.entity,
|
||||
location.id,
|
||||
);
|
||||
outputEntities.push(out);
|
||||
if (readerOutput.entities.length === 0) {
|
||||
return { location, entities: [] };
|
||||
}
|
||||
|
||||
return { location, entities: outputEntities };
|
||||
const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities(
|
||||
readerOutput.entities,
|
||||
location.id,
|
||||
);
|
||||
|
||||
const entities = await this.entitiesCatalog.entities({
|
||||
'metadata.uid': writtenEntities.map(e => e.entityId),
|
||||
});
|
||||
|
||||
return { location, entities };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +167,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
|
||||
try {
|
||||
await this.entitiesCatalog.batchAddOrUpdateEntities(
|
||||
readerOutput.entities.map(e => e.entity),
|
||||
readerOutput.entities,
|
||||
location.id,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UrlReader } from '@backstage/backend-common';
|
||||
import {
|
||||
Entity,
|
||||
EntityPolicy,
|
||||
EntityRelationSpec,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -61,7 +62,10 @@ export class LocationReaders implements LocationReader {
|
||||
async read(location: LocationSpec): Promise<ReadLocationResult> {
|
||||
const { rulesEnforcer, logger } = this.options;
|
||||
|
||||
const output: ReadLocationResult = { entities: [], errors: [] };
|
||||
const output: ReadLocationResult = {
|
||||
entities: [],
|
||||
errors: [],
|
||||
};
|
||||
let items: CatalogProcessorResult[] = [result.location(location, false)];
|
||||
|
||||
for (let depth = 0; depth < MAX_DEPTH; ++depth) {
|
||||
@@ -75,11 +79,21 @@ export class LocationReaders implements LocationReader {
|
||||
await this.handleData(item, emit);
|
||||
} else if (item.type === 'entity') {
|
||||
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
|
||||
const entity = await this.handleEntity(item, emit);
|
||||
const relations = Array<EntityRelationSpec>();
|
||||
|
||||
const entity = await this.handleEntity(item, emitResult => {
|
||||
if (emitResult.type === 'relation') {
|
||||
relations.push(emitResult.relation);
|
||||
return;
|
||||
}
|
||||
emit(emitResult);
|
||||
});
|
||||
|
||||
if (entity) {
|
||||
output.entities.push({
|
||||
entity,
|
||||
location: item.location,
|
||||
relations,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -118,11 +132,23 @@ export class LocationReaders implements LocationReader {
|
||||
) {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
const validatedEmit: CatalogProcessorEmit = emitResult => {
|
||||
if (emitResult.type === 'relation') {
|
||||
throw new Error('readLocation may not emit entity relations');
|
||||
}
|
||||
|
||||
emit(emitResult);
|
||||
};
|
||||
|
||||
for (const processor of processors) {
|
||||
if (processor.readLocation) {
|
||||
try {
|
||||
if (
|
||||
await processor.readLocation(item.location, item.optional, emit)
|
||||
await processor.readLocation(
|
||||
item.location,
|
||||
item.optional,
|
||||
validatedEmit,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -145,10 +171,20 @@ export class LocationReaders implements LocationReader {
|
||||
) {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
const validatedEmit: CatalogProcessorEmit = emitResult => {
|
||||
if (emitResult.type === 'relation') {
|
||||
throw new Error('parseData may not emit entity relations');
|
||||
}
|
||||
|
||||
emit(emitResult);
|
||||
};
|
||||
|
||||
for (const processor of processors) {
|
||||
if (processor.parseData) {
|
||||
try {
|
||||
if (await processor.parseData(item.data, item.location, emit)) {
|
||||
if (
|
||||
await processor.parseData(item.data, item.location, validatedEmit)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -242,10 +278,18 @@ export class LocationReaders implements LocationReader {
|
||||
`Encountered error at location ${item.location.type} ${item.location.target}, ${item.error}`,
|
||||
);
|
||||
|
||||
const validatedEmit: CatalogProcessorEmit = emitResult => {
|
||||
if (emitResult.type === 'relation') {
|
||||
throw new Error('handleError may not emit entity relations');
|
||||
}
|
||||
|
||||
emit(emitResult);
|
||||
};
|
||||
|
||||
for (const processor of processors) {
|
||||
if (processor.handleError) {
|
||||
try {
|
||||
await processor.handleError(item.error, item.location, emit);
|
||||
await processor.handleError(item.error, item.location, validatedEmit);
|
||||
} catch (e) {
|
||||
const message = `Processor ${processor.constructor.name} threw an error while handling another error at ${item.location.type} ${item.location.target}, ${e}`;
|
||||
emit(result.generalError(item.location, message));
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
@@ -44,11 +45,9 @@ export class AzureApiReaderProcessor implements CatalogProcessor {
|
||||
).toString('base64')}`;
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
return {
|
||||
headers,
|
||||
};
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
@@ -67,7 +66,7 @@ export class AzureApiReaderProcessor implements CatalogProcessor {
|
||||
|
||||
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
|
||||
if (response.ok && response.status !== 203) {
|
||||
const data = await response.buffer();
|
||||
const data = Buffer.from(await response.text());
|
||||
emit(result.data(location, data));
|
||||
} else {
|
||||
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import * as result from './results';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
@@ -70,8 +70,8 @@ export class BitbucketApiReaderProcessor implements CatalogProcessor {
|
||||
const response = await fetch(url.toString(), this.getRequestOptions());
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.buffer();
|
||||
emit(result.data(location, data));
|
||||
const data = await response.text();
|
||||
emit(result.data(location, Buffer.from(data)));
|
||||
} else {
|
||||
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Logger } from 'winston';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
@@ -252,8 +252,8 @@ export class GithubReaderProcessor implements CatalogProcessor {
|
||||
const response = await fetch(url.toString(), options);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.buffer();
|
||||
emit(result.data(location, data));
|
||||
const data = await response.text();
|
||||
emit(result.data(location, Buffer.from(data)));
|
||||
} else {
|
||||
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
@@ -61,8 +61,8 @@ export class GitlabApiReaderProcessor implements CatalogProcessor {
|
||||
const url = this.buildRawUrl(location.target, projectID);
|
||||
const response = await fetch(url.toString(), this.getRequestOptions());
|
||||
if (response.ok) {
|
||||
const data = await response.buffer();
|
||||
emit(result.data(location, data));
|
||||
const data = await response.text();
|
||||
emit(result.data(location, Buffer.from(data)));
|
||||
} else {
|
||||
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch from 'node-fetch';
|
||||
import fetch from 'cross-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
@@ -40,8 +40,8 @@ export class GitlabReaderProcessor implements CatalogProcessor {
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.buffer();
|
||||
emit(result.data(location, data));
|
||||
const data = await response.text();
|
||||
emit(result.data(location, Buffer.from(data)));
|
||||
} else {
|
||||
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
|
||||
@@ -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 {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
LocationSpec,
|
||||
parseEntityRef,
|
||||
ApiEntityV1alpha1,
|
||||
ComponentEntityV1alpha1,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_OWNER_OF,
|
||||
getEntityName,
|
||||
} from '@backstage/catalog-model';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
import * as result from './results';
|
||||
|
||||
const includedKinds = new Set(['api', 'component']);
|
||||
|
||||
export class OwnerRelationProcessor implements CatalogProcessor {
|
||||
async postProcessEntity(
|
||||
entity: Entity,
|
||||
_location: LocationSpec,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity> {
|
||||
if (!includedKinds.has(entity.kind.toLowerCase())) {
|
||||
return entity;
|
||||
}
|
||||
const apiOrComponentEntity = entity as
|
||||
| ApiEntityV1alpha1
|
||||
| ComponentEntityV1alpha1;
|
||||
|
||||
const owner = apiOrComponentEntity.spec?.owner;
|
||||
if (owner) {
|
||||
const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
|
||||
|
||||
const selfRef = getEntityName(entity);
|
||||
const ownerRef = parseEntityRef(owner, {
|
||||
defaultKind: 'group',
|
||||
defaultNamespace: namespace,
|
||||
});
|
||||
|
||||
emit(
|
||||
result.relation({
|
||||
source: selfRef,
|
||||
type: RELATION_OWNED_BY,
|
||||
target: ownerRef,
|
||||
}),
|
||||
);
|
||||
emit(
|
||||
result.relation({
|
||||
source: ownerRef,
|
||||
type: RELATION_OWNER_OF,
|
||||
target: selfRef,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -14,25 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { UrlReaderProcessor } from './UrlReaderProcessor';
|
||||
import { getVoidLogger, UrlReaders } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import {
|
||||
CatalogProcessorDataResult,
|
||||
CatalogProcessorErrorResult,
|
||||
CatalogProcessorResult,
|
||||
} from './types';
|
||||
import { UrlReaderProcessor } from './UrlReaderProcessor';
|
||||
|
||||
describe('UrlReaderProcessor', () => {
|
||||
const mockApiOrigin = 'http://localhost:23000';
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
msw.setupDefaultHandlers(server);
|
||||
it('should load from url', async () => {
|
||||
const logger = getVoidLogger();
|
||||
const reader = UrlReaders.default({ logger, config: new ConfigReader({}) });
|
||||
|
||||
@@ -28,6 +28,7 @@ export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
|
||||
export { GithubReaderProcessor } from './GithubReaderProcessor';
|
||||
export { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor';
|
||||
export { GitlabReaderProcessor } from './GitlabReaderProcessor';
|
||||
export { OwnerRelationProcessor } from './OwnerRelationProcessor';
|
||||
export { LocationRefProcessor } from './LocationEntityProcessor';
|
||||
export { PlaceholderProcessor } from './PlaceholderProcessor';
|
||||
export type { PlaceholderResolver } from './PlaceholderProcessor';
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import { InputError, NotFoundError } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
EntityRelationSpec,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { CatalogProcessorResult } from './types';
|
||||
|
||||
export function notFoundError(
|
||||
@@ -67,3 +71,7 @@ export function entity(
|
||||
): CatalogProcessorResult {
|
||||
return { type: 'entity', location: atLocation, entity: newEntity };
|
||||
}
|
||||
|
||||
export function relation(spec: EntityRelationSpec): CatalogProcessorResult {
|
||||
return { type: 'relation', relation: spec };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
EntityRelationSpec,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
export type CatalogProcessor = {
|
||||
/**
|
||||
@@ -113,6 +117,12 @@ export type CatalogProcessorEntityResult = {
|
||||
location: LocationSpec;
|
||||
};
|
||||
|
||||
export type CatalogProcessorRelationResult = {
|
||||
type: 'relation';
|
||||
relation: EntityRelationSpec;
|
||||
entityRef?: string;
|
||||
};
|
||||
|
||||
export type CatalogProcessorErrorResult = {
|
||||
type: 'error';
|
||||
error: Error;
|
||||
@@ -123,4 +133,5 @@ export type CatalogProcessorResult =
|
||||
| CatalogProcessorLocationResult
|
||||
| CatalogProcessorDataResult
|
||||
| CatalogProcessorEntityResult
|
||||
| CatalogProcessorRelationResult
|
||||
| CatalogProcessorErrorResult;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { graphql } from '@octokit/graphql';
|
||||
import { graphql as graphqlMsw } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import {
|
||||
getOrganizationTeams,
|
||||
getOrganizationUsers,
|
||||
@@ -26,9 +27,7 @@ import {
|
||||
|
||||
describe('github', () => {
|
||||
const server = setupServer();
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
describe('getOrganizationUsers', () => {
|
||||
it('reads members', async () => {
|
||||
|
||||