diff --git a/.changeset/2863.md b/.changeset/2863.md
deleted file mode 100644
index 729e3aab7b..0000000000
--- a/.changeset/2863.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-'@backstage/core-api': patch
----
-
-Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the
-default scope is configurable when overwriting the Core Api in the app.
-
-```
-GithubAuth.create({
- discoveryApi,
- oauthRequestApi,
- defaultScopes: ['read:user', 'repo'],
-}),
-```
-
-Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type.
-
-```
-export type OAuthApiCreateOptions = AuthApiCreateOptions & {
- oauthRequestApi: OAuthRequestApi;
- defaultScopes?: string[];
-};
-
-export type AuthApiCreateOptions = {
- discoveryApi: DiscoveryApi;
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
-```
diff --git a/.changeset/3157.md b/.changeset/3157.md
deleted file mode 100644
index f15d05c46a..0000000000
--- a/.changeset/3157.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@backstage/core': patch
----
-
-Adds the MarkdownContent component to render and display Markdown content with the default
-[GFM](https://github.github.com/gfm/) (Github flavored Markdown) dialect.
-
-```
-
-```
-
-To render the Markdown content with plain [CommonMark](https://commonmark.org/), set the dialect to `common-mark`
-
-```
- 1000%
diff --git a/.changeset/curly-yaks-invite.md b/.changeset/curly-yaks-invite.md
new file mode 100644
index 0000000000..b9191a3895
--- /dev/null
+++ b/.changeset/curly-yaks-invite.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Add support for reading groups and users from the Microsoft Graph API.
diff --git a/.changeset/dry-pillows-carry.md b/.changeset/dry-pillows-carry.md
deleted file mode 100644
index 5352e26d2d..0000000000
--- a/.changeset/dry-pillows-carry.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@backstage/core': patch
----
-
-- The BottomLink is now able to handle with internal routes.
-- @backstage/core Link component detect whether it's an external link or not, and render accordingly
diff --git a/.changeset/dull-pans-sip.md b/.changeset/dull-pans-sip.md
new file mode 100644
index 0000000000..13616c1f8d
--- /dev/null
+++ b/.changeset/dull-pans-sip.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-scaffolder': patch
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Move constructing the catalog-info.yaml URL for scaffolded components to the publishers
diff --git a/.changeset/empty-kids-look.md b/.changeset/empty-kids-look.md
new file mode 100644
index 0000000000..680cd7f25c
--- /dev/null
+++ b/.changeset/empty-kids-look.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-register-component': patch
+---
+
+Remove catalog link on validate popup
diff --git a/.changeset/forty-buses-watch.md b/.changeset/forty-buses-watch.md
new file mode 100644
index 0000000000..4074566fe7
--- /dev/null
+++ b/.changeset/forty-buses-watch.md
@@ -0,0 +1,14 @@
+---
+'@backstage/catalog-model': patch
+'@backstage/plugin-catalog-backend': patch
+---
+
+Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details.
+
+Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields.
+
+The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation.
+
+After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either.
+
+If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code.
diff --git a/.changeset/long-flowers-wink.md b/.changeset/long-flowers-wink.md
new file mode 100644
index 0000000000..7f9cf12315
--- /dev/null
+++ b/.changeset/long-flowers-wink.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-sonarqube': patch
+---
+
+Add configuration schema
diff --git a/.changeset/lucky-lemons-return.md b/.changeset/lucky-lemons-return.md
deleted file mode 100644
index 49125d31d4..0000000000
--- a/.changeset/lucky-lemons-return.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/create-app': patch
----
-
-Fix missing api-docs plugin registration in app template
diff --git a/.changeset/moody-pugs-smoke.md b/.changeset/moody-pugs-smoke.md
deleted file mode 100644
index 3092ec4bfe..0000000000
--- a/.changeset/moody-pugs-smoke.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-user-settings': patch
----
-
-Export `AuthProviders`, `DefaultProviderSettings` and `ProviderSettingsItem`.
diff --git a/.changeset/orange-cherries-run.md b/.changeset/orange-cherries-run.md
new file mode 100644
index 0000000000..3c06c27b47
--- /dev/null
+++ b/.changeset/orange-cherries-run.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Add experimental backend:bundle command
diff --git a/.changeset/poor-mails-marry.md b/.changeset/poor-mails-marry.md
new file mode 100644
index 0000000000..fdc7e6f1b8
--- /dev/null
+++ b/.changeset/poor-mails-marry.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Add new `versions:check` and `versions:bump` commands to simplify version management and avoid conflicts
diff --git a/.changeset/shaggy-bobcats-warn.md b/.changeset/shaggy-bobcats-warn.md
deleted file mode 100644
index 048e1a0ff6..0000000000
--- a/.changeset/shaggy-bobcats-warn.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/core': minor
----
-
-New DependencyGraph component added to core package.
diff --git a/.changeset/silent-dodos-repeat.md b/.changeset/silent-dodos-repeat.md
deleted file mode 100644
index 04978406f3..0000000000
--- a/.changeset/silent-dodos-repeat.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/core': patch
----
-
-Fix divider prop not respected on InfoCard
diff --git a/.changeset/small-worms-check.md b/.changeset/small-worms-check.md
new file mode 100644
index 0000000000..e39265f8f9
--- /dev/null
+++ b/.changeset/small-worms-check.md
@@ -0,0 +1,6 @@
+---
+'example-app': patch
+'@backstage/plugin-search': patch
+---
+
+Using the search field in the sidebar now navigates to the search result page.
diff --git a/.changeset/smooth-hairs-breathe.md b/.changeset/smooth-hairs-breathe.md
deleted file mode 100644
index 2849a1d4b7..0000000000
--- a/.changeset/smooth-hairs-breathe.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-tech-radar': patch
----
-
-Make the footer color of the tech-radar work in both light and dark theme.
diff --git a/.changeset/spicy-bananas-help.md b/.changeset/spicy-bananas-help.md
deleted file mode 100644
index 6d847f4bc3..0000000000
--- a/.changeset/spicy-bananas-help.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/plugin-api-docs': patch
----
-
-Upgrade @kyma-project/asyncapi-react to 0.14.2
diff --git a/.changeset/strong-moose-cough.md b/.changeset/strong-moose-cough.md
deleted file mode 100644
index 41a0da7cca..0000000000
--- a/.changeset/strong-moose-cough.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/theme': patch
----
-
-Improve styling of outlined chips in dark mode.
diff --git a/.changeset/tame-rockets-explode.md b/.changeset/tame-rockets-explode.md
new file mode 100644
index 0000000000..123472701e
--- /dev/null
+++ b/.changeset/tame-rockets-explode.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-common': patch
+---
+
+Make integration host and url configurations visible in the frontend
diff --git a/.changeset/tidy-brooms-look.md b/.changeset/tidy-brooms-look.md
new file mode 100644
index 0000000000..f2c2a9064e
--- /dev/null
+++ b/.changeset/tidy-brooms-look.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Fix React entity YAML filename to new standard
diff --git a/.changeset/weak-otters-love.md b/.changeset/weak-otters-love.md
deleted file mode 100644
index 2c273e067b..0000000000
--- a/.changeset/weak-otters-love.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@backstage/plugin-scaffolder': minor
-'@backstage/plugin-scaffolder-backend': minor
----
-
-`createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`.
-
-```js
-import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend';
-import { SingleHostDiscovery } from '@backstage/backend-common';
-
-const discovery = SingleHostDiscovery.fromConfig(config);
-const entityClient = new CatalogEntityClient({ discovery });
-```
-
-- Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity.
diff --git a/.changeset/weak-rice-beam.md b/.changeset/weak-rice-beam.md
deleted file mode 100644
index c507e350a9..0000000000
--- a/.changeset/weak-rice-beam.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@backstage/backend-common': patch
----
-
-Capture plugin name under the /api/ prefix for http metrics
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d818c0d6b5..b3c9bc653d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -9,5 +9,6 @@
/plugins/cost-insights @backstage/silver-lining
/plugins/cloudbuild @trivago/ebarrios
/plugins/techdocs @backstage/techdocs-core
+/plugins/search @backstage/techdocs-core
/plugins/techdocs-backend @backstage/techdocs-core
/.changeset/cost-insights-* @backstage/silver-lining
diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index ecb8fdf3c1..c31e19e35c 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -1,4 +1,5 @@
abc
+andrewthauer
Apdex
api
Api
@@ -9,21 +10,24 @@ async
Avro
backrub
Balachandran
+benjdlambert
Bigtable
+Billett
Blackbox
bool
boolean
+builtins
Chai
changeset
changesets
Changesets
-changset
chanwit
Chanwit
cisphobia
cissexist
classname
cli
+cloudbuild
cncf
codeblocks
Codecov
@@ -50,6 +54,7 @@ Dockerfile
Dockerize
dockerode
Docusaurus
+dzolotusky
eg
Ek
env
@@ -59,13 +64,16 @@ facto
failover
Figma
Firekube
+freben
Fredrik
github
Github
gitlab
Gitlab
+Grafana
graphql
graphviz
+Gustavsson
Hackathons
haproxy
heroku
@@ -74,8 +82,10 @@ horizontalpodautoscalers
Hostname
http
https
+Iain
img
incentivised
+inlined
inlinehilite
interop
javascript
@@ -92,8 +102,8 @@ learnings
lerna
Lerna
magiclink
-Maintainership
mailto
+maintainership
Malus
md
microsite
@@ -110,6 +120,7 @@ msw
namespace
namespaces
Namespaces
+namespacing
neuro
newrelic
nginx
@@ -158,24 +169,30 @@ Rollup
Rosaceae
rst
rsync
+rugvip
ruleset
sam
scaffolded
scaffolder
Scaffolder
semlas
+semver
Serverless
Sinon
smartsymobls
+Snyk
sparklines
Spotifiers
spotify
Spotify
squidfunk
src
+stefanalund
subkey
+subtree
superfences
Superfences
+superset
talkdesk
Talkdesk
tasklist
@@ -190,9 +207,13 @@ theres
toc
tolerations
Tolerations
+toolchain
toolsets
+tooltip
+tooltips
touchpoints
ui
+untracked
upvote
url
utils
@@ -201,14 +222,10 @@ Voi
Wealthsimple
Weaveworks
Webpack
+www
+WWW
xyz
yaml
Zalando
Zhou
-Billett
-cloudbuild
-Grafana
-Iain
-Snyk
-www
-WWW
+Zolotusky
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 75c6a13cd2..c35e2a88ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,6 +72,9 @@ jobs:
- name: prettier
run: yarn prettier:check
+ - name: validate config
+ run: yarn backstage-cli config:check
+
- name: lint
run: yarn lerna -- run lint --since origin/master
diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml
index 570d857c48..1844700cea 100644
--- a/.github/workflows/e2e-win.yml
+++ b/.github/workflows/e2e-win.yml
@@ -32,7 +32,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1.0.1
+ uses: microsoft/setup-msbuild@v1.0.2
- name: yarn install
run: yarn install --frozen-lockfile
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index f5029bf836..ca63e627e9 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -47,6 +47,9 @@ jobs:
run: yarn install --frozen-lockfile
# End of yarn setup
+ - name: validate config
+ run: yarn backstage-cli config:check
+
- name: lint
run: yarn lerna -- run lint
diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml
index a0691f2a79..a8d476f713 100644
--- a/.github/workflows/techdocs-project-board.yml
+++ b/.github/workflows/techdocs-project-board.yml
@@ -1,6 +1,7 @@
name: Automatically add new TechDocs Issues and PRs to the GitHub project board
-# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/backstage/backstage/projects/5
-# New issues with TechDocs in their title or docs-like-code label will be added to the board.
+# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1
+# New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board.
+# Caveat: New PRs created from forks will not be added since GitHub actions don't share credentials with forks.
on:
issues:
@@ -9,7 +10,7 @@ on:
types: [opened, reopened, labeled, edited]
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
jobs:
assign_issue_or_pr_to_project:
@@ -23,7 +24,7 @@ jobs:
contains(github.event.issue.title, 'techdocs') ||
contains(github.event.issue.title, 'Techdocs')
with:
- project: 'https://github.com/backstage/backstage/projects/5'
+ project: 'https://github.com/orgs/backstage/projects/1'
column_name: 'Incoming'
- name: Assign new issue to Incoming based on its label.
@@ -31,7 +32,7 @@ jobs:
if: |
contains(github.event.issue.labels.*.name, 'docs-like-code')
with:
- project: 'https://github.com/backstage/backstage/projects/5'
+ project: 'https://github.com/orgs/backstage/projects/1'
column_name: 'Incoming'
- name: Assign new PR to Incoming based on its title.
@@ -41,7 +42,7 @@ jobs:
contains(github.event.pull_request.title, 'techdocs') ||
contains(github.event.pull_request.title, 'Techdocs')
with:
- project: 'https://github.com/backstage/backstage/projects/5'
+ project: 'https://github.com/orgs/backstage/projects/1'
column_name: 'Incoming'
- name: Assign new PR to Incoming based on its label.
@@ -49,5 +50,5 @@ jobs:
if: |
contains(github.event.pull_request.labels.*.name, 'docs-like-code')
with:
- project: 'https://github.com/backstage/backstage/projects/5'
+ project: 'https://github.com/orgs/backstage/projects/1'
column_name: 'Incoming'
diff --git a/.gitignore b/.gitignore
index 5c27601791..3334bf956d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,6 +96,7 @@ typings/
.nuxt
dist
dist-types
+dist-workspace
# Gatsby files
.cache/
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 19bbb3bcb9..65841d1c6a 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -1,52 +1,45 @@
-# Backstage Governance
+# Process for becoming a maintainer
-This document defines project governance for the project.
+## Your organization is not yet a maintainer
-## Maintainers
+- Express interest to the sponsors that your organization is interested in becoming a maintainer. Becoming a maintainer generally means that you are going to be spending substantial time on Backstage for the foreseeable future. You should have domain expertise and be extremely proficient in TypeScript.
+- We will expect you to start contributing increasingly complicated PRs, under the guidance of the existing maintainers.
+- We may ask you to do some PRs from our backlog.
+- As you gain experience with the code base and our standards, we will ask you to do code reviews for incoming PRs.
+- After a period of approximately 2-3 months of working together and making sure we see eye to eye, the existing sponsors and maintainers will confer and decide whether to grant maintainer status or not. We make no guarantees on the length of time this will take, but 2-3 months is the approximate goal.
-Backstage Maintainers have write access to the Backstage GitHub repository https://github.com/backstage/backstage. The current maintainers can be found in [MAINTAINERS](MAINTAINERS.md).
-
-This privilege is granted with some expectation of responsibility: maintainers are people who care about the Backstage project and want to help it grow and improve. A maintainer is not just someone who can make changes, but someone who has demonstrated his or her ability to collaborate with the team, get the most knowledgeable people to review code, contribute high-quality code, and follow through to fix issues (in code or tests).
-
-A maintainer is a contributor to the Backstage project's success and a citizen helping the project succeed.
-
-## Becoming a Maintainer
+## Your organization is currently a maintainer
To become a maintainer you need to demonstrate the following:
-- commitment to the project
- - participate in discussions, contributions, code reviews for 3 months or more,
- - perform code reviews for 10 non-trivial pull requests,
- - contribute 10 non-trivial pull requests and have them merged into master,
-- ability to write good code,
-- ability to collaborate with the team,
-- understanding of how the team works (policies, processes for testing and code review, etc),
-- understanding of the project's code base and coding style.
+- First decide whether your organization really needs more people with maintainer access. Valid reasons are "blast radius", a large organization that is working on multiple unrelated projects, etc.
+- Contact a sponsor for your organization and express interest.
+- Start doing PRs and code reviews under the guidance of your maintainer.
+- After a period of 1-2 months the existing sponsors will discuss granting maintainer access.
+- Maintainer access can be upgraded to sponsor access after another conference of the existing sponsors.
-## Changes in Maintainership
+# Maintainer responsibilities
-A new maintainer must be proposed by an existing maintainer by opening an issue (with title `Maintainer Nomination`) to the Backstage GitHub repository (https://github.com/backstage/backstage) containing the following information:
+- Monitor email aliases.
+- Monitor Discord (delayed response is perfectly acceptable).
+- Triage GitHub issues and perform pull request reviews for other maintainers and the community.
+- Triage build issues - file issues for known flaky builds or bugs, and either fix or find someone to fix any master build breakages.
+- During GitHub issue triage, apply all applicable ([labels](https://github.com/backstage/backstage/labels)) to each new issue. Labels are extremely useful for future issue follow up. Which labels to apply is somewhat subjective so just use your best judgment. A few of the most important labels that are not self explanatory are:
+ - good first issue: Mark any issue that can reasonably be accomplished by a new contributor with this label.
+ - help wanted: Unless it is immediately obvious that someone is going to work on an issue (and if so assign it), mark it help wanted.
+- Make sure that ongoing PRs are moving forward at the right pace or closing them.
+- Participate when called upon in the security release process. Note that although this should be a rare occurrence, if a serious vulnerability is found, the process may take up to several full days of work to implement. This reality should be taken into account when discussing time commitment obligations with employers.
+- In general, continue to be willing to spend at least 25% of one's time working on Backstage (~1.25 business days per week).
+- We currently maintain an "on-call" rotation within the maintainers. Each on-call is 1 week. Although all maintainers are welcome to perform all of the above tasks, it is the on-call maintainer's responsibility to triage incoming issues/questions and marshal ongoing work forward. To reiterate, it is not the responsibility of the on-call maintainer to answer all questions and do all reviews, but it is their responsibility to make sure that everything is being actively covered by someone.
-- nominee's first and last name,
-- nominee's email address and GitHub user name,
-- an explanation of why the nominee should be a maintainer,
-- a list of links to non-trivial pull requests (top 10) authored by the nominee.
+# When does a maintainer lose maintainer status
-Maintainers can be removed by a 2/3 majority vote.
+If a maintainer is no longer interested or cannot perform the maintainer duties listed above, they should volunteer to be moved to emeritus status. In extreme cases this can also occur by a vote of the sponsors and maintainers per the voting process below.
-## Approving PRs
+# Conflict resolution and voting
-PRs may be merged after receiving at least one approval from a maintainer.
+In general, we prefer that technical issues and maintainer membership are amicably worked out between the persons involved. If a dispute cannot be decided independently, the sponsors and maintainers can be called in to decide an issue. If the sponsors and maintainers themselves cannot decide an issue, the issue will be resolved by voting. The voting process is a simple majority in which each sponsor receives two votes and each maintainer receives one vote.
-## GitHub Project Administration
+# Adding new projects to the Backstage GitHub organization
-Maintainers will be added to the collaborators list of the Backstage repository with "Write" access.
-
-## Changes in Governance
-
-All changes in Governance require a 2/3 majority vote.
-
-## Other Changes
-
-Unless specified above, all other changes to the project require a 2/3 majority vote.
-Additionally, any maintainer may request that any change require a 2/3 majority vote.
+New projects will be added to the Backstage organization via GitHub issue discussion in one of the existing projects in the organization. Once sufficient discussion has taken place (~3-5 business days but depending on the volume of conversation), the maintainers of the project where the issue was opened (since different projects in the organization may have different maintainers) will decide whether the new project should be added. See the section above on voting if the maintainers cannot easily decide.
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
deleted file mode 100644
index 33437692c9..0000000000
--- a/MAINTAINERS.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Maintainers
-
-- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines.
-
-## Current Maintainers 🏓
-
-- Stefan Ålund - Spotify (GitHub: @stefanalund, Discord: @stalund)
-- Patrik Oldsberg - Spotify (GitHub: @Rugvip, Discord: @Rugvip)
-- Fredrik Adelöw - Spotify (GitHub: @freben, Discord: @freben)
-- Ben Lambert - Spotify (GitHub: @benjdlambert, Discord: @blam)
-
-## Plugin maintainers 🧩
-
-Teams and individuals that maintain a plugin (or another non-core module of the code) can get write access to that part of the repo using CODEOWNERS.
-
-## Hall of Fame 👏
-
-People that have made significant contributions to the project and earned write access:
-
-- Andrew Thauer - Wealthsimple (GitHub: @andrewthauer)
-- Oliver Sand - SDA SE (GitHub: @Fox32)
diff --git a/OWNERS.md b/OWNERS.md
new file mode 100644
index 0000000000..8e5002fc0a
--- /dev/null
+++ b/OWNERS.md
@@ -0,0 +1,24 @@
+- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines.
+- See [GOVERNANCE.md](GOVERNANCE.md) for governance guidelines and responsibilities.
+
+This page lists all active sponsors and maintainers.
+
+# Sponsors
+
+- Niklas Gustavsson ([protocol7](https://github.com/protocol7)) (ngn@spotify.com)
+- Dave Zolotusky ([dzolotusky](https://github.com/dzolotusky)) (dzolo@spotify.com)
+- Lee Mills ([leemills83](https://github.com/leemills83)) (leem@spotify.com)
+
+# Maintainers
+
+- Patrik Oldsberg ([rugvip](https://github.com/rugvip)) (Discord: @Rugvip)
+- Fredrik Adelöw ([freben](https://github.com/freben)) (Discord: @freben)
+- Ben Lambert ([benjdlambert](https://github.com/benjdlambert)) (Discord: @blam)
+- Stefan Ålund ([stefanalund](https://github.com/stefanalund)) (Discord: @stalund)
+
+# Friends of Backstage
+
+People that have made significant contributions to the project and earned write access.
+
+- Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer))
+- Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32))
diff --git a/app-config.yaml b/app-config.yaml
index 148627dc5a..fcc54a1a35 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -46,6 +46,12 @@ proxy:
X-Api-Key:
$env: NEW_RELIC_REST_API_KEY
+ '/buildkite/api':
+ target: https://api.buildkite.com/v2/
+ headers:
+ Authorization:
+ $env: BUILDKITE_TOKEN
+
organization:
name: My Company
@@ -134,6 +140,19 @@ catalog:
# dn: ou=access,ou=groups,ou=example,dc=example,dc=net
# options:
# filter: (&(objectClass=some-group-class)(!(groupType=email)))
+ microsoftGraphOrg:
+ ### Example for how to add your Microsoft Graph tenant
+ #providers:
+ # - target: https://graph.microsoft.com/v1.0
+ # authority: https://login.microsoftonline.com
+ # tenantId:
+ # $env: MICROSOFT_GRAPH_TENANT_ID
+ # clientId:
+ # $env: MICROSOFT_GRAPH_CLIENT_ID
+ # clientSecret:
+ # $env: MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN
+ # userFilter: accountEnabled eq true and userType eq 'member'
+ # groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
locations:
# Backstage example components
@@ -170,8 +189,10 @@ scaffolder:
api:
token:
$env: AZURE_TOKEN
-
auth:
+ ### Providing an auth.session.secret will enable session support in the auth-backend
+ # session:
+ # secret: custom session secret
providers:
google:
development:
@@ -216,6 +237,20 @@ auth:
$env: AUTH_OAUTH2_AUTH_URL
tokenUrl:
$env: AUTH_OAUTH2_TOKEN_URL
+ oidc:
+ development:
+ metadataUrl:
+ $env: AUTH_OIDC_METADATA_URL
+ clientId:
+ $env: AUTH_OIDC_CLIENT_ID
+ clientSecret:
+ $env: AUTH_OIDC_CLIENT_SECRET
+ authorizationUrl:
+ $env: AUTH_OIDC_AUTH_URL
+ tokenUrl:
+ $env: AUTH_OIDC_TOKEN_URL
+ tokenSignedResponseAlg:
+ $env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG
auth0:
development:
clientId:
@@ -264,7 +299,7 @@ costInsights:
homepage:
clocks:
- label: UTC
- timzone: UTC
+ timezone: UTC
- label: NYC
timezone: 'America/New_York'
- label: STO
diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md
index 7b60062265..96bfdb4351 100644
--- a/contrib/chart/backstage/README.md
+++ b/contrib/chart/backstage/README.md
@@ -7,7 +7,7 @@ This folder contains Helm charts that can easily create a Kubernetes deployment
These charts depend on the `nginx-ingress` controller being present in the cluster. If it's not already installed you
can run:
-```
+```shell
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install nginx-ingress ingress-nginx/ingress-nginx
```
@@ -16,7 +16,7 @@ helm install nginx-ingress ingress-nginx/ingress-nginx
After choosing a DNS name where backstage will be hosted create a yaml file for your custom configuration.
-```
+```yaml
appConfig:
app:
baseUrl: https://backstage.mydomain.com
@@ -30,12 +30,11 @@ appConfig:
techdocs:
storageUrl: https://backstage.mydomain.com/api/techdocs/static/docs
requestUrl: https://backstage.mydomain.com/api/techdocs
-
```
Then use it to run:
-```
+```shell
git clone https://github.com/backstage/backstage.git
cd contrib/chart/backstage
helm dependency update
@@ -54,7 +53,7 @@ After a few minutes Backstage should be up and running in your cluster under the
Make sure to create the appropriate DNS entry in your infrastructure. To find the public IP address run:
-```bash
+```shell
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS AGE
backstage-ingress * 123.1.2.3 80 17m
@@ -74,10 +73,10 @@ These charts can install or reuse a `clusterIssuer` to generate certificates for
To enable it you need to provide a valid email address in the chart's values:
-```
+```yaml
issuer:
email: me@example.com
- clusterIssuer: "letsencrypt-prod"
+ clusterIssuer: 'letsencrypt-prod'
```
By default, the charts use `letsencrypt-staging` so in the above example we instruct helm to use the production issuer
@@ -92,7 +91,7 @@ Configuring a connection to an existing PostgreSQL instance is possible through
First create a yaml file with the configuration you want to override, for example `backstage-prod.yaml`:
-```bash
+```yaml
postgresql:
enabled: false
@@ -122,12 +121,11 @@ lighthouse:
user:
password:
database: lighthouse_audit_service
-
```
For the CA, create a `configMap` named `--postgres-ca` with a file called `ca.crt`:
-```
+```shell
kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt"
```
@@ -135,7 +133,7 @@ kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt"
Now install the helm chart:
-```
+```shell
cd contrib/chart/backstage
helm install -f backstage-prod.yaml my-backstage .
```
@@ -144,7 +142,7 @@ helm install -f backstage-prod.yaml my-backstage .
The docker images used for the deployment can be configured through the charts values:
-```
+```yaml
frontend:
image:
repository:
@@ -165,7 +163,7 @@ lighthouse:
Create a docker-registry secret
-```
+```shell
kubectl create secret docker-registry # args
```
@@ -181,7 +179,7 @@ dockerRegistrySecretName:
To install the charts a specific namespace use `--namespace `:
-```
+```shell
helm install -f my_values.yaml --namespace demos backstage .
```
@@ -189,7 +187,7 @@ helm install -f my_values.yaml --namespace demos backstage .
To deploy backstage with the pre-loaded demo data disable `backend.demoData`:
-```
+```shell
helm install -f my_values.yaml --set backend.demoData=false backstage .
```
@@ -244,7 +242,7 @@ This error happens in the backend when it tries to connect to the configured Pos
To uninstall Backstage simply run:
-```
+```shell
RELEASE_NAME= # use `helm list` to find out the name
helm uninstall ${RELEASE_NAME}
kubectl delete pvc data-${RELEASE_NAME}-postgresql-0
diff --git a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md
index 4f8351ac15..f5288cf144 100644
--- a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md
+++ b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md
@@ -12,7 +12,7 @@ There are however some ways to get this to work without too much effort. It's mo
### Using `global-agent`
-1. Install `global-agent` using `yarn install global-agent`
+1. Install `global-agent` using `yarn add global-agent`
2. Go to the entry file for the backend (`src/index.ts`)
3. At the top of the file paste the following:
@@ -33,7 +33,7 @@ More information and more options for configuring `global-agent` including just
`proxy-agent` is a library that you can use to override the `globalAgents` of `node` land with a tunnel to use for each request.
-1. Install `proxy-agent` using `yarn install proxy-agent`
+1. Install `proxy-agent` using `yarn add proxy-agent`
2. Go to the entry file for the backend (`src/index.ts`)
3. At the top of the file paste the following:
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 7b906a341b..9c11b015ea 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -48,10 +48,9 @@ source candidates. (And we'll probably end up writing some brand new ones, too.)
### What's the roadmap for Backstage?
We envision three phases, which you can learn about in
-[our project roadmap](https://github.com/backstage/backstage#project-roadmap).
-Even though the open source version of Backstage is relatively new compared to
-our internal version, we have already begun work on various aspects of all three
-phases. Looking at the
+[our project roadmap](overview/roadmap.md). Even though the open source version
+of Backstage is relatively new compared to our internal version, we have already
+begun work on various aspects of all three phases. Looking at the
[milestones for active issues](https://github.com/backstage/backstage/milestones)
will also give you a sense of our progress.
@@ -115,8 +114,7 @@ type of content. Plugins all use a common set of platform APIs and reusable UI
components. Plugins can fetch data either from the backend or an API exposed
through the proxy.
-Learn more about
-[the different components](https://github.com/backstage/backstage#overview) that
+Learn more about [the different components](overview/what-is-backstage.md) that
make up Backstage.
### Do I have to write plugins in TypeScript?
@@ -126,17 +124,17 @@ APIs in TypeScript, but aren't forcing it on individual plugins.
### How do I find out if a plugin already exists?
-Before you write a plugin,
+You can browse and search for all available plugins in the
+[Plugin Marketplace](https://backstage.io/plugins).
+
+If you can't find it in the marketplace, before you write a plugin
[search the plugin issues](https://github.com/backstage/backstage/issues?q=is%3Aissue+label%3Aplugin+)
-to see if it already exists or is in the works. If no one's thought of it yet,
-great! Open a new issue as
+to see if is in the works. If no one's thought of it yet, great! Open a new
+issue as
[a plugin suggestion](https://github.com/backstage/backstage/issues/new/choose)
and describe what your plugin will do. This will help coordinate our
contributors' efforts and avoid duplicating existing functionality.
-You can browse and search for all available plugins in the
-[Plugin Marketplace](https://backstage.io/plugins).
-
### Which plugin is used the most at Spotify?
By far, our most-used plugin is our TechDocs plugin, which we use for creating
@@ -182,6 +180,10 @@ comes to [deployment](https://backstage.io/docs/getting-started/deployment-k8s),
the system integrator (typically, the infrastructure team in your organization)
maintains Backstage in your own environment.
+For more information, see our
+[Owners](https://github.com/backstage/backstage/blob/master/OWNERS.md) and
+[Governance](https://github.com/backstage/backstage/blob/master/GOVERNANCE.md).
+
### Does Spotify provide a managed version of Backstage?
No, this is not a service offering. We build the piece of software, and someone
@@ -215,14 +217,14 @@ data is shared with.
Yes. The core frontend framework could be used for building any large-scale web
application where (1) multiple teams are building separate parts of the app, and
(2) you want the overall experience to be consistent. That being said, in
-[Phase 2](https://github.com/backstage/backstage#project-roadmap) of the project
-we will add features that are needed for developer portals and systems for
-managing software ecosystems. Our ambition will be to keep Backstage modular.
+[Phase 2](overview/roadmap.md) of the project we will add features that are
+needed for developer portals and systems for managing software ecosystems. Our
+ambition will be to keep Backstage modular.
### How can I get involved?
Jump right in! Come help us fix some of the
-[early bugs and first issues](https://github.com/backstage/backstage/labels/good%20first%20issue)
+[early bugs and good first issues](https://github.com/backstage/backstage/contribute)
or reach [a new milestone](https://github.com/backstage/backstage/milestones).
Or write an open source plugin for Backstage, like this
[Lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse).
diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md
index a4d154b44b..2547db5c5c 100644
--- a/docs/api/utility-apis.md
+++ b/docs/api/utility-apis.md
@@ -11,7 +11,7 @@ possible residing within the plugin itself and its backend APIs. There will
however always be a need for plugins to communicate outside of its boundaries,
both with other plugins and the app itself.
-Backstage provides two primary methods for plugins to communication across their
+Backstage provides two primary methods for plugins to communicate across their
boundaries in client-side code. The first one being the `createPlugin` API and
the registration hooks passed to the `register` method, and the second one being
Utility APIs. While the `createPlugin` API is focused on the initialization
@@ -195,8 +195,8 @@ interface for the API, and create an `ApiRef` using `createApiRef` exported from
`@backstage/core`. Also be sure to provide at least one implementation of the
API, and to declare a default factory for the API in `createPlugin`.
-Custom Utility APIs can be either public or private, which it is up to the
-plugin to choose. Private APIs do not expose an external API surface, and it's
+Custom Utility APIs can be either public or private, which is up to the plugin
+to choose. Private APIs do not expose an external API surface, and it's
therefore possible to make breaking changes to the API without affecting other
users of the plugin. If an API is made public however, it opens up for other
plugins to make use of the API, and it also makes it possible for users for your
@@ -243,7 +243,7 @@ The figure below shows the relationship between
The current method for connecting Utility API providers and consumers is via the
React tree using an `ApiProvider`, which is added to the `AppProvider` of the
`App`. In the future there may potentially be more ways to do this, in ways that
-are not tied to react. A design goal of the Utility APIs was to not have them
+are not tied to React. A design goal of the Utility APIs was to not have them
directly tied to React.
The indirection provided by Utility APIs also makes it straightforward to test
diff --git a/docs/conf/defining.md b/docs/conf/defining.md
index bea03e4e44..ead5c4ff67 100644
--- a/docs/conf/defining.md
+++ b/docs/conf/defining.md
@@ -4,16 +4,109 @@ title: Defining Configuration for your Plugin
description: Documentation on Defining Configuration for your Plugin
---
-There is currently no tooling support or helpers for defining plugin
-configuration. But it's on the roadmap.
+Configuration in Backstage is organized via a configuration schema, which in
+turn is defined using a superset of
+[JSON Schema Draft-07](https://json-schema.org/specification-links.html#draft-7).
+Each plugin or package within a Backstage app can contribute to the schema,
+which during validation is stitched together into a single schema.
-Meanwhile, document the config values that you are reading in your plugin
-README.
+## Schema Collection and Definition
-## Format
+Schemas are collected from all packages and dependencies in each repo that are a
+part of the Backstage ecosystem, including transitive dependencies. The current
+definition of "part of the ecosystem" is that a package has at least one
+dependency in the `@backstage` namespace, but this is subject to change.
+
+Each package is searched for a schema at a single point of entry, a top-level
+`"configSchema"` field in `package.json`. The field can either contain an
+inlined JSON schema, or a relative path to a schema file. Supported schema file
+formats are `.json` or `.d.ts`.
+
+> When defining a schema file, be sure to include the file in your
+> `package.json` > `"files"` field as well!
+
+TypeScript configuration schema files should export a single `Config` type, for
+example:
+
+```ts
+export interface Config {
+ app: {
+ /**
+ * Frontend root URL
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+}
+```
+
+Separate `.json` schema files can use a top-level
+`"$schema": "https://backstage.io/schema/config-v1"` declaration in order to
+receive schema validation and autocompletion. For example:
+
+```json
+{
+ "$schema": "https://backstage.io/schema/config-v1",
+ "type": "object",
+ "properties": {
+ "app": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string",
+ "description": "Frontend root URL",
+ "visibility": "frontend"
+ }
+ },
+ "required": ["baseUrl"]
+ },
+ "required": ["app"]
+ }
+}
+```
+
+## Visibility
+
+The `https://backstage.io/schema/config-v1` meta schema is a superset of JSON
+Schema Draft 07. The single addition is a custom `visibility` keyword, which is
+used to indicate whether the given config value should be visible in the
+frontend or not. The possible values are `frontend`, `backend`, and `secret`,
+where `backend` is the default. A visibility of `secret` has the same scope at
+runtime, but it will be treated with more care in certain contexts, and defining
+both `frontend` and `secret` for the same value in two different schemas will
+result in an error during schema merging.
+
+The visibility only applies to the direct parent of where the keyword is placed
+in the schema. For example, if you set the visibility to `frontend` for a subset
+of the schema with `type: "object"`, but none of the descendants, only an empty
+object will be available in the frontend. The full ancestry does not need to
+have correctly defined visibilities however, so it is enough to only for example
+declare the visibility of a leaf node of `type: "string"`.
+
+## Validation
+
+Schemas can be validated using the `backstage-cli config:check` command. If you
+want to validate anything else than the default `app-config.yaml`, be sure to
+pass in all of the configuration files as `--config ` options as well.
+
+To validate and examine the frontend configuration, use the
+`backstage-cli config:print --frontend` command. Just like for validation you
+may need to pass in all files using one or multiple `--config ` options.
+
+## Guidelines
+
+> Make limited use of static configuration. The first question to ask is whether
+> a particular option actually needs to be static configuration, or if it might
+> just as well be a TypeScript API. In general, options that you want to be able
+> to change for different deployment environments should be static
+> configuration, while it should otherwise be avoided.
When defining configuration for your plugin, keep keys camelCased and stick to
-existing casing conventions such as `baseUrl`.
+existing casing conventions such as `baseUrl` rather than `baseURL`.
It is also usually best to prefer objects over arrays, as it makes it possible
to override individual values using separate files or environment variables.
+
+Avoid creating new top-level fields as much as possible. Either place your
+configuration within an existing known top-level block, or create a single new
+one using e.g. the name of the product that the plugin integrates.
diff --git a/docs/conf/index.md b/docs/conf/index.md
index 2c3ee06ba6..a6f1d1f6f7 100644
--- a/docs/conf/index.md
+++ b/docs/conf/index.md
@@ -28,11 +28,25 @@ development or small tweaks to be able to reuse deployment artifacts in
different environments.
The configuration is shared between the frontend and backend, meaning that
-values that are common between the two only needs to be defined once. Such as
-the `backend.baseUrl`.
+values that are common between the two only need to be defined once. Such as the
+`backend.baseUrl`.
For more details, see [Writing Configuration](./writing.md).
+## Configuration Schema
+
+The configuration is validated using JSON Schema definitions. Each plugin and
+package can provide pieces of the configuration schema, which are stitched
+together to form a complete schema during validation. The configuration schema
+is also used to select what configuration is available in the frontend using a
+custom `visibility` keyword, as configuration is by default only available in
+the backend.
+
+You can validate your configuration against the schema using
+`backstage-cli config:check`, and define a schema for your own plugin either
+using JSON Schema or TypeScript. For more information, see
+[Defining Configuration](./defining.md).
+
## Reading Configuration
As a plugin developer, you likely end up wanting to define configuration that
@@ -49,5 +63,5 @@ More details are provided in dedicated sections of the documentation.
plugin.
- [Writing Configuration](./writing.md): How to provide configuration for your
Backstage deployment.
-- [Defining Configuration](./defining.md): How to define configuration for users
- of your plugin.
+- [Defining Configuration](./defining.md): How to define a configuration schema
+ for users of your plugin or package.
diff --git a/docs/conf/writing.md b/docs/conf/writing.md
index 4eb92bb2ff..f28dd5b2d3 100644
--- a/docs/conf/writing.md
+++ b/docs/conf/writing.md
@@ -74,7 +74,7 @@ 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:
-```
+```shell
yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml
```
@@ -97,10 +97,10 @@ order:
- If no config flags are provided, `app-config.local.yaml` has higher priority
than `app-config.yaml`.
-## Secrets
+## Secrets and Dynamic Data
-Secrets are supported via a special secret keys that are prefixed with `$`,
-which in turn provides a number of different ways to read in secrets. To load a
+Secrets are supported via special data loading keys that are prefixed with `$`,
+which in turn provide a number of different ways to read in secrets. To load a
configuration value as a secret, supply an object with one of the special secret
keys, for example `$env` or `$file`. A full list of supported secret keys can be
found below. For example, the following will read the config key
@@ -117,10 +117,6 @@ will return the value of the environment variable `MY_SECRET_KEY` when the
backend started up. All secrets are loaded at startup, so changing the contents
of secret files or environment variables will not be reflected at runtime.
-Note that secrets will never be included in the frontend bundle or development
-builds. When loading configuration you have to explicitly enable reading of
-secrets, which is only done for the backend configuration.
-
As hinted at, secrets can be loaded from a bunch of different sources, and can
be extended with more. Below is a list of the currently supported methods for
loading secrets.
@@ -155,8 +151,11 @@ used to point to a specific value inside the file. Supported file extensions are
```yaml
$data: ./my-secrets.json#deployment.key
+```
-# my-secrets.json
+Example `my-secrets.json` file:
+
+```json
{
"deployment": {
"key": "my-secret-key"
diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md
index a330ef196a..60f1901807 100644
--- a/docs/features/software-catalog/descriptor-format.md
+++ b/docs/features/software-catalog/descriptor-format.md
@@ -2,8 +2,8 @@
id: descriptor-format
title: Descriptor Format of Catalog Entities
sidebar_label: YAML File Format
-description: Documentation on Descriptor Format of Catalog Entities which
-describes the default data shape and semantics of catalog entities
+# prettier-ignore
+description: Documentation on Descriptor Format of Catalog Entities which describes the default data shape and semantics of catalog entities
---
This section describes the default data shape and semantics of catalog entities.
@@ -22,11 +22,15 @@ we recommend that you name them `catalog-info.yaml`.
- [Overall Shape Of An Entity](#overall-shape-of-an-entity)
- [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope)
- [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata)
+- [Common to All Kinds: Relations](#common-to-all-kinds-relations)
- [Kind: Component](#kind-component)
- [Kind: Template](#kind-template)
- [Kind: API](#kind-api)
- [Kind: Group](#kind-group)
- [Kind: User](#kind-user)
+- [Kind: Resource](#kind-resource)
+- [Kind: System](#kind-system)
+- [Kind: Domain](#kind-domain)
## Overall Shape Of An Entity
@@ -39,7 +43,7 @@ software catalog API.
"kind": "Component",
"metadata": {
"annotations": {
- "backstage.io/managed-by-location": "file:/tmp/component-info.yaml",
+ "backstage.io/managed-by-location": "file:/tmp/catalog-info.yaml",
"example.com/service-discovery": "artistweb",
"circleci.com/project-slug": "github/example-org/artist-website"
},
@@ -259,6 +263,61 @@ This field is optional, and currently has no special semantics.
Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters
in total.
+## Common to All Kinds: Relations
+
+The `relations` root field is a read-only list of relations, between the current
+entity and other entities, described in the
+[well-known relations section](well-known-relations.md). Relations are commonly
+two-way, so that there's a pair of relation types each describing one direction
+of the relation.
+
+A relation as part of a single entity that's read out of the API may look as
+follows.
+
+```js
+{
+ // ...
+ "relations": [
+ {
+ "target": {
+ "kind": "group",
+ "namespace": "default",
+ "name": "dev.infra"
+ },
+ "type": "ownedBy"
+ }
+ ],
+ "spec": {
+ "owner": "dev.infra",
+ // ...
+ }
+}
+```
+
+The fields of a relation are:
+
+| Field | Type | Description |
+| ---------- | ------ | -------------------------------------------------------------------------------- |
+| `target` | Object | A complete [compound reference](references.md) to the other end of the relation. |
+| `type` | String | The type of relation FROM a source entity TO the target entity. |
+| `metadata` | Object | Reserved for future use. |
+
+Entity descriptor YAML files are not supposed to contain this field. Instead,
+catalog processors analyze the entity descriptor data and its surroundings, and
+deduce relations that are then attached onto the entity as read from the
+catalog.
+
+Where relations are produced, they are to be considered the authoritative source
+for that piece of data. In the example above, a plugin would do better to
+consume the relation rather than `spec.owner` for deducing the owner of the
+entity, because it may even be the case that the owner isn't taken from the YAML
+at all - it could be taken from a CODEOWNERS file nearby instead for example.
+Also, the `spec.owner` is on a shortened form and may have semantics associated
+with it (such as the default kind being `Group` if not specified).
+
+See the [well-known relations section](well-known-relations.md) for a list of
+well-known / common relations and their semantics.
+
## Kind: Component
Describes the following entity kind:
@@ -533,6 +592,9 @@ The current set of well-known and common values for this field is:
[OpenAPI](https://swagger.io/specification/) version 2 or version 3 spec.
- `asyncapi` - An API definition based on the
[AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) spec.
+- `graphql` - An API definition based on
+ [GraphQL schemas](https://spec.graphql.org/) for consuming
+ [GraphQL](https://graphql.org/) based APIs.
- `grpc` - An API definition based on
[Protocol Buffers](https://developers.google.com/protocol-buffers) to use with
[gRPC](https://grpc.io/).
@@ -641,6 +703,11 @@ sufficient to enter only the `metadata.name` field of that group.
### `spec.ancestors` [required]
+**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
+removed entirely from the model on Dec 6th, 2020 in the repository and will not
+be present in released packages following the next release after that. Please
+update your code to not consume this field before the removal date.
+
The recursive list of parents up the hierarchy, by stepping through parents one
by one. The list must be present, but may be empty if `parent` is not present.
The first entry in the list is equal to `parent`, and then the following ones
@@ -669,6 +736,11 @@ sufficient to enter only the `metadata.name` field of those groups.
### `spec.descendants` [required]
+**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
+removed entirely from the model on Dec 6th, 2020 in the repository and will not
+be present in released packages following the next release after that. Please
+update your code to not consume this field before the removal date.
+
The immediate and recursive child groups of this group in the hierarchy
(children, and children's children, etc.). The list must be present, but may be
empty if there are no child groups. The items are not guaranteed to be ordered
@@ -740,3 +812,15 @@ with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
+
+## Kind: Resource
+
+This kind is not yet defined, but is reserved [for future use](system-model.md).
+
+## Kind: System
+
+This kind is not yet defined, but is reserved [for future use](system-model.md).
+
+## Kind: Domain
+
+This kind is not yet defined, but is reserved [for future use](system-model.md).
diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md
index 72170fb576..d4dca0c059 100644
--- a/docs/features/software-catalog/extending-the-model.md
+++ b/docs/features/software-catalog/extending-the-model.md
@@ -1,41 +1,351 @@
---
id: extending-the-model
title: Extending the model
-description: Documentation on Extending the model
+description: Documentation on extending the catalog model
---
-Backstage natively supports tracking of the following component
-[`type`](descriptor-format.md)'s:
+The Backstage catalog [entity data model](descriptor-format.md) is based on the
+[Kubernetes objects format](https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/),
+and borrows a lot of its semantics as well. This page describes those semantics
+at a higher level and how to extend them to fit your organization.
-- Services
-- Websites
-- Libraries
-- Documentation
-- Other
+Backstage comes with a number of catalog concepts out of the box:
-
+- There are a number of builtin versioned _kinds_, such as `Component`, `User`
+ etc. These encapsulate the high level concept of an entity, and define the
+ schema for its entity definition data.
+- An entity has both a _metadata_ object and a _spec_ object at the root.
+- Each kind may or may not have a _type_. For example, there are several well
+ known types of component, such as `service` and `website`. These clarify the
+ more detailed nature of the entity, and may affect what features are exposed
+ in the interface.
+- Entities may have a number of _[annotations](well-known-annotations.md)_ on
+ them. These can be added either by humans into the descriptor files, or added
+ by automated processes when the entity is ingested into the catalog.
+- Entities may have a number of _labels_ on them.
+- Entities may have a number of _relations_, expressing how they relate to each
+ other in different ways.
-Since these types are likely not the only kind of software you will want to
-track in Backstage, it is possible to add your own software types that fit your
-organization's data model. Inside Spotify our model has grown significantly over
-the years, and now includes ML models, Apps, data pipelines and many more.
+We'll list different possibilities for extending this below.
-## Adding a new type
+## Adding a New apiVersion of an Existing Kind
-TODO: Describe what changes are needed to add a new type that shows up in the
-catalog.
+Example intents:
-## The Other type
+> "I want to evolve this core kind, tweaking the semantics a bit so I will bump
+> the apiVersion a step"
+
+> "This core kind is a decent fit but we want to evolve it at will so we'll move
+> it to our own company's apiVersion space and use that instead of
+> `backstage.io`."
+
+The `backstage.io` apiVersion space is reserved for use by the Backstage
+maintainers. Please do not change or add versions within that space.
+
+If you add an [apiVersion](descriptor-format.md#apiversion-and-kind-required)
+space of your own, you are effectively branching out from the underlying kind
+and making your own. An entity kind is identified by the apiVersion + kind pair,
+so even though the resulting entity may be similar to the core one, there will
+be no guarantees that plugins will be able to parse or understand its data. See
+below about adding a new kind.
+
+## Adding a New Kind
+
+Example intents:
+
+> "The kinds that come with the package are lacking. I want to model this other
+> thing that is a poor fit for either of the builtins."
+
+> "This core kind is a decent fit but we want to evolve it at will so we'll move
+> it to our own company's apiVersion space and use that instead of
+> `backstage.io`."
+
+A [kind](descriptor-format.md#apiversion-and-kind-required) is an overarching
+family, or an idea if you will, of entities that also share a schema. Backstage
+comes with a number of builtin ones that we believe are useful for a large
+variety of needs that one may want to model in Backstage. The primary ambition
+is to map things to these kinds, but sometimes you may want or need to extend
+beyond them.
+
+Introducing a new apiVersion is basically the same as adding a new kind. Bear in
+mind that most plugins will be compiled against the builtin
+`@backstage/catalog-model` package and have expectations that kinds align with
+that.
+
+The catalog backend itself, from a storage and API standpoint, does not care
+about the kind of entities it stores. Extending with new kinds is mainly a
+matter of permitting them to pass validation when building the backend catalog
+using the `CatalogBuilder`, and then to make plugins be able to understand the
+new kind.
+
+For the consuming side, it's a different story. Adding a kind has a very large
+impact. The very foundation of Backstage is to attach behavior and views and
+functionality to entities that we ascribe some meaning to. There will be many
+places where code checks `if (kind === 'X')` for some hard coded `X`, and casts
+it to a concrete type that it imported from a package such as
+`@backstage/catalog-model`.
+
+If you want to model something that doesn't feel like a fit for either of the
+builtin kinds, feel free to reach out to the Backstage maintainers to discuss
+how to best proceed.
+
+If you end up adding that new kind, you must namespace its `apiVersion`
+accordingly with a prefix that makes sense, typically based on your organization
+name - e.g. `my-company.net/v1`. Also do pick a new `kind` identifier that does
+not collide with the builtin kinds.
+
+## Adding a New Type of an Existing Kind
+
+Example intents:
+
+> "This is clearly a component, but it's of a type that doesn't quite fit with
+> the ones I've seen before."
+
+> "We don't call our teams "team", can't we put "flock" as the group type?"
+
+Some entity kinds have a `type` field in its spec. This is where an organization
+are free to express the variety of entities within a kind. This field is
+expected to follow some taxonomy that makes sense for yourself. The chosen value
+may affect what operations and views are enabled in Backstage for that entity.
+Inside Spotify our model has grown significantly over the years, and our
+component types now include ML models, apps, data pipelines and many more.
It might be tempting to put software that doesn't fit into any of the existing
-types into Other. There are a few reasons why we advise against this; firstly,
-we have found that it is preferred to match the conceptual model that your
-engineers have when describing your software. Secondly, Backstage helps your
-engineers manage their software by integrating the infrastructure tooling
-through plugins. Different plugins are used for managing different types of
-components.
+types into an Other catch-all type. There are a few reasons why we advise
+against this; firstly, we have found that it is preferred to match the
+conceptual model that your engineers have when describing your software.
+Secondly, Backstage helps your engineers manage their software by integrating
+the infrastructure tooling through plugins. Different plugins are used for
+managing different types of components.
For example, the
[Lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse)
only makes sense for Websites. The more specific you can be in how you model
your software, the easier it is to provide plugins that are contextual.
+
+Adding a new type takes relatively little effort and carries little risk. Any
+type value is accepted by the catalog backend, but plugins may have to be
+updated if you want particular behaviors attached to that new type.
+
+## Changing the Validation Rules for The Entity Envelope or Metadata Fields
+
+Example intents:
+
+> "We want to import our old catalog but the default set of allowed characters
+> for a metadata.name are too strict."
+
+> "I want to change the rules for annotations so that I'm allowed to store any
+> data in annotation values, not just strings."
+
+After pieces of raw entity data have been read from a location, they are passed
+through a fixed number of so called `Validators`, as part of the entity policy
+check step. They ensure that the types and syntax of the base envelope and
+metadata make sense - in short, things that aren't entity-kind-specific. Some or
+all of these validators can be replaced when building the backend catalog using
+the `CatalogBuilder`.
+
+The risk and impact of this type of extension varies, based on what it is that
+you want to do. For example, extending the valid character set for kinds,
+namespaces and names can be fairly harmless, with a few notable exceptions -
+there is code that expects these to never ever contain a colon or slash, for
+example, and introducing URL-unsafe characters risks breaking plugins that
+aren't careful about encoding arguments. Supporting non-strings in annotations
+may be possible but has not yet been tried out in the real world - there is
+likely to be some level of plugin breakage that can be hard to predict.
+
+Before making this kind of extension, we recommend that you contact the
+Backstage maintainers or a support partner to discuss your use case.
+
+## Changing the Validation Rules for Core Entity Fields
+
+Example intent:
+
+> "I don't like that the owner is mandatory. I'd like it to be optional."
+
+After reading and policy-checked entity data from a location, it is sent through
+the processor chain looking for processors that implement the
+`validateEntityKind` step, to see that the data is of a known kind and abides by
+its schema. There is a builtin processor that implements this for all known core
+kinds and matches the data against their fixed validation schema. This processor
+can be replaced when building the backend catalog using the `CatalogBuilder`,
+with a processor of your own that validates the data differently.
+
+This type of extension is high risk, and may have high impact across the
+ecosystem depending on the type of change that is made. It is therefore not
+recommended in normal cases. There will be a large number of plugins and
+processors - and even the core itself - that make assumptions about the shape of
+the data and import the typescript data type from the `@backstage/catalog-model`
+package.
+
+## Adding New Fields to the Metadata Object
+
+Example intent:
+
+> "Our entities have this auxiliary property that I would like to express for
+> several entity kinds and it doesn't really fit as a spec field."
+
+The metadata object is currently left open for extension. Any unknown fields
+found in the metadata will just be stored verbatim in the catalog. However we
+want to caution against extending the metadata excessively. Firstly, you run the
+risk of colliding with future extensions to the model. Secondly, it is common
+that this type of extension lives more comfortably elsewhere - primarily in the
+metadata labels or annotations, but sometimes you even may want to make a new
+component type or similar instead.
+
+There are some situations where metadata can be the right place. If you feel
+that you have run into such a case and that it would apply to others, do feel
+free to contact the Backstage maintainers or a support partner to discuss your
+use case. Maybe we can extend the core model to benefit both you and others.
+
+## Adding New Fields to the Spec Object of an Existing Kind
+
+Example intent:
+
+> "The builtin Component kind is fine but we want to add an additional field to
+> the spec for describing whether it's in prod or staging."
+
+A kind's schema validation typically doesn't forbid "unknown" fields in an
+entity `spec`, and the catalog will happily store whatever is in it. So doing
+this will usually work from the catalog's point of view.
+
+Adding fields like this is subject to the same risks as mentioned about metadata
+extensions above. Firstly, you run the risk of colliding with future extensions
+to the model. Secondly, it is common that this type of extension lives more
+comfortably elsewhere - primarily in the metadata labels or annotations, but
+sometimes you even may want to make a new component type or similar instead.
+
+There are some situations where the spec can be the right place. If you feel
+that you have run into such a case and that it would apply to others, do feel
+free to contact the Backstage maintainers or a support partner to discuss your
+use case. Maybe we can extend the core model to benefit both you and others.
+
+## Adding a New Annotation
+
+Example intents:
+
+> "Our custom made build system has the concept of a named pipeline-set, and we
+> want to associate individual components with their corresponding pipeline-sets
+> so we can show their build status."
+
+> "We have an alerting system that automatically monitors service health, and
+> there's this integration key that binds the service to an alerts pool. We want
+> to be able to show the ongoing alerts for our services in Backstage so it'd be
+> nice to attach that integration key to the entity somehow."
+
+Annotations are mainly intended to be consumed by plugins, for feature detection
+or linking into external systems. Sometimes they are added by humans, but often
+they are automatically generated at ingestion time by processors. There is a set
+of [well-known annotations](well-known-annotations.md), but you are free to add
+additional ones. This carries no risk or impact to other systems as long as you
+abide by the following naming rules.
+
+- The `backstage.io` annotation prefix is reserved for use by the Backstage
+ maintainers. Reach out to us if you feel that you would like to make an
+ addition to that prefix.
+- Annotations that pertain to a well known third party system should ideally be
+ prefixed with a domain, in a way that makes sense to a reader and connects it
+ clearly to the system (or the maker of the system). For example, you might use
+ a `pagerduty.com` prefix for pagerduty related annotations, but maybe not
+ `ldap.com` for LDAP annotations since it's not directly affiliated with or
+ owned by an LDAP foundation/company/similar.
+- Annotations that have no prefix at all, are considered local to your Backstage
+ instance and can be used freely as such, but you should not make use of them
+ outside of your organization. For example, if you were to open source a plugin
+ that generates or consumes annotations, then those annotations must be
+ properly prefixed with your company domain or a domain that pertains to the
+ annotation at hand.
+
+## Adding a New Label
+
+Example intents:
+
+> "Our process reaping system wants to periodically scrape for components that
+> have a certain property."
+
+> "It'd be nice if our service owners could just tag their components somehow to
+> let the CD system know to automatically generate SRV records or not for that
+> service."
+
+Labels are mainly intended to be used for filtering of entities, by external
+systems that want to find entities that have some certain property. This is
+sometimes used for feature detection / selection. An example could be to add a
+label `deployments.my-company.net/register-srv: "true"`.
+
+At the time of writing this, the use of labels is very limited and we are still
+settling together with the community on how to best use them. If you feel that
+your use case fits the labels best, we would appreciate if you let the Backstage
+maintainers know.
+
+You are free to add labels. This carries no risk or impact to other systems as
+long as you abide by the following naming rules.
+
+- The `backstage.io` label prefix is reserved for use by the Backstage
+ maintainers. Reach out to us if you feel that you would like to make an
+ addition to that prefix.
+- Labels that pertain to a well known third party system should ideally be
+ prefixed with a domain, in a way that makes sense to a reader and connects it
+ clearly to the system (or the maker of the system). For example, you might use
+ a `pagerduty.com` prefix for pagerduty related labels, but maybe not
+ `ldap.com` for LDAP labels since it's not directly affiliated with or owned by
+ an LDAP foundation/company/similar.
+- Labels that have no prefix at all, are considered local to your Backstage
+ instance and can be used freely as such, but you should not make use of them
+ outside of your organization. For example, if you were to open source a plugin
+ that generates or consumes labels, then those labels must be properly prefixed
+ with your company domain or a domain that pertains to the label at hand.
+
+## Adding a New Relation Type
+
+Example intents:
+
+> "We have this concept of service maintainership, separate from ownership, that
+> we would like to make relations to individual users for."
+
+> We feel that we want to explicitly model the team-to-global-department mapping
+> as a relation, because it is core to our org setup and we frequently query for
+> it.
+
+Any processor can emit relations for entities as they are being processed, and
+new processors can be added when building the backend catalog using the
+`CatalogBuilder`. They can emit relations based on the entity data itself, or
+based on information gathered from elsewhere. Relations are directed and go from
+a source entity to a target entity. They are also tied to the entity that
+originated them - the one that was subject to processing when the relation was
+emitted. Relations may be dangling (referencing something that does not actually
+exist by that name in the catalog), and callers need to be aware of that.
+
+There is a set of [well-known relations](well-known-relations.md), but you are
+free to emit your own as well. You cannot change the fact that they are directed
+and have a source and target that have to be an
+[entity reference](references.md), but you can invent your own types. You do not
+have to make any changes to the catalog backend in order to accept new relation
+types.
+
+At the time of writing this, we do not have any namespacing/prefixing scheme for
+relation types. The type is also not validated to contain only some particular
+set of characters. Until rules for this are settled, you should stick to using
+only letters, dashes and digits, and to avoid collisions with future core
+relation types, you may want to prefix the type somehow. For example:
+`myCompany-maintainerOf` + `myCompany-maintainedBy`.
+
+If you have a suggestion for a relation type to be elevated to the core
+offering, reach out to the Backstage maintainers or a support partner.
+
+## Using a Well-Known Relation Type for a New Purpose
+
+Example intents:
+
+> "The ownerOf/ownedBy relation types sound like a good fit for expressing how
+> users are technical owners of our company specific ServiceAccount kind, and we
+> want to reuse those relation types for that."
+
+At the time of writing, this is uncharted territory. If the documented use of a
+relation states that one end of the relation commonly is a User or a Group, for
+example, then consumers are likely to have conditional statements on the form
+`if (x.kind === 'User') {} else {}`, which get confused when an unexpected kind
+appears.
+
+If you want to extend the use of an established relation type in a way that has
+an effect outside of your organization, reach out to the Backstage maintainers
+or a support partner to discuss risk/impact. It may even be that one end of the
+relation could be considered for addition to the core.
diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md
index 325bed5e4a..e7d253f11e 100644
--- a/docs/features/software-catalog/external-integrations.md
+++ b/docs/features/software-catalog/external-integrations.md
@@ -1,8 +1,8 @@
---
id: external-integrations
title: External integrations
-description: Documentation on External integrations to integrate systems
-with Backstage
+# prettier-ignore
+description: Documentation on External integrations to integrate systems with Backstage
---
Backstage natively supports importing catalog data through the use of
diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md
index 6076068504..70541b85db 100644
--- a/docs/features/software-catalog/index.md
+++ b/docs/features/software-catalog/index.md
@@ -2,8 +2,8 @@
id: software-catalog-overview
title: Backstage Service Catalog (alpha)
sidebar_label: Overview
-description: The Backstage Service Catalog — actually, a software catalog, since
-it includes more than just services
+# prettier-ignore
+description: The Backstage Service Catalog — actually, a software catalog, since it includes more than just services
---
## What is a Service Catalog?
@@ -62,7 +62,7 @@ Users can register new components by going to `/create` and clicking the
Backstage expects the full URL to the YAML in your source control. Example:
```bash
-https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml
+https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
```
_More examples can be found
@@ -91,7 +91,7 @@ above example can be added using the following configuration:
catalog:
locations:
- type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml
+ target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
```
More information about catalog configuration can be found
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 5cb6837340..7d16427a3c 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -2,9 +2,8 @@
id: well-known-annotations
title: Well-known Annotations on Catalog Entities
sidebar_label: Well-known Annotations
-description: Documentation on lists a number of well known Annotations, that
-have defined semantics. They can be attached to catalog entities and consumed
-by plugins as needed
+# prettier-ignore
+description: Documentation that lists a number of well known Annotations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed.
---
This section lists a number of well known
@@ -191,9 +190,25 @@ metadata:
```
The value of these annotations are the corresponding attributes that were found
-when ingestion the entity from LDAP. Not all of them may be present, depending
+when ingesting the entity from LDAP. Not all of them may be present, depending
on what attributes that the server presented at ingestion time.
+### graph.microsoft.com/tenant-id, graph.microsoft.com/group-id, graph.microsoft.com/user-id
+
+```yaml
+# Example:
+metadata:
+ annotations:
+ graph.microsoft.com/tenant-id: 6902611b-ffc1-463f-8af3-4d5285dc057b
+ graph.microsoft.com/group-id: c57e8ba2-6cc4-1039-9ebc-d5f241a7ca21
+ graph.microsoft.com/user-id: 2de244b5-104b-4e8f-a3b8-dce3c31e54b6
+```
+
+The value of these annotations are the corresponding attributes that were found
+when ingesting the entity from the Microsoft Graph API. Not all of them may be
+present, depending on what attributes that the server presented at ingestion
+time.
+
### sonarqube.org/project-key
```yaml
diff --git a/docs/features/software-catalog/well-known-relations.md b/docs/features/software-catalog/well-known-relations.md
new file mode 100644
index 0000000000..07a4fc0a5c
--- /dev/null
+++ b/docs/features/software-catalog/well-known-relations.md
@@ -0,0 +1,82 @@
+---
+id: well-known-relations
+title: Well-known Relations between Catalog Entities
+sidebar_label: Well-known Relations
+# prettier-ignore
+description: Documentation that lists a number of well known Relations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed.
+---
+
+This section lists a number of well known
+[entity relation types](descriptor-format.md#common-to-all-kinds-relations),
+that have defined semantics. They can be attached to catalog entities and
+consumed by plugins as needed.
+
+If you are looking to extend the set of relations, see
+[Extending the model](extending-the-model.md).
+
+## Relations
+
+This is a (non-exhaustive) list of relations that are known to be in active use.
+
+Each relation has a _source_ (implicitly: the entity that holds the relation), a
+_target_ (the entity to which the source has a relation), and a _type_ that
+tells what relation the source has with the target. The relation is directional;
+there are commonly pairs of relation types and the entity at the other end will
+have the opposite relation in the opposite direction (e.g. when querying for
+`A`, you will see `A.ownedBy.B`, and when querying `B`, you will see
+`B.ownerOf.A`).
+
+### `ownedBy` and `ownerOf`
+
+An ownership relation where the owner is usually an organizational entity
+([User](descriptor-format.md#kind-user) or
+[Group](descriptor-format.md#kind-group)), and the other entity can be anything.
+
+In Backstage, the owner of an entity is the singular entity (commonly a team)
+that bears ultimate responsibility for the entity, and has the authority and
+capability to develop and maintain it. They will be the point of contact if
+something goes wrong, or if features are to be requested. The main purpose of
+this relation is for display purposes in Backstage, so that people looking at
+catalog entities can get an understanding of to whom this entity belongs. It is
+not to be used by automated processes to for example assign authorization in
+runtime systems. There may be others that also develop or otherwise touch the
+entity, but there will always be one ultimate owner.
+
+This relation is commonly generated based on `spec.owner` of the owned entity,
+where present.
+
+### `consumesApi` and `providesApi`
+
+A relation with an [API](descriptor-format.md#kind-api) entity, typically from a
+[Component](descriptor-format.md#kind-component) or
+[System](descriptor-format.md#kind-system).
+
+These relations express that a component or system either exposes an API -
+meaning that it hosts callable endpoints from which you can consume that API -
+or that they are dependent on being able to consume that API.
+
+This relation is commonly generated based on `spec.implementsApis` of the
+component or system in question.
+
+### `dependsOn` and `dependencyOf`
+
+A relation denoting a dependency on another entity.
+
+This relation is a general expression of being in need of that other entity for
+an entity to function. It can for example be used to express that a website
+component needs a library component as part of its build, or that a service
+component uses a persistent storage resource.
+
+### `parentOf` and `childOf`
+
+A parent/child relation to build up a tree, used for example to describe the
+organizational structure between [Groups](descriptor-format.md#kind-group).
+
+This relation is commonly based on `spec.parent` and/or `spec.children`.
+
+### `memberOf` and `hasMember`
+
+A membership relation, typically for [Users](descriptor-format.md#kind-user) in
+[Groups](descriptor-format.md#kind-group).
+
+This relation is commonly based on `spec.memberOf`.
diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md
index 75a75003f7..e2b69d6767 100644
--- a/docs/features/software-templates/adding-templates.md
+++ b/docs/features/software-templates/adding-templates.md
@@ -55,6 +55,10 @@ contains more information about the required fields.
Once we have a `template.yaml` ready, we can then add it to the service catalog
for use by the scaffolder.
+_NOTE_: When the `publish` step is completed, it is currently assumed by the
+scaffolder that the final repository should contain a `catalog-info.yaml` in
+order to register this with the Catalog in Backstage.
+
Currently the catalog supports loading definitions from GitHub + Local Files. To
load from other places, not only will there need to be another preparer, but the
support to load the location will also need to be added to the Catalog.
diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md
index f1cf637997..16131a1ded 100644
--- a/docs/features/techdocs/README.md
+++ b/docs/features/techdocs/README.md
@@ -122,3 +122,12 @@ migrate Spotify's existing TechDocs features to open source.
https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend
[techdocs/container]: https://github.com/backstage/techdocs-container
[techdocs/cli]: https://github.com/backstage/techdocs-cli
+
+## Feedback
+
+We have created a sweet and short TechDocs user survey -
+https://docs.google.com/forms/d/e/1FAIpQLSdn5Vn3MQhCdyYRuW8cMzZkMQF0bFxXYN168gZRvESLfJWVVg/viewform
+
+This is to gather inputs from you (the Backstage community) which will help us
+best serve TechDocs adopters and existing users. Your inputs will shape our
+roadmap and we will share it in the open.
diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md
index cbbbc1e6cd..e51c6a122e 100644
--- a/docs/features/techdocs/getting-started.md
+++ b/docs/features/techdocs/getting-started.md
@@ -95,7 +95,7 @@ environment is compatible with techdocs.
You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from
pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g.
apt). See our
-[Dockerfile](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/Dockerfile)
+[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile)
for the latest requirements. You should be trying to match your Dockerfile with
this one.
@@ -104,7 +104,7 @@ Note: We recommend Python version 3.7 or higher.
Caveat: Please install the `mkdocs-techdocs-core` package after all other Python
packages. The order is important to make sure we get correct version of some of
the dependencies. For example, we want `Markdown` version to be
-[3.2.2](https://github.com/spotify/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
+[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
You can also explicitly install `Markdown==3.2.2` after installing all other
Python packages.
diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md
index c35eded6a5..6aef4c48c1 100644
--- a/docs/getting-started/create-an-app.md
+++ b/docs/getting-started/create-an-app.md
@@ -14,7 +14,7 @@ need to run Backstage in your own environment.
To create a Backstage app, you will need to have
[Node.js](https://nodejs.org/en/download/) Active LTS Release installed
-(currently v12).
+(currently v14).
Backstage provides a utility for creating new apps. It guides you through the
initial setup of selecting the name of the app and a database for the backend.
@@ -38,6 +38,42 @@ app-folder is the name that was provided when prompted.
Inside that directory, it will generate all the files and folder structure
needed for you to run your app.
+### Linking in local Backstage packages
+
+It can often be useful to try out changes to the packages in the main Backstage
+repo within your own app. For example if you want to make modifications to
+`@backstage/core` and try them out in your app.
+
+To link in external packages, add them to your `package.json` and `lerna.json`
+workspace paths. These can be either relative or absolute paths with or without
+globs. For example:
+
+```json
+"packages": [
+ "packages/*",
+ "plugins/*",
+ "../backstage/packages/core", // New path added to work on @backstage/core
+],
+```
+
+Then reinstall packages to make yarn set up symlinks:
+
+```bash
+yarn install
+```
+
+With this in place you can now modify the `@backstage/core` package within the
+main repo, and have those changes be reflected and tested in your app. Simply
+run your app using `yarn start` as normal.
+
+Note that for backend packages you need to make sure that linked packages are
+not dependencies of any non-linked package. If you for example want to work on
+`@backstage/backend-common`, you need to also link in other backend plugins and
+packages that depend on `@backstage/backend-common`, or temporarily disable
+those plugins in your backend. This is because the transformation of backend
+module tree stops whenever a non-local package is encountered, and from that
+point node will `require` packages directly for that entire module subtree.
+
### Troubleshooting
The create app command doesn't always work as expected, this is a collection of
diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md
index a30a918fd0..6de9d234e3 100644
--- a/docs/getting-started/running-backstage-locally.md
+++ b/docs/getting-started/running-backstage-locally.md
@@ -8,19 +8,19 @@ description: Documentation on How to run Backstage Locally
- Node.js
-First make sure you are using Node.js with an Active LTS Release, currently v12.
+First make sure you are using Node.js with an Active LTS Release, currently v14.
This is made easy with a version manager such as
[nvm](https://github.com/nvm-sh/nvm) which allows for version switching.
```bash
# Installing a new version
-nvm install 12
-> Downloading and installing node v12.18.3...
-> Now using node v12.18.3 (npm v6.14.6)
+nvm install 14
+> Downloading and installing node v14.15.1...
+> Now using node v14.15.1 (npm v6.14.8)
# Checking your version
node --version
-> v12.18.3
+> v14.15.1
```
- Yarn
diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md
index 5ff4d49a7c..71612cc5e4 100644
--- a/docs/overview/roadmap.md
+++ b/docs/overview/roadmap.md
@@ -39,7 +39,7 @@ If you have questions about the roadmap or want to provide feedback, we would
love to hear from you! Please create an
[Issue](https://github.com/backstage/backstage/issues/new/choose), ping us on
[Discord](https://discord.gg/EBHEGzX) or reach out directly at
-[alund@spotify.com](mailto:alund@spotify.com).
+[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com).
Want to help out? Awesome ❤️ Head over to
[CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md)
diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md
new file mode 100644
index 0000000000..416cdcdd4a
--- /dev/null
+++ b/docs/overview/stability-index.md
@@ -0,0 +1,384 @@
+---
+id: stability-index
+title: Stability Index
+description:
+ An overview of the commitment to stability for different parts of the
+ Backstage codebase.
+---
+
+## Overview
+
+The purpose of the Backstage Stability Index is to communicate the stability of
+various parts of the project. It is tracked using a scoring system where a
+higher score indicates a higher level of stability and is a commitment to
+smoother transitions between breaking changes. Importantly, the Stability Index
+does not supersede [semver](https://semver.org/), meaning we will still adhere
+to semver and only do breaking changes in minor releases as long as we are on
+`0.x`.
+
+Each package or section is assigned a stability score between 0 and 3, with each
+point building on top of the previous one:
+
+- **0** - Breaking changes are noted in the changelog, and documentation is
+ updated.
+- **1** - The changelog entry includes a clearly documented upgrade path,
+ providing guidance for how to migrate previous usage patterns to the new
+ version.
+- **2** - Breaking changes always include a deprecation phase where both the old
+ and the new APIs can be used in parallel. This deprecation must have been
+ released for at least two weeks before the deprecated API is removed in a
+ minor version bump.
+- **3** - The time limit for the deprecation is 3 months instead of two weeks.
+
+TL;DR:
+
+- **0** - There's a changelog entry.
+- **1** - There's a migration guide.
+- **2** - 2 weeks of deprecation.
+- **3** - 3 months of deprecation.
+
+## Packages
+
+### [`example-app`](https://github.com/backstage/backstage/tree/master/packages/app/)
+
+This is the `packages/app` package, and it serves as an example as well as
+utility for local development in the main Backstage repo.
+
+Stability: `N/A`
+
+### [`example-backend`](https://github.com/backstage/backstage/tree/master/packages/backend/)
+
+This is the `packages/backend` package, and it serves as an example as well as
+utility for local development in the main Backstage repo.
+
+Stability: `N/A`
+
+### [`backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common/)
+
+A collection of common helpers to be used by both backend plugins, and for
+constructing backend packages.
+
+Stability: `1`
+
+### [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client/)
+
+An HTTP client for interacting with the catalog backend. Usable both in frontend
+and Backend.
+
+Stability: `0`. This is a very new addition and we have some immediate changes
+planned.
+
+### [`catalog-model`](https://github.com/backstage/backstage/tree/master/packages/catalog-model/)
+
+Contains the core catalog model, and utilities for working with entities. Usable
+both in frontend and Backend.
+
+Stability: `2`. The catalog model is evolving, but because of the broad usage we
+
+want to ensure some stability.
+
+### [`cli`](https://github.com/backstage/backstage/tree/master/packages/cli/)
+
+The main toolchain used for Backstage development. The interface that is
+considered for stability are the various commands and options passed to those
+commands, as well as the environment variables read by the CLI. The build output
+may change over time and is not considered a breaking change unless it is likely
+to affect external tooling.
+
+Stability: `2`
+
+### [`cli-common`](https://github.com/backstage/backstage/tree/master/packages/cli-common/)
+
+Lightweight utilities used by the various Backstage CLIs, not intended for
+external use.
+
+Stability: `N/A`
+
+### [`config`](https://github.com/backstage/backstage/tree/master/packages/config/)
+
+Provides the logic and interfaces for reading static configuration.
+
+Stability: `2`
+
+### [`config-loader`](https://github.com/backstage/backstage/tree/master/packages/config-loader/)
+
+Used to load in static configuration, mainly for use by the CLI and
+@backstage/backend-common.
+
+Stability: `1`. Mainly intended for internal use.
+
+### [`core`](https://github.com/backstage/backstage/tree/master/packages/core/)
+
+#### Section: React Components
+
+All of the React components exported from `src/components/` and `src/layout/`
+
+Stability: `1`. These components have not received a proper review of the API,
+but we also want to ensure stability.
+
+#### Section: Plugin API
+
+The parts of the core API that are used by plugins, and the way plugins expose
+functionality to apps and other plugins. Includes for example `createPlugin`,
+`createRouteRef`, `createApiRef`.
+
+Stability: `2`. There are planned breaking changes around the way that plugins
+expose features and do routing. We still commit to keeping a short deprecation
+period so that plugins outside of the main repo have time to migrate.
+
+#### Section: App API
+
+The APIs used exclusively in the app, such as `createApp` and the system icons.
+
+Stability: `2`
+
+#### Section: Utility API Definitions
+
+The type declarations of the core Utility APIs.
+
+Stability: `2`. Changes to the Utility API type declarations need time to
+propagate.
+
+#### Section: Utility API Implementations
+
+The interfaces and default implementations for various Utility APIs, such as
+ErrorApi, IdentityApi, the auth APIs, etc.
+
+Stability: `1`. Most changes to the core utility APIs will not lead to
+widespread breaking changes since most apps rely on the default implementations.
+
+### [`core-api`](https://github.com/backstage/backstage/tree/master/packages/core-api/)
+
+The non-visual parts of @backstage/core. Everything in this packages is
+re-exported from @backstage/core, and this package should not be used directly.
+
+Stability: See @backstage/core
+
+### [`create-app`](https://github.com/backstage/backstage/tree/master/packages/create-app/)
+
+The CLI used to scaffold new Backstage projects.
+
+Stability: `2`
+
+### [`dev-utils`](https://github.com/backstage/backstage/tree/master/packages/dev-utils/)
+
+Provides utilities for developing plugins in isolation.
+
+Stability: `0`. This package is largely broken and needs updates.
+
+### [`docgen`](https://github.com/backstage/backstage/tree/master/packages/docgen/)
+
+Internal CLI utility for generating API Documentation.
+
+Stability: `N/A`
+
+### [`e2e-test`](https://github.com/backstage/backstage/tree/master/packages/e2e-test/)
+
+Internal CLI utility for running e2e tests.
+
+Stability: `N/A`
+
+### [`storybook`](https://github.com/backstage/backstage/tree/master/packages/storybook/)
+
+Internal storybook build for publishing stories to
+https://backstage.io/storybook
+
+Stability: `N/A`
+
+### [`test-utils`](https://github.com/backstage/backstage/tree/master/packages/test-utils/)
+
+Utilities for writing tests for Backstage plugins and apps.
+
+Stability: `2`
+
+### [`test-utils-core`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/)
+
+Internal testing utilities that are separated out for usage in
+@backstage/core-api. All exports are re-exported by @backstage/test-utils. This
+package should not be depended on directly.
+
+Stability: See @backstage/test-utils
+
+### [`theme`](https://github.com/backstage/backstage/tree/master/packages/theme/)
+
+The core Backstage MUI theme along with customization utilities.
+
+#### Section: TypeScript
+
+This is the TypeScript API exported by the theme package.
+
+Stability: `2`
+
+#### Section: Visual Theme
+
+The visual theme exported by the theme packages, where for example changing a
+color could be considered a breaking change.
+
+Stability: `1`
+
+## Plugins
+
+Plugins are rarely marked as stable as the `@backstage/core` plugin API is under
+heavy development.
+
+Many backend plugins are split into "REST API" and "TypeScript Interface"
+sections. The "TypeScript Interface" refers to the API used to integrate the
+plugin into the backend.
+
+Any plugin that is not listed below is untracked and can generally be considered
+unstable with a score of `0`. Open a Pull Request if you want your plugin to be
+added!
+
+### [`api-docs`](https://github.com/backstage/backstage/tree/master/plugins/api-docs/)
+
+Components to discover and display API entities as an extension to the catalog
+plugin.
+
+Stability: `0`
+
+### [`app-backend`](https://github.com/backstage/backstage/tree/master/plugins/app-backend/)
+
+A backend plugin that can be used to serve the frontend app and inject
+configuration.
+
+Stability: `2`
+
+### [`auth-backend`](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/)
+
+A backend plugin that implements the backend portion of the various
+authentication flows used in Backstage.
+
+#### Section: REST API
+
+Stability: `2`
+
+#### Section: TypeScript Interface
+
+Stability: `1`
+
+### [`catalog`](https://github.com/backstage/backstage/tree/master/plugins/catalog/)
+
+The frontend plugin for the catalog, with the table and building blocks for the
+entity pages.
+
+Stability: `1`. We're planning some work to overhaul how entity pages are
+constructed.
+
+### [`catalog-backend`](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/)
+
+The backend API for the catalog, also exposes the processing subsystem for
+customization of the catalog. Powers the @backstage/plugin-catalog frontend
+plugin.
+
+#### Section: REST API
+
+Stability: `1`. There are plans to remove and rework some endpoints.
+
+#### Section: TypeScript Interface
+
+Stability: `1`. There are plans to rework parts of the Processor interface.
+
+### [`catalog-graphql`](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/)
+
+Provides the catalog schema and resolvers for the graphql backend.
+
+Stability: `0`. Under heavy development and subject to change.
+
+### [`explore`](https://github.com/backstage/backstage/tree/master/plugins/explore/)
+
+A frontend plugin that introduces the concept of exploring internal and external
+tooling in an organization.
+
+Stability: `0`. Only an example at the moment and not customizable.
+
+### [`graphiql`](https://github.com/backstage/backstage/tree/master/plugins/graphiql/)
+
+Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage.
+
+Stability: `1`
+
+### [`graphql`](https://github.com/backstage/backstage/tree/master/plugins/graphql/)
+
+A backend plugin that provides
+
+Stability: `0`. Under heavy development and subject to change.
+
+### [`kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/)
+
+The frontend component of the Kubernetes plugin, used to browse and visualize
+Kubernetes resources.
+
+Stability: `1`.
+
+### [`kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/)
+
+The backend component of the Kubernetes plugin, used to fetch Kubernetes
+resources from clusters and associate them with entities in the Catalog.
+
+Stability: `1`.
+
+### [`proxy-backend`](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/)
+
+A backend plugin used to set up proxying to other endpoints based on static
+configuration.
+
+Stability: `1`
+
+### [`register-component`](https://github.com/backstage/backstage/tree/master/plugins/register-component/)
+
+A frontend plugin that allows the user to register entity locations in the
+catalog.
+
+Stability: `0`. This plugin is likely to be replaced by a generic entity import
+plugin instead.
+
+### [`scaffolder`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/)
+
+The frontend scaffolder plugin where one can browse templates and initiate
+scaffolding jobs.
+
+Stability: `1`
+
+### [`scaffolder-backend`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/)
+
+The backend scaffolder plugin that provides an implementation for templates in
+the catalog.
+
+Stability: `1`. There is planned work to rework the scaffolder in
+https://github.com/backstage/backstage/issues/2771.
+
+### [`tech-radar`](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/)
+
+Visualize the your company's official guidelines of different areas of software
+development.
+
+Stability: `0`
+
+### [`techdocs`](https://github.com/backstage/backstage/tree/master/plugins/techdocs/)
+
+The frontend component of the TechDocs plugin, used to browse technical
+documentation of entities.
+
+Stability: `1`
+
+### [`techdocs-backend`](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/)
+
+The backend component of the TechDocs plugin, used to transform and serve
+TechDocs.
+
+Stability: `0`
+
+### [`user-settings`](https://github.com/backstage/backstage/tree/master/plugins/user-settings/)
+
+A frontend plugin that provides a page where the user can tweak various
+settings.
+
+Stability: `1`
+
+### [`welcome`](https://github.com/backstage/backstage/tree/master/plugins/welcome/)
+
+A plugin that can be used to welcome the user to Backstage.
+
+Stability: `0`. This used to be the start page for the example app, but has been
+replaced by the catalog plugin. It is still viewable at `/welcome` but may be
+removed.
diff --git a/docs/overview/vision.md b/docs/overview/vision.md
index df75a4f0a6..c17e2b17ba 100644
--- a/docs/overview/vision.md
+++ b/docs/overview/vision.md
@@ -19,5 +19,5 @@ We are working on making Backstage the trusted standard toolbox (read: UX layer)
for the open source infrastructure landscape. Think of it like Kubernetes for
developer experience. We realize this is an ambitious goal. We can’t do it
alone. If this sounds interesting or you'd like to help us shape our product
-vision, we'd love to talk. You can email me directly:
-[alund@spotify.com](mailto:alund@spotify.com).
+vision, we'd love to talk. You can email us directly:
+[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com).
diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md
index 034fba4cb8..c25e087e57 100644
--- a/docs/support/project-structure.md
+++ b/docs/support/project-structure.md
@@ -89,6 +89,12 @@ are separated out into their own folder, see further down.
There are no "core" packages in the backend. Instead we have `backend-common`
which contains helper middleware and other utils.
+- [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client) -
+ An isomorphic client to interact with the Software Catalog. Backend plugins
+ can use the package directly. Frontend plugins can use the client by using
+ `@backstage/plugin-catalog` in combination with `useApi` and the
+ `catalogApiRef`.
+
- [`catalog-model/`](https://github.com/backstage/backstage/tree/master/packages/catalog-model) -
You can consider this to be a library for working with the catalog of sorts.
It contains the definition of an
diff --git a/docs/support/support.md b/docs/support/support.md
index 797aca041f..49075366af 100644
--- a/docs/support/support.md
+++ b/docs/support/support.md
@@ -5,15 +5,16 @@ description: Support and Community Details and Links
---
- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the
- project
+ project.
- [Good First Issues](https://github.com/backstage/backstage/contribute) - Start
- here if you want to contribute
+ here if you want to contribute.
- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the
- technical direction
-- [FAQ](../FAQ.md) - Frequently Asked Questions
-- [Code of Conduct](../../CODE_OF_CONDUCT.md) - This is how we roll
-- [Blog](https://backstage.io/blog/) - Announcements and updates
+ technical direction by reviewing _Request for Comments_ issues.
+- [FAQ](../FAQ.md) - Frequently Asked Questions.
+- [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md) -
+ This is how we roll.
+- [Blog](https://backstage.io/blog/) - Announcements and updates.
- [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to
- our email newsletter
+ our email newsletter.
- Give us a star ⭐️ - If you are using Backstage or think it is an interesting
- project, we would love a star ❤️
+ project, we would love a star! ❤️
diff --git a/microsite/blog/2020-03-16-announcing-backstage.md b/microsite/blog/2020-03-16-announcing-backstage.md
index 3911debf08..5824b76a57 100644
--- a/microsite/blog/2020-03-16-announcing-backstage.md
+++ b/microsite/blog/2020-03-16-announcing-backstage.md
@@ -41,4 +41,4 @@ We are envisioning three phases of the project and we have already begun work on
- **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack.
-Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email me directly: [alund@spotify.com](mailto:alund@spotify.com).
+Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email us directly: [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com).
diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md
index 0a0ff9e0b1..f4f62e2cf7 100644
--- a/microsite/blog/2020-03-18-what-is-backstage.md
+++ b/microsite/blog/2020-03-18-what-is-backstage.md
@@ -85,4 +85,4 @@ We are envisioning [three phases](https://github.com/backstage/backstage/milesto
https://github.com/backstage/backstage/milestones
-Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [alund@spotify.com](mailto:alund@spotify.com).
+Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com).
diff --git a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md
index eeed43b713..e50d7a6d47 100644
--- a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md
+++ b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md
@@ -49,6 +49,6 @@ Alpha is our shorthand for "we don’t yet think Backstage is ready for producti
As with most alpha releases, you should expect things to change quite a lot until we reach the beta stage (we’re targeting the end of summer). There are obviously many things missing as well, but we wanted to start collecting feedback early and make it easier to see the end-to-end flow.
-If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏
+If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏
To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community).
diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md
index c216d7e398..bd6ae8eeeb 100644
--- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md
+++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md
@@ -73,6 +73,6 @@ Backstage ships with four example templates, but since these are likely not the
We have learned that one of the keys to getting these standards adopted is to keep an open process. Templates are code. By making it clear to your engineers that you are open to pull requests, and that teams with different needs can add their own templates, you are on the path of striking a good balance between autonomy and standardization.
-If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏
+If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏
To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community).
diff --git a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md
index fbf47a39c3..8fc459cb62 100644
--- a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md
+++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md
@@ -18,4 +18,4 @@ The Backstage community is healthy and growing quickly. Over [130 people](https:
We’re excited to embark on this journey with the CNCF community. There’s so much great tech being built here, and it’s about time we share it to build even greater products, together. Entering into the CNCF Sandbox is just the first step. We are committed to working with the community to bring Backstage through the Incubation step, and finally all the way to becoming a Graduated, top-level project.
-Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:alund@spotify.com) me directly.
+Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:backstage-interest@spotify.com) me directly.
diff --git a/microsite/data/plugins/api-docs.yaml b/microsite/data/plugins/api-docs.yaml
index 3343a46693..03ec818445 100644
--- a/microsite/data/plugins/api-docs.yaml
+++ b/microsite/data/plugins/api-docs.yaml
@@ -5,5 +5,5 @@ authorUrl: https://sda.se/
category: Discovery
description: Components to discover and display API entities as an extension to the catalog plugin.
documentation: https://github.com/backstage/backstage/blob/master/plugins/api-docs/README.md
-iconUrl: https://thecoders.io/wp-content/uploads/2019/11/tech-swagger.svg
+iconUrl: https://raw.githubusercontent.com/vscode-icons/vscode-icons/master/icons/file_type_swagger.svg
npmPackageName: '@backstage/plugin-api-docs'
diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml
index 7fa276f444..f325c0df8b 100644
--- a/microsite/data/plugins/aws-lambda.yaml
+++ b/microsite/data/plugins/aws-lambda.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io
category: Monitoring
description: View AWS Lambda functions for your components in Backstage.
documentation: https://roadie.io/backstage/plugins/aws-lambda
-iconUrl: https://roadie.io/static/77f62f79e27ae8565496e4df7eef8be5/45f2b/logo.png
+iconUrl: https://roadie.io/images/logos/lambda.png
npmPackageName: '@roadiehq/backstage-plugin-aws-lambda'
diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml
new file mode 100644
index 0000000000..eeb9440aac
--- /dev/null
+++ b/microsite/data/plugins/buildkite.yaml
@@ -0,0 +1,12 @@
+---
+title: Buildkite
+author: roadie.io
+authorUrl: https://roadie.io
+category: CI
+description: View Buildkite CI builds for your service in Backstage.
+documentation: https://roadie.io/backstage/plugins/buildkite
+iconUrl: https://roadie.io/images/logos/buildkite.png
+npmPackageName: '@roadiehq/backstage-plugin-buildkite'
+tags:
+ - ci
+ - cd
diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml
index 097ef00e50..8c1ea0be0a 100644
--- a/microsite/data/plugins/firebase-functions.yaml
+++ b/microsite/data/plugins/firebase-functions.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io/
category: Monitoring
description: View Firebase Functions details for your service in Backstage.
documentation: https://roadie.io/backstage/plugins/firebase-functions
-iconUrl: https://roadie.io/static/49fb23200ad0eaa6703b4ddf75c78cf1/45f2b/logo-vertical.png
+iconUrl: https://roadie.io/images/logos/firebase.png
npmPackageName: '@roadiehq/backstage-plugin-firebase-functions'
diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml
index 8ac8321da0..1dba3dcfd1 100644
--- a/microsite/data/plugins/github-insights.yaml
+++ b/microsite/data/plugins/github-insights.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io
category: Monitoring
description: View GitHub Insights for your components in Backstage.
documentation: https://roadie.io/backstage/plugins/github-insights
-iconUrl: https://roadie.io/static/2ad5123c425908efde0c922d707e737b/06c84/code-icon.png
+iconUrl: https://roadie.io/images/logos/insights.png
npmPackageName: '@roadiehq/backstage-plugin-github-insights'
diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml
index 6479452c8e..8e44a4bf56 100644
--- a/microsite/data/plugins/github-pull-requests.yaml
+++ b/microsite/data/plugins/github-pull-requests.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io/
category: CI
description: View GitHub pull requests for your service in Backstage.
documentation: https://roadie.io/backstage/plugins/github-pull-requests
-iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png
+iconUrl: https://roadie.io/images/logos/github.png
npmPackageName: '@roadiehq/backstage-plugin-github-pull-requests'
diff --git a/microsite/data/plugins/security-insights.yaml b/microsite/data/plugins/security-insights.yaml
index 1cbcfbc3aa..4f34f79027 100644
--- a/microsite/data/plugins/security-insights.yaml
+++ b/microsite/data/plugins/security-insights.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io/
category: Security
description: View Security Insights for your components in Backstage.
documentation: https://roadie.io/backstage/plugins/security-insights
-iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png
+iconUrl: https://roadie.io/images/logos/github.png
npmPackageName: '@roadiehq/backstage-plugin-security-insights'
diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml
index 520b884c20..fe1b18c9d4 100644
--- a/microsite/data/plugins/travis-ci.yaml
+++ b/microsite/data/plugins/travis-ci.yaml
@@ -5,5 +5,5 @@ authorUrl: https://roadie.io/
category: CI
description: View Travis CI builds for your service in Backstage.
documentation: https://roadie.io/backstage/plugins/travis-ci
-iconUrl: https://roadie.io/static/af2941eaf0af675facb281d566f42e14/45f2b/travis-ci-mascot-200x200.png
+iconUrl: https://roadie.io/images/logos/travis.png
npmPackageName: '@roadiehq/backstage-plugin-travis-ci'
diff --git a/microsite/package.json b/microsite/package.json
index a84364c214..3b5ab2eeb4 100644
--- a/microsite/package.json
+++ b/microsite/package.json
@@ -17,7 +17,7 @@
"@spotify/prettier-config": "^9.0.0",
"docusaurus": "^2.0.0-alpha.66",
"js-yaml": "^3.14.0",
- "prettier": "^2.0.5"
+ "prettier": "^2.2.0"
},
"prettier": "@spotify/prettier-config"
}
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index 64fdd2f420..e60d9c520e 100644
--- a/microsite/sidebars.json
+++ b/microsite/sidebars.json
@@ -7,6 +7,7 @@
"overview/vision",
"overview/background",
"overview/adopting",
+ "overview/stability-index",
"overview/logos"
],
"Getting Started": [
@@ -45,6 +46,7 @@
"features/software-catalog/descriptor-format",
"features/software-catalog/references",
"features/software-catalog/well-known-annotations",
+ "features/software-catalog/well-known-relations",
"features/software-catalog/extending-the-model",
"features/software-catalog/external-integrations",
"features/software-catalog/software-catalog-api"
diff --git a/microsite/static/schema/config-v1 b/microsite/static/schema/config-v1
new file mode 100644
index 0000000000..f85f2844cc
--- /dev/null
+++ b/microsite/static/schema/config-v1
@@ -0,0 +1,170 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://backstage.io/schema/config-v1",
+ "title": "Backstage Configuration Meta-Schema",
+ "definitions": {
+ "schemaArray": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "#" }
+ },
+ "nonNegativeInteger": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "nonNegativeIntegerDefault0": {
+ "allOf": [
+ { "$ref": "#/definitions/nonNegativeInteger" },
+ { "default": 0 }
+ ]
+ },
+ "simpleTypes": {
+ "enum": [
+ "array",
+ "boolean",
+ "integer",
+ "null",
+ "number",
+ "object",
+ "string"
+ ]
+ },
+ "stringArray": {
+ "type": "array",
+ "items": { "type": "string" },
+ "uniqueItems": true,
+ "default": []
+ }
+ },
+ "type": ["object", "boolean"],
+ "properties": {
+ "$id": {
+ "type": "string",
+ "format": "uri-reference"
+ },
+ "$schema": {
+ "type": "string",
+ "format": "uri"
+ },
+ "$ref": {
+ "type": "string",
+ "format": "uri-reference"
+ },
+ "$comment": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "default": true,
+ "readOnly": {
+ "type": "boolean",
+ "default": false
+ },
+ "writeOnly": {
+ "type": "boolean",
+ "default": false
+ },
+ "examples": {
+ "type": "array",
+ "items": true
+ },
+ "multipleOf": {
+ "type": "number",
+ "exclusiveMinimum": 0
+ },
+ "maximum": {
+ "type": "number"
+ },
+ "exclusiveMaximum": {
+ "type": "number"
+ },
+ "minimum": {
+ "type": "number"
+ },
+ "exclusiveMinimum": {
+ "type": "number"
+ },
+ "maxLength": { "$ref": "#/definitions/nonNegativeInteger" },
+ "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
+ "pattern": {
+ "type": "string",
+ "format": "regex"
+ },
+ "additionalItems": { "$ref": "#" },
+ "items": {
+ "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/schemaArray" }],
+ "default": true
+ },
+ "maxItems": { "$ref": "#/definitions/nonNegativeInteger" },
+ "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
+ "uniqueItems": {
+ "type": "boolean",
+ "default": false
+ },
+ "contains": { "$ref": "#" },
+ "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" },
+ "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" },
+ "required": { "$ref": "#/definitions/stringArray" },
+ "additionalProperties": { "$ref": "#" },
+ "definitions": {
+ "type": "object",
+ "additionalProperties": { "$ref": "#" },
+ "default": {}
+ },
+ "properties": {
+ "type": "object",
+ "additionalProperties": { "$ref": "#" },
+ "default": {}
+ },
+ "patternProperties": {
+ "type": "object",
+ "additionalProperties": { "$ref": "#" },
+ "propertyNames": { "format": "regex" },
+ "default": {}
+ },
+ "dependencies": {
+ "type": "object",
+ "additionalProperties": {
+ "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/stringArray" }]
+ }
+ },
+ "propertyNames": { "$ref": "#" },
+ "const": true,
+ "enum": {
+ "type": "array",
+ "items": true,
+ "minItems": 1,
+ "uniqueItems": true
+ },
+ "type": {
+ "anyOf": [
+ { "$ref": "#/definitions/simpleTypes" },
+ {
+ "type": "array",
+ "items": { "$ref": "#/definitions/simpleTypes" },
+ "minItems": 1,
+ "uniqueItems": true
+ }
+ ]
+ },
+ "format": { "type": "string" },
+ "contentMediaType": { "type": "string" },
+ "contentEncoding": { "type": "string" },
+ "if": { "$ref": "#" },
+ "then": { "$ref": "#" },
+ "else": { "$ref": "#" },
+ "allOf": { "$ref": "#/definitions/schemaArray" },
+ "anyOf": { "$ref": "#/definitions/schemaArray" },
+ "oneOf": { "$ref": "#/definitions/schemaArray" },
+ "not": { "$ref": "#" },
+ "visibility": {
+ "type": "string",
+ "enum": ["frontend", "backend", "secret"]
+ }
+ },
+ "default": true
+}
diff --git a/microsite/yarn.lock b/microsite/yarn.lock
index b120cb2d47..2147373bfe 100644
--- a/microsite/yarn.lock
+++ b/microsite/yarn.lock
@@ -5204,10 +5204,10 @@ prepend-http@^2.0.0:
resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
-prettier@^2.0.5:
- version "2.1.2"
- resolved "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5"
- integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==
+prettier@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.0.tgz#8a03c7777883b29b37fb2c4348c66a78e980418b"
+ integrity sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==
prismjs@^1.17.1:
version "1.21.0"
diff --git a/package.json b/package.json
index 505ee0caad..4140886bc3 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,9 @@
"plugins/*"
]
},
+ "resolutions": {
+ "**/@roadiehq/backstage-plugin-*/@backstage/core": "*"
+ },
"version": "1.0.0",
"devDependencies": {
"@changesets/cli": "^2.11.0",
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index 458f5a5e5c..ab00924a26 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,77 @@
# example-app
+## 0.2.2
+
+### Patch Changes
+
+- 3efd03c0e: Removed obsolete CircleCI proxy config from example-app
+- Updated dependencies [1722cb53c]
+- Updated dependencies [1722cb53c]
+- Updated dependencies [17a9f48f6]
+- Updated dependencies [4040d4fcb]
+- Updated dependencies [f360395d0]
+- Updated dependencies [259d848ee]
+- Updated dependencies [8b7737d0b]
+- Updated dependencies [902340451]
+ - @backstage/cli@0.3.0
+ - @backstage/core@0.3.1
+ - @backstage/plugin-cost-insights@0.4.0
+ - @backstage/plugin-lighthouse@0.2.2
+ - @backstage/plugin-rollbar@0.2.2
+ - @backstage/plugin-sentry@0.2.2
+ - @backstage/plugin-techdocs@0.2.2
+ - @backstage/plugin-user-settings@0.2.2
+ - @backstage/plugin-catalog@0.2.2
+ - @backstage/test-utils@0.1.3
+
+## 0.2.1
+
+### Patch Changes
+
+- Updated dependencies [7b37d65fd]
+- Updated dependencies [4aca74e08]
+- Updated dependencies [a41730c6e]
+- Updated dependencies [9a294574c]
+- Updated dependencies [0703edee0]
+- Updated dependencies [e8f69ba93]
+- Updated dependencies [0c0798f08]
+- Updated dependencies [5a2705de2]
+- Updated dependencies [0c0798f08]
+- Updated dependencies [84b654d5d]
+- Updated dependencies [0c0798f08]
+- Updated dependencies [199237d2f]
+- Updated dependencies [6627b626f]
+- Updated dependencies [3f05616bf]
+- Updated dependencies [803527bd3]
+- Updated dependencies [4577e377b]
+- Updated dependencies [2d0bd1be7]
+- Updated dependencies [59166e5ec]
+- Updated dependencies [a906f20e7]
+ - @backstage/core@0.3.0
+ - @backstage/plugin-jenkins@0.3.0
+ - @backstage/plugin-cost-insights@0.3.0
+ - @backstage/plugin-user-settings@0.2.1
+ - @backstage/plugin-api-docs@0.2.1
+ - @backstage/plugin-tech-radar@0.3.0
+ - @backstage/theme@0.2.1
+ - @backstage/plugin-catalog@0.2.1
+ - @backstage/plugin-scaffolder@0.3.0
+ - @backstage/plugin-circleci@0.2.1
+ - @backstage/plugin-cloudbuild@0.2.1
+ - @backstage/plugin-explore@0.2.1
+ - @backstage/plugin-gcp-projects@0.2.1
+ - @backstage/plugin-github-actions@0.2.1
+ - @backstage/plugin-gitops-profiles@0.2.1
+ - @backstage/plugin-graphiql@0.2.1
+ - @backstage/plugin-kubernetes@0.2.1
+ - @backstage/plugin-lighthouse@0.2.1
+ - @backstage/plugin-newrelic@0.2.1
+ - @backstage/plugin-register-component@0.2.1
+ - @backstage/plugin-rollbar@0.2.1
+ - @backstage/plugin-sentry@0.2.1
+ - @backstage/plugin-techdocs@0.2.1
+ - @backstage/plugin-welcome@0.2.1
+
## 0.2.0
### Minor Changes
diff --git a/packages/app/package.json b/packages/app/package.json
index 5cf74b0fbb..042fd47188 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,42 +1,44 @@
{
"name": "example-app",
- "version": "0.2.0",
+ "version": "0.2.2",
"private": true,
"bundled": true,
"dependencies": {
"@backstage/catalog-model": "^0.2.0",
- "@backstage/cli": "^0.2.0",
- "@backstage/core": "^0.2.0",
- "@backstage/plugin-api-docs": "^0.2.0",
- "@backstage/plugin-catalog": "^0.2.0",
- "@backstage/plugin-circleci": "^0.2.0",
- "@backstage/plugin-cloudbuild": "^0.2.0",
- "@backstage/plugin-cost-insights": "^0.2.0",
- "@backstage/plugin-explore": "^0.2.0",
- "@backstage/plugin-gcp-projects": "^0.2.0",
- "@backstage/plugin-github-actions": "^0.2.0",
- "@backstage/plugin-gitops-profiles": "^0.2.0",
- "@backstage/plugin-graphiql": "^0.2.0",
- "@backstage/plugin-jenkins": "^0.2.0",
- "@backstage/plugin-kubernetes": "^0.2.0",
- "@backstage/plugin-lighthouse": "^0.2.0",
- "@backstage/plugin-newrelic": "^0.2.0",
- "@backstage/plugin-register-component": "^0.2.0",
- "@backstage/plugin-rollbar": "^0.2.0",
- "@backstage/plugin-scaffolder": "^0.2.0",
- "@backstage/plugin-sentry": "^0.2.0",
- "@backstage/plugin-tech-radar": "^0.2.0",
- "@backstage/plugin-techdocs": "^0.2.0",
- "@backstage/plugin-user-settings": "^0.2.0",
- "@backstage/plugin-welcome": "^0.2.0",
- "@backstage/test-utils": "^0.1.2",
- "@backstage/theme": "^0.2.0",
+ "@backstage/cli": "^0.3.0",
+ "@backstage/core": "^0.3.1",
+ "@backstage/plugin-api-docs": "^0.2.1",
+ "@backstage/plugin-catalog": "^0.2.2",
+ "@backstage/plugin-circleci": "^0.2.1",
+ "@backstage/plugin-cloudbuild": "^0.2.1",
+ "@backstage/plugin-cost-insights": "^0.4.0",
+ "@backstage/plugin-explore": "^0.2.1",
+ "@backstage/plugin-gcp-projects": "^0.2.1",
+ "@backstage/plugin-github-actions": "^0.2.1",
+ "@backstage/plugin-gitops-profiles": "^0.2.1",
+ "@backstage/plugin-graphiql": "^0.2.1",
+ "@backstage/plugin-jenkins": "^0.3.0",
+ "@backstage/plugin-kubernetes": "^0.2.1",
+ "@backstage/plugin-lighthouse": "^0.2.2",
+ "@backstage/plugin-newrelic": "^0.2.1",
+ "@backstage/plugin-register-component": "^0.2.1",
+ "@backstage/plugin-rollbar": "^0.2.2",
+ "@backstage/plugin-scaffolder": "^0.3.0",
+ "@backstage/plugin-sentry": "^0.2.2",
+ "@backstage/plugin-search": "^0.2.0",
+ "@backstage/plugin-tech-radar": "^0.3.0",
+ "@backstage/plugin-techdocs": "^0.2.2",
+ "@backstage/plugin-user-settings": "^0.2.2",
+ "@backstage/plugin-welcome": "^0.2.1",
+ "@backstage/test-utils": "^0.1.3",
+ "@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.0.0",
- "@roadiehq/backstage-plugin-github-insights": "^0.2.12",
- "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.1",
- "@roadiehq/backstage-plugin-travis-ci": "^0.2.7",
+ "@roadiehq/backstage-plugin-github-insights": "^0.2.15",
+ "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3",
+ "@roadiehq/backstage-plugin-travis-ci": "^0.2.8",
+ "@roadiehq/backstage-plugin-buildkite": "^0.1.3",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
@@ -48,7 +50,7 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@testing-library/cypress": "^6.0.0",
+ "@testing-library/cypress": "^7.0.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
@@ -85,14 +87,5 @@
"last 1 safari version"
]
},
- "license": "Apache-2.0",
- "proxy": {
- "/circleci/api": {
- "target": "https://circleci.com/api/v1.1",
- "changeOrigin": true,
- "pathRewrite": {
- "^/circleci/api/": "/"
- }
- }
- }
+ "license": "Apache-2.0"
}
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index e947e5c91d..47e7d32d1a 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -33,12 +33,12 @@ import {
SidebarContext,
SidebarItem,
SidebarDivider,
- SidebarSearchField,
SidebarSpace,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
import { graphiQLRouteRef } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
+import { SidebarSearch } from '@backstage/plugin-search';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -73,17 +73,11 @@ const SidebarLogo: FC<{}> = () => {
);
};
-const handleSearch = (query: string): void => {
- // XXX (@koroeskohr): for testing purposes
- // eslint-disable-next-line no-console
- console.log(query);
-};
-
const Root: FC<{}> = ({ children }) => (
-
+
{/* Global nav, not org-specific */}
diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx
new file mode 100644
index 0000000000..1392f77a6d
--- /dev/null
+++ b/packages/app/src/components/catalog/EntityPage.test.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { CICDSwitcher } from './EntityPage';
+import { UrlPatternDiscovery, ApiProvider, ApiRegistry } from '@backstage/core';
+import {
+ buildKiteApiRef,
+ BuildkiteApi,
+} from '@roadiehq/backstage-plugin-buildkite';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+
+describe('EntityPage Test', () => {
+ const entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'ExampleComponent',
+ annotations: {
+ 'buildkite.com/project-slug': 'exampleProject/examplePipeline',
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'service',
+ lifecycle: 'production',
+ },
+ };
+
+ const discoveryApi = UrlPatternDiscovery.compile('http://exampleapi.com');
+
+ const apis = ApiRegistry.from([
+ [buildKiteApiRef, new BuildkiteApi({ discoveryApi })],
+ ]);
+
+ describe('CICDSwitcher Test', () => {
+ it('Should render Buildkite View', async () => {
+ const renderedComponent = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+ expect(
+ renderedComponent.getByText(/exampleProject\/examplePipeline/),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 0a3007237e..d826bfa25e 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -66,13 +66,19 @@ import {
isPluginApplicableToEntity as isPullRequestsAvailable,
PullRequestsStatsCard,
} from '@roadiehq/backstage-plugin-github-pull-requests';
+import {
+ Router as BuildkiteRouter,
+ isPluginApplicableToEntity as isBuildkiteAvailable,
+} from '@roadiehq/backstage-plugin-buildkite';
-const CICDSwitcher = ({ entity }: { entity: Entity }) => {
+export const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
// You can for example enforce that all components of type 'service' should use GitHubActions
switch (true) {
case isJenkinsAvailable(entity):
return ;
+ case isBuildkiteAvailable(entity):
+ return ;
case isGitHubActionsAvailable(entity):
return ;
case isCircleCIAvailable(entity):
diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts
index 06ff6b07d3..01828b1405 100644
--- a/packages/app/src/identityProviders.ts
+++ b/packages/app/src/identityProviders.ts
@@ -22,9 +22,16 @@ import {
samlAuthApiRef,
microsoftAuthApiRef,
oneloginAuthApiRef,
+ oidcAuthApiRef,
} from '@backstage/core';
export const providers = [
+ {
+ id: 'oidc-auth-provider',
+ title: 'Oidc',
+ message: 'Sign In using OpenId Connect',
+ apiRef: oidcAuthApiRef,
+ },
{
id: 'google-auth-provider',
title: 'Google',
diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts
index bdc3851318..87b4c0ceea 100644
--- a/packages/app/src/plugins.ts
+++ b/packages/app/src/plugins.ts
@@ -38,3 +38,5 @@ export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild';
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights';
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
+export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
+export { plugin as Search } from '@backstage/plugin-search';
diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md
index 2b8b38b9b0..4822032c7f 100644
--- a/packages/backend-common/CHANGELOG.md
+++ b/packages/backend-common/CHANGELOG.md
@@ -1,5 +1,49 @@
# @backstage/backend-common
+## 0.3.0
+
+### Minor Changes
+
+- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas.
+
+ The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project.
+
+ A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging.
+
+ Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format.
+
+ TypeScript configuration schema files should export a single `Config` type, for example:
+
+ ```ts
+ export interface Config {
+ app: {
+ /**
+ * Frontend root URL
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+ }
+ ```
+
+- 8e2effb53: Refactored UrlReader.readTree to be required and accept (url, options)
+
+### Patch Changes
+
+- 1722cb53c: Added configuration schema
+- 7b37e6834: Added the integration package
+- Updated dependencies [1722cb53c]
+- Updated dependencies [7b37e6834]
+ - @backstage/config-loader@0.3.0
+ - @backstage/integration@0.1.1
+ - @backstage/test-utils@0.1.3
+
+## 0.2.1
+
+### Patch Changes
+
+- 33b7300eb: Capture plugin name under the /api/ prefix for http metrics
+
## 0.2.0
### Minor Changes
diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts
new file mode 100644
index 0000000000..7a134ac506
--- /dev/null
+++ b/packages/backend-common/config.d.ts
@@ -0,0 +1,169 @@
+/*
+ * 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 interface Config {
+ app: {
+ baseUrl: string; // defined in core, but repeated here without doc
+ };
+
+ backend: {
+ baseUrl: string; // defined in core, but repeated here without doc
+
+ /** Address that the backend should listen to. */
+ listen:
+ | string
+ | {
+ /** Address of the interface that the backend should bind to. */
+ address?: string;
+ /** Port that the backend should listen to. */
+ port?: number;
+ };
+
+ /** HTTPS configuration for the backend. If omitted the backend will serve HTTP */
+ https?: {
+ /** Certificate configuration or parameters for generating a self-signed certificate */
+ certificate?:
+ | {
+ /** Algorithm to use to generate a self-signed certificate */
+ algorithm: string;
+ keySize?: number;
+ days?: number;
+ }
+ | {
+ /** PEM encoded certificate. Use $file to load in a file */
+ cert: string;
+ /**
+ * PEM encoded certificate key. Use $file to load in a file.
+ * @visibility secret
+ */
+ key: string;
+ };
+ };
+
+ /** Database connection configuration, select database type using the `client` field */
+ database:
+ | {
+ client: 'sqlite3';
+ connection: ':memory:' | string;
+ }
+ | {
+ client: 'pg';
+ /**
+ * PostgreSQL connection string or knex configuration object.
+ * @secret
+ */
+ connection: string | object;
+ };
+
+ cors?: {
+ origin?: string | string[];
+ methods?: string | string[];
+ allowedHeaders?: string | string[];
+ exposedHeaders?: string | string[];
+ credentials?: boolean;
+ maxAge?: number;
+ preflightContinue?: boolean;
+ optionsSuccessStatus?: number;
+ };
+
+ /** */
+ csp?: object;
+ };
+
+ /** Configuration for integrations towards various external repository provider systems */
+ integrations?: {
+ /** Integration configuration for Azure */
+ azure?: Array<{
+ /**
+ * The hostname of the given Azure instance
+ * @visibility frontend
+ */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ }>;
+
+ /** Integration configuration for BitBucket */
+ bitbucket?: Array<{
+ /**
+ * The hostname of the given Bitbucket instance
+ * @visibility frontend
+ */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ /**
+ * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0
+ * @visibility frontend
+ */
+ apiBaseUrl?: string;
+ /**
+ * The username to use for authenticated requests.
+ * @visibility secret
+ */
+ username?: string;
+ /**
+ * BitBucket app password used to authenticate requests.
+ * @visibility secret
+ */
+ appPassword?: string;
+ }>;
+
+ /** Integration configuration for GitHub */
+ github?: Array<{
+ /**
+ * The hostname of the given GitHub instance
+ * @visibility frontend
+ */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ /**
+ * The base url for the GitHub API, for example https://api.github.com
+ * @visibility frontend
+ */
+ apiBaseUrl?: string;
+ /**
+ * The base url for GitHub raw resources, for example https://raw.githubusercontent.com
+ * @visibility frontend
+ */
+ rawBaseUrl?: string;
+ }>;
+
+ /** Integration configuration for GitLab */
+ gitlab?: Array<{
+ /**
+ * The hostname of the given GitLab instance
+ * @visibility frontend
+ */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ }>;
+ };
+}
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index c004eaac28..b22e4638f5 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
- "version": "0.2.0",
+ "version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -31,16 +31,19 @@
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.1",
- "@backstage/config-loader": "^0.2.0",
- "@backstage/test-utils": "^0.1.2",
+ "@backstage/config-loader": "^0.3.0",
+ "@backstage/integration": "^0.1.1",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
+ "archiver": "^5.0.2",
"compression": "^1.7.4",
+ "concat-stream": "^2.0.0",
"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",
+ "fs-extra": "^9.0.1",
"git-url-parse": "^11.4.0",
"helmet": "^4.0.0",
"knex": "^0.21.6",
@@ -51,6 +54,8 @@
"prom-client": "^12.0.0",
"selfsigned": "^1.10.7",
"stoppable": "^1.1.0",
+ "tar": "^6.0.5",
+ "unzipper": "^0.10.11",
"winston": "^3.2.1"
},
"peerDependencies": {
@@ -62,22 +67,34 @@
}
},
"devDependencies": {
- "@backstage/cli": "^0.2.0",
+ "@backstage/cli": "^0.3.0",
+ "@backstage/test-utils": "^0.1.3",
+ "@types/archiver": "^3.1.1",
"@types/compression": "^1.7.0",
+ "@types/concat-stream": "^1.6.0",
+ "@types/fs-extra": "^9.0.3",
"@types/http-errors": "^1.6.3",
"@types/minimist": "^1.2.0",
+ "@types/mock-fs": "^4.13.0",
"@types/morgan": "^1.9.0",
+ "@types/recursive-readdir": "^2.2.0",
"@types/stoppable": "^1.1.0",
"@types/supertest": "^2.0.8",
+ "@types/tar": "^4.0.3",
+ "@types/unzipper": "^0.10.3",
"@types/webpack-env": "^1.15.2",
"@types/yaml": "^1.9.7",
"get-port": "^5.1.1",
"http-errors": "^1.7.3",
"jest": "^26.0.1",
+ "mock-fs": "^4.13.0",
"msw": "^0.21.2",
+ "recursive-readdir": "^2.2.2",
"supertest": "^4.0.2"
},
"files": [
- "dist"
- ]
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
}
diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts
index 69e46bd5b4..86beb65805 100644
--- a/packages/backend-common/src/config.ts
+++ b/packages/backend-common/src/config.ts
@@ -40,7 +40,6 @@ export async function loadBackendConfig(options: Options): Promise {
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
configRoot: paths.targetRoot,
configPaths: configOpts.map(opt => resolvePath(opt)),
- shouldReadSecrets: true,
});
options.logger.info(
diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts
index 24b435006a..ab97d1b073 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.test.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts
@@ -14,109 +14,185 @@
* limitations under the License.
*/
+import fs from 'fs';
+import path from 'path';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
-import { AzureUrlReader } from './AzureUrlReader';
+import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader';
import { msw } from '@backstage/test-utils';
+import { ReadTreeResponseFactory } from './tree';
const logger = getVoidLogger();
+const treeResponseFactory = ReadTreeResponseFactory.create({
+ config: new ConfigReader({}),
+});
+
describe('AzureUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
- beforeEach(() => {
- worker.use(
- rest.get('*', (req, res, ctx) =>
- res(
- ctx.status(200),
- ctx.json({
- url: req.url.toString(),
- headers: req.headers.getAllHeaders(),
- }),
+ describe('read', () => {
+ beforeEach(() => {
+ worker.use(
+ rest.get('*', (req, res, ctx) =>
+ res(
+ ctx.status(200),
+ ctx.json({
+ url: req.url.toString(),
+ headers: req.headers.getAllHeaders(),
+ }),
+ ),
),
- ),
- );
- });
+ );
+ });
- const createConfig = (token?: string) =>
- new ConfigReader(
+ const createConfig = (token?: string) =>
+ new ConfigReader(
+ {
+ integrations: { azure: [{ host: 'dev.azure.com', token }] },
+ },
+ 'test-config',
+ );
+
+ it.each([
{
- integrations: { azure: [{ host: 'dev.azure.com', token }] },
+ url:
+ 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
+ config: createConfig(),
+ response: expect.objectContaining({
+ url:
+ 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
+ }),
},
- 'test-config',
- );
-
- it.each([
- {
- url:
- 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
- config: createConfig(),
- response: expect.objectContaining({
+ {
url:
- 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
- }),
- },
- {
- url:
- 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
- config: createConfig(),
- response: expect.objectContaining({
- url:
- 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
- }),
- },
- {
- url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
- config: createConfig('0123456789'),
- response: expect.objectContaining({
- headers: expect.objectContaining({
- authorization: 'Basic OjAxMjM0NTY3ODk=',
+ 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
+ config: createConfig(),
+ response: expect.objectContaining({
+ url:
+ 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
}),
- }),
- },
- {
- url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
- config: createConfig(undefined),
- response: expect.objectContaining({
- headers: expect.not.objectContaining({
- authorization: expect.anything(),
+ },
+ {
+ url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
+ config: createConfig('0123456789'),
+ response: expect.objectContaining({
+ headers: expect.objectContaining({
+ authorization: 'Basic OjAxMjM0NTY3ODk=',
+ }),
}),
- }),
- },
- ])('should handle happy path %#', async ({ url, config, response }) => {
- const [{ reader }] = AzureUrlReader.factory({ config, logger });
+ },
+ {
+ url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
+ config: createConfig(undefined),
+ response: expect.objectContaining({
+ headers: expect.not.objectContaining({
+ authorization: expect.anything(),
+ }),
+ }),
+ },
+ ])('should handle happy path %#', async ({ url, config, response }) => {
+ const [{ reader }] = AzureUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
- const data = await reader.read(url);
- const res = await JSON.parse(data.toString('utf-8'));
- expect(res).toEqual(response);
+ const data = await reader.read(url);
+ const res = await JSON.parse(data.toString('utf-8'));
+ expect(res).toEqual(response);
+ });
+
+ it.each([
+ {
+ url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
+ config: createConfig(),
+ error:
+ 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
+ },
+ {
+ url: 'com/a/b/blob/master/path/to/c.yaml',
+ config: createConfig(),
+ error:
+ 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
+ },
+ {
+ url: '',
+ config: createConfig(''),
+ error:
+ "Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
+ },
+ ])('should handle error path %#', async ({ url, config, error }) => {
+ await expect(async () => {
+ const [{ reader }] = AzureUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
+ await reader.read(url);
+ }).rejects.toThrow(error);
+ });
});
- it.each([
- {
- url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
- config: createConfig(),
- error:
- 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
- },
- {
- url: 'com/a/b/blob/master/path/to/c.yaml',
- config: createConfig(),
- error:
- 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
- },
- {
- url: '',
- config: createConfig(''),
- error:
- "Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
- },
- ])('should handle error path %#', async ({ url, config, error }) => {
- await expect(async () => {
- const [{ reader }] = AzureUrlReader.factory({ config, logger });
- await reader.read(url);
- }).rejects.toThrow(error);
+ describe('readTree', () => {
+ const repoBuffer = fs.readFileSync(
+ path.resolve('src', 'reading', '__fixtures__', 'repo.zip'),
+ );
+
+ beforeEach(() => {
+ worker.use(
+ rest.get(
+ 'https://dev.azure.com/organization/project/_apis/git/repositories/repository/items',
+ (_, res, ctx) =>
+ res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/zip'),
+ ctx.body(repoBuffer),
+ ),
+ ),
+ );
+ });
+
+ it('returns the wanted files from an archive', async () => {
+ const processor = new AzureUrlReader(
+ {
+ host: 'dev.azure.com',
+ },
+ { treeResponseFactory },
+ );
+
+ const response = await processor.readTree(
+ 'https://dev.azure.com/organization/project/_git/repository',
+ );
+
+ const files = await response.files();
+
+ expect(files.length).toBe(2);
+ const mkDocsFile = await files[1].content();
+ const indexMarkdownFile = await files[0].content();
+
+ expect(mkDocsFile.toString()).toBe('site_name: Test\n');
+ expect(indexMarkdownFile.toString()).toBe('# Test\n');
+ });
+ });
+
+ describe('getDownloadUrl', () => {
+ it('do not add scopePath if no path is specified', async () => {
+ const result = getDownloadUrl(
+ 'https://dev.azure.com/organization/project/_git/repository',
+ );
+
+ expect(result.searchParams.get('scopePath')).toBeNull();
+ });
+
+ it('add scopePath if a path is specified', async () => {
+ const result = getDownloadUrl(
+ 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs',
+ );
+ expect(result.searchParams.get('scopePath')).toEqual('docs');
+ });
});
});
diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts
index 8e6e6a75df..ad990d1d5d 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.ts
@@ -14,49 +14,60 @@
* limitations under the License.
*/
+import {
+ AzureIntegrationConfig,
+ readAzureIntegrationConfigs,
+} from '@backstage/integration';
import fetch from 'cross-fetch';
-import { Config } from '@backstage/config';
+import { Readable } from 'stream';
+import parseGitUri from 'git-url-parse';
import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
+import {
+ ReaderFactory,
+ ReadTreeOptions,
+ ReadTreeResponse,
+ UrlReader,
+} from './types';
+import { ReadTreeResponseFactory } from './tree';
-type Options = {
- // TODO: added here for future support, but we only allow dev.azure.com for now
- host: string;
- token?: string;
-};
+export function getDownloadUrl(url: string): URL {
+ const {
+ name: repoName,
+ owner: project,
+ organization,
+ protocol,
+ resource,
+ filepath,
+ } = parseGitUri(url);
-function readConfig(config: Config): Options[] {
- const optionsArr = Array();
+ // scopePath will limit the downloaded content
+ // /docs will only download the docs folder and everything below it
+ // /docs/index.md will only download index.md but put it in the root of the archive
+ const scopePath = filepath
+ ? `&scopePath=${encodeURIComponent(filepath)}`
+ : '';
- const providerConfigs =
- config.getOptionalConfigArray('integrations.azure') ?? [];
-
- for (const providerConfig of providerConfigs) {
- const host = providerConfig.getOptionalString('host') ?? 'dev.azure.com';
- const token = providerConfig.getOptionalString('token');
-
- optionsArr.push({ host, token });
- }
-
- // As a convenience we always make sure there's at least an unauthenticated
- // reader for public azure repos.
- if (!optionsArr.some(p => p.host === 'dev.azure.com')) {
- optionsArr.push({ host: 'dev.azure.com' });
- }
-
- return optionsArr;
+ return new URL(
+ `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`,
+ );
}
export class AzureUrlReader implements UrlReader {
- static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(options => {
- const reader = new AzureUrlReader(options);
+ static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
+ const configs = readAzureIntegrationConfigs(
+ config.getOptionalConfigArray('integrations.azure') ?? [],
+ );
+ return configs.map(options => {
+ const reader = new AzureUrlReader(options, { treeResponseFactory });
const predicate = (url: URL) => url.host === options.host;
return { reader, predicate };
});
};
- constructor(private readonly options: Options) {
+ constructor(
+ private readonly options: AzureIntegrationConfig,
+ private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
+ ) {
if (options.host !== 'dev.azure.com') {
throw Error(
`Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`,
@@ -86,6 +97,28 @@ export class AzureUrlReader implements UrlReader {
throw new Error(message);
}
+ async readTree(
+ url: string,
+ options?: ReadTreeOptions,
+ ): Promise {
+ const response = await fetch(
+ getDownloadUrl(url).toString(),
+ this.getRequestOptions({ Accept: 'application/zip' }),
+ );
+ if (!response.ok) {
+ const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
+ if (response.status === 404) {
+ throw new NotFoundError(message);
+ }
+ throw new Error(message);
+ }
+
+ return this.deps.treeResponseFactory.fromZipArchive({
+ stream: (response.body as unknown) as Readable,
+ filter: options?.filter,
+ });
+ }
+
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch}
@@ -145,8 +178,10 @@ export class AzureUrlReader implements UrlReader {
}
}
- private getRequestOptions(): RequestInit {
- const headers: HeadersInit = {};
+ private getRequestOptions(additionalHeaders?: {
+ [key: string]: string;
+ }): RequestInit {
+ const headers: HeadersInit = additionalHeaders ?? {};
if (this.options.token) {
headers.Authorization = `Basic ${Buffer.from(
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index 210ee16873..01744db28a 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -14,24 +14,22 @@
* limitations under the License.
*/
-import { ConfigReader } from '@backstage/config';
+import { BitbucketIntegrationConfig } from '@backstage/integration';
import {
BitbucketUrlReader,
getApiRequestOptions,
getApiUrl,
- ProviderConfig,
- readConfig,
} from './BitbucketUrlReader';
describe('BitbucketUrlReader', () => {
describe('getApiRequestOptions', () => {
it('inserts a token when needed', () => {
- const withToken: ProviderConfig = {
+ const withToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
- const withoutToken: ProviderConfig = {
+ const withoutToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
@@ -44,13 +42,13 @@ describe('BitbucketUrlReader', () => {
});
it('insert basic auth when needed', () => {
- const withUsernameAndPassword: ProviderConfig = {
+ const withUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
- const withoutUsernameAndPassword: ProviderConfig = {
+ const withoutUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
@@ -67,11 +65,11 @@ describe('BitbucketUrlReader', () => {
describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
- const config: ProviderConfig = { host: '', apiBaseUrl: '' };
+ const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for Bitbucket Cloud', () => {
- const config: ProviderConfig = {
+ const config: BitbucketIntegrationConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
@@ -87,7 +85,7 @@ describe('BitbucketUrlReader', () => {
);
});
it('happy path for Bitbucket Server', () => {
- const config: ProviderConfig = {
+ const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
};
@@ -104,66 +102,6 @@ describe('BitbucketUrlReader', () => {
});
});
- describe('readConfig', () => {
- function config(
- providers: {
- host: string;
- apiBaseUrl?: string;
- token?: string;
- username?: string;
- password?: string;
- }[],
- ) {
- return ConfigReader.fromConfigs([
- {
- context: '',
- data: {
- integrations: { bitbucket: providers },
- },
- },
- ]);
- }
-
- it('adds a default Bitbucket Cloud entry when missing', () => {
- const output = readConfig(config([]));
- expect(output).toEqual([
- {
- host: 'bitbucket.org',
- apiBaseUrl: 'https://api.bitbucket.org/2.0',
- },
- ]);
- });
-
- it('injects the correct Bitbucket Cloud API base URL when missing', () => {
- const output = readConfig(config([{ host: 'bitbucket.org' }]));
- expect(output).toEqual([
- {
- host: 'bitbucket.org',
- apiBaseUrl: 'https://api.bitbucket.org/2.0',
- },
- ]);
- });
-
- it('rejects custom targets with no base URLs', () => {
- expect(() =>
- readConfig(config([{ host: 'bitbucket.mycompany.net' }])),
- ).toThrow(
- "Bitbucket integration for 'bitbucket.mycompany.net' must configure an explicit apiBaseUrl",
- );
- });
-
- it('rejects funky configs', () => {
- expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
- expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
- expect(() =>
- readConfig(config([{ host: 'bitbucket.org', apiBaseUrl: 7 } as any])),
- ).toThrow(/apiBaseUrl/);
- expect(() =>
- readConfig(config([{ host: 'bitbucket.org', token: 7 } as any])),
- ).toThrow(/token/);
- });
- });
-
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new BitbucketUrlReader({
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts
index 348426fd34..9694c1d987 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts
@@ -14,57 +14,18 @@
* limitations under the License.
*/
-import { Config } from '@backstage/config';
-import parseGitUri from 'git-url-parse';
+import {
+ BitbucketIntegrationConfig,
+ readBitbucketIntegrationConfigs,
+} from '@backstage/integration';
import fetch from 'cross-fetch';
+import parseGitUri from 'git-url-parse';
import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
+import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
-const DEFAULT_BASE_URL = 'https://api.bitbucket.org/2.0';
-
-/**
- * The configuration parameters for a single Bitbucket API provider.
- */
-export type ProviderConfig = {
- /**
- * The host of the target that this matches on, e.g. "bitbucket.com"
- */
- host: string;
-
- /**
- * The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
- * with no trailing slash.
- *
- * May be omitted specifically for Bitbucket Cloud; then it will be deduced.
- *
- * The API will always be preferred if both its base URL and a token are
- * present.
- */
- apiBaseUrl?: string;
-
- /**
- * The authorization token to use for requests to a Bitbucket Server provider.
- *
- * See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html
- *
- * If no token is specified, anonymous access is used.
- */
- token?: string;
-
- /**
- * The username to use for requests to Bitbucket Cloud (bitbucket.org).
- */
- username?: string;
-
- /**
- * Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords.
- *
- * See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
- */
- appPassword?: string;
-};
-
-export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
+export function getApiRequestOptions(
+ provider: BitbucketIntegrationConfig,
+): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
@@ -84,7 +45,10 @@ export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
// Converts for example
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
-export function getApiUrl(target: string, provider: ProviderConfig): URL {
+export function getApiUrl(
+ target: string,
+ provider: BitbucketIntegrationConfig,
+): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
@@ -115,74 +79,39 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL {
}
}
-export function readConfig(config: Config): ProviderConfig[] {
- const providers: ProviderConfig[] = [];
-
- const providerConfigs =
- config.getOptionalConfigArray('integrations.bitbucket') ?? [];
-
- // First read all the explicit providers
- for (const providerConfig of providerConfigs) {
- const host = providerConfig.getOptionalString('host') ?? 'bitbucket.org';
- let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
- const token = providerConfig.getOptionalString('token');
- const username = providerConfig.getOptionalString('username');
- const appPassword = providerConfig.getOptionalString('appPassword');
-
- if (apiBaseUrl) {
- apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
- } else if (host === 'bitbucket.org') {
- apiBaseUrl = DEFAULT_BASE_URL;
- }
-
- if (!apiBaseUrl) {
- throw new Error(
- `Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
- );
- }
- if (!token && username && !appPassword) {
- throw new Error(
- `Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
- );
- }
-
- providers.push({
- host,
- apiBaseUrl,
- token,
- username,
- appPassword,
- });
- }
-
- // If no explicit bitbucket.org provider was added, put one in the list as
- // a convenience
- if (!providers.some(p => p.host === 'bitbucket.org')) {
- providers.push({
- host: 'bitbucket.org',
- apiBaseUrl: DEFAULT_BASE_URL,
- });
- }
-
- return providers;
-}
-
/**
* A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as
* the one exposed by Bitbucket Cloud itself.
*/
export class BitbucketUrlReader implements UrlReader {
- private config: ProviderConfig;
+ private readonly config: BitbucketIntegrationConfig;
static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(provider => {
+ const configs = readBitbucketIntegrationConfigs(
+ config.getOptionalConfigArray('integrations.bitbucket') ?? [],
+ );
+ return configs.map(provider => {
const reader = new BitbucketUrlReader(provider);
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
};
- constructor(config: ProviderConfig) {
+ constructor(config: BitbucketIntegrationConfig) {
+ const { host, apiBaseUrl, token, username, appPassword } = config;
+
+ if (!apiBaseUrl) {
+ throw new Error(
+ `Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
+ );
+ }
+
+ if (!token && username && !appPassword) {
+ throw new Error(
+ `Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
+ );
+ }
+
this.config = config;
}
@@ -209,6 +138,10 @@ export class BitbucketUrlReader implements UrlReader {
throw new Error(message);
}
+ readTree(): Promise {
+ throw new Error('BitbucketUrlReader does not implement readTree');
+ }
+
toString() {
const { host, token, username, appPassword } = this.config;
let authed = Boolean(token);
diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts
index d2e5c45620..1d1784590c 100644
--- a/packages/backend-common/src/reading/FetchUrlReader.ts
+++ b/packages/backend-common/src/reading/FetchUrlReader.ts
@@ -16,7 +16,7 @@
import fetch from 'cross-fetch';
import { NotFoundError } from '../errors';
-import { UrlReader } from './types';
+import { ReadTreeResponse, UrlReader } from './types';
/**
* A UrlReader that does a plain fetch of the URL.
@@ -41,6 +41,10 @@ export class FetchUrlReader implements UrlReader {
throw new Error(message);
}
+ readTree(): Promise {
+ throw new Error('FetchUrlReader does not implement readTree');
+ }
+
toString() {
return 'fetch{}';
}
diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts
index 8df464db9f..eb87d339ea 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts
@@ -15,32 +15,41 @@
*/
import { ConfigReader } from '@backstage/config';
+import { GitHubIntegrationConfig } from '@backstage/integration';
+import { msw } from '@backstage/test-utils';
+import fs from 'fs';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import path from 'path';
import {
getApiRequestOptions,
getApiUrl,
getRawRequestOptions,
getRawUrl,
GithubUrlReader,
- ProviderConfig,
- readConfig,
} from './GithubUrlReader';
+import { ReadTreeResponseFactory } from './tree';
+
+const treeResponseFactory = ReadTreeResponseFactory.create({
+ config: new ConfigReader({}),
+});
describe('GithubUrlReader', () => {
describe('getApiRequestOptions', () => {
it('sets the correct API version', () => {
- const config: ProviderConfig = { host: '', apiBaseUrl: '' };
+ const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect((getApiRequestOptions(config).headers as any).Accept).toEqual(
'application/vnd.github.v3.raw',
);
});
it('inserts a token when needed', () => {
- const withToken: ProviderConfig = {
+ const withToken: GitHubIntegrationConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
- const withoutToken: ProviderConfig = {
+ const withoutToken: GitHubIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
@@ -55,12 +64,12 @@ describe('GithubUrlReader', () => {
describe('getRawRequestOptions', () => {
it('inserts a token when needed', () => {
- const withToken: ProviderConfig = {
+ const withToken: GitHubIntegrationConfig = {
host: '',
rawBaseUrl: '',
token: 'A',
};
- const withoutToken: ProviderConfig = {
+ const withoutToken: GitHubIntegrationConfig = {
host: '',
rawBaseUrl: '',
};
@@ -75,12 +84,12 @@ describe('GithubUrlReader', () => {
describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
- const config: ProviderConfig = { host: '', apiBaseUrl: '' };
+ const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for github', () => {
- const config: ProviderConfig = {
+ const config: GitHubIntegrationConfig = {
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
};
@@ -107,7 +116,7 @@ describe('GithubUrlReader', () => {
});
it('happy path for ghe', () => {
- const config: ProviderConfig = {
+ const config: GitHubIntegrationConfig = {
host: 'ghe.mycompany.net',
apiBaseUrl: 'https://ghe.mycompany.net/api/v3',
};
@@ -126,12 +135,12 @@ describe('GithubUrlReader', () => {
describe('getRawUrl', () => {
it('rejects targets that do not look like URLs', () => {
- const config: ProviderConfig = { host: '', apiBaseUrl: '' };
+ const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for github', () => {
- const config: ProviderConfig = {
+ const config: GitHubIntegrationConfig = {
host: 'github.com',
rawBaseUrl: 'https://raw.githubusercontent.com',
};
@@ -148,7 +157,7 @@ describe('GithubUrlReader', () => {
});
it('happy path for ghe', () => {
- const config: ProviderConfig = {
+ const config: GitHubIntegrationConfig = {
host: 'ghe.mycompany.net',
rawBaseUrl: 'https://ghe.mycompany.net/raw',
};
@@ -163,66 +172,15 @@ describe('GithubUrlReader', () => {
});
});
- describe('readConfig', () => {
- function config(
- providers: { host: string; apiBaseUrl?: string; token?: string }[],
- ) {
- return ConfigReader.fromConfigs([
- {
- context: '',
- data: {
- integrations: { github: providers },
- },
- },
- ]);
- }
-
- it('adds a default GitHub entry when missing', () => {
- const output = readConfig(config([]));
- expect(output).toEqual([
- {
- host: 'github.com',
- apiBaseUrl: 'https://api.github.com',
- rawBaseUrl: 'https://raw.githubusercontent.com',
- },
- ]);
- });
-
- it('injects the correct GitHub API base URL when missing', () => {
- const output = readConfig(config([{ host: 'github.com' }]));
- expect(output).toEqual([
- {
- host: 'github.com',
- apiBaseUrl: 'https://api.github.com',
- rawBaseUrl: 'https://raw.githubusercontent.com',
- },
- ]);
- });
-
- it('rejects custom targets with no base URLs', () => {
- expect(() => readConfig(config([{ host: 'ghe.company.com' }]))).toThrow(
- "GitHub integration for 'ghe.company.com' must configure an explicit apiBaseUrl and rawBaseUrl",
- );
- });
-
- it('rejects funky configs', () => {
- expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
- expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
- expect(() =>
- readConfig(config([{ host: 'github.com', apiBaseUrl: 7 } as any])),
- ).toThrow(/apiBaseUrl/);
- expect(() =>
- readConfig(config([{ host: 'github.com', token: 7 } as any])),
- ).toThrow(/token/);
- });
- });
-
describe('implementation', () => {
it('rejects unknown targets', async () => {
- const processor = new GithubUrlReader({
- host: 'github.com',
- apiBaseUrl: 'https://api.github.com',
- });
+ const processor = new GithubUrlReader(
+ {
+ host: 'github.com',
+ apiBaseUrl: 'https://api.github.com',
+ },
+ { treeResponseFactory },
+ );
await expect(
processor.read('https://not.github.com/apa'),
).rejects.toThrow(
@@ -230,4 +188,88 @@ describe('GithubUrlReader', () => {
);
});
});
+
+ describe('readTree', () => {
+ const worker = setupServer();
+
+ msw.setupDefaultHandlers(worker);
+
+ const repoBuffer = fs.readFileSync(
+ path.resolve('src', 'reading', '__fixtures__', 'repo.tar.gz'),
+ );
+
+ beforeEach(() => {
+ worker.use(
+ rest.get(
+ 'https://github.com/backstage/mock/archive/repo.tar.gz',
+ (_, res, ctx) =>
+ res(
+ ctx.status(200),
+ ctx.set('Content-Type', 'application/x-gzip'),
+ ctx.body(repoBuffer),
+ ),
+ ),
+ );
+ });
+
+ it('returns the wanted files from an archive', async () => {
+ const processor = new GithubUrlReader(
+ {
+ host: 'github.com',
+ apiBaseUrl: 'https://api.github.com',
+ },
+ { treeResponseFactory },
+ );
+
+ const response = await processor.readTree(
+ 'https://github.com/backstage/mock/tree/repo',
+ );
+
+ const files = await response.files();
+
+ expect(files.length).toBe(2);
+ const mkDocsFile = await files[0].content();
+ const indexMarkdownFile = await files[1].content();
+
+ expect(mkDocsFile.toString()).toBe('site_name: Test\n');
+ expect(indexMarkdownFile.toString()).toBe('# Test\n');
+ });
+
+ it('must specify a branch', async () => {
+ const processor = new GithubUrlReader(
+ {
+ host: 'github.com',
+ apiBaseUrl: 'https://api.github.com',
+ },
+ { treeResponseFactory },
+ );
+
+ await expect(
+ processor.readTree('https://github.com/backstage/mock'),
+ ).rejects.toThrow(
+ 'GitHub URL must contain branch to be able to fetch tree',
+ );
+ });
+
+ it('returns the wanted files from an archive with a subpath', async () => {
+ const processor = new GithubUrlReader(
+ {
+ host: 'github.com',
+ apiBaseUrl: 'https://api.github.com',
+ },
+ { treeResponseFactory },
+ );
+
+ const response = await processor.readTree(
+ 'https://github.com/backstage/mock/tree/repo/docs',
+ );
+
+ const files = await response.files();
+
+ expect(files.length).toBe(1);
+ const indexMarkdownFile = await files[0].content();
+
+ expect(indexMarkdownFile.toString()).toBe('# Test\n');
+ });
+ });
});
diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts
index d12344ec91..907f2ada7a 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.ts
@@ -14,52 +14,25 @@
* limitations under the License.
*/
-import { Config } from '@backstage/config';
-import parseGitUri from 'git-url-parse';
+import {
+ GitHubIntegrationConfig,
+ readGitHubIntegrationConfigs,
+} from '@backstage/integration';
import fetch from 'cross-fetch';
-import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
+import parseGitUri from 'git-url-parse';
+import { Readable } from 'stream';
+import { InputError, NotFoundError } from '../errors';
+import { ReadTreeResponseFactory } from './tree';
+import {
+ ReaderFactory,
+ ReadTreeOptions,
+ ReadTreeResponse,
+ UrlReader,
+} from './types';
-/**
- * The configuration parameters for a single GitHub API provider.
- */
-export type ProviderConfig = {
- /**
- * The host of the target that this matches on, e.g. "github.com"
- */
- host: string;
-
- /**
- * The base URL of the API of this provider, e.g. "https://api.github.com",
- * with no trailing slash.
- *
- * May be omitted specifically for GitHub; then it will be deduced.
- *
- * The API will always be preferred if both its base URL and a token are
- * present.
- */
- apiBaseUrl?: string;
-
- /**
- * The base URL of the raw fetch endpoint of this provider, e.g.
- * "https://raw.githubusercontent.com", with no trailing slash.
- *
- * May be omitted specifically for GitHub; then it will be deduced.
- *
- * The API will always be preferred if both its base URL and a token are
- * present.
- */
- rawBaseUrl?: string;
-
- /**
- * The authorization token to use for requests to this provider.
- *
- * If no token is specified, anonymous access is used.
- */
- token?: string;
-};
-
-export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
+export function getApiRequestOptions(
+ provider: GitHubIntegrationConfig,
+): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
};
@@ -73,7 +46,9 @@ export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
};
}
-export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
+export function getRawRequestOptions(
+ provider: GitHubIntegrationConfig,
+): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
@@ -88,7 +63,10 @@ export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
// Converts for example
// from: https://github.com/a/b/blob/branchname/path/to/c.yaml
// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname
-export function getApiUrl(target: string, provider: ProviderConfig): URL {
+export function getApiUrl(
+ target: string,
+ provider: GitHubIntegrationConfig,
+): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
@@ -113,7 +91,10 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL {
// Converts for example
// from: https://github.com/a/b/blob/branchname/c.yaml
// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml
-export function getRawUrl(target: string, provider: ProviderConfig): URL {
+export function getRawUrl(
+ target: string,
+ provider: GitHubIntegrationConfig,
+): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
@@ -135,70 +116,31 @@ export function getRawUrl(target: string, provider: ProviderConfig): URL {
}
}
-export function readConfig(config: Config): ProviderConfig[] {
- const providers: ProviderConfig[] = [];
-
- const providerConfigs =
- config.getOptionalConfigArray('integrations.github') ?? [];
-
- // First read all the explicit providers
- for (const providerConfig of providerConfigs) {
- const host = providerConfig.getOptionalString('host') ?? 'github.com';
- let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
- let rawBaseUrl = providerConfig.getOptionalString('rawBaseUrl');
- const token = providerConfig.getOptionalString('token');
-
- if (apiBaseUrl) {
- apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
- } else if (host === 'github.com') {
- apiBaseUrl = 'https://api.github.com';
- }
-
- if (rawBaseUrl) {
- rawBaseUrl = rawBaseUrl.replace(/\/+$/, '');
- } else if (host === 'github.com') {
- rawBaseUrl = 'https://raw.githubusercontent.com';
- }
-
- if (!apiBaseUrl && !rawBaseUrl) {
- throw new Error(
- `GitHub integration for '${host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
- );
- }
-
- providers.push({ host, apiBaseUrl, rawBaseUrl, token });
- }
-
- // If no explicit github.com provider was added, put one in the list as
- // a convenience
- if (!providers.some(p => p.host === 'github.com')) {
- providers.push({
- host: 'github.com',
- apiBaseUrl: 'https://api.github.com',
- rawBaseUrl: 'https://raw.githubusercontent.com',
- });
- }
-
- return providers;
-}
-
/**
* A processor that adds the ability to read files from GitHub v3 APIs, such as
* the one exposed by GitHub itself.
*/
export class GithubUrlReader implements UrlReader {
- private config: ProviderConfig;
-
- static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(provider => {
- const reader = new GithubUrlReader(provider);
+ static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
+ const configs = readGitHubIntegrationConfigs(
+ config.getOptionalConfigArray('integrations.github') ?? [],
+ );
+ return configs.map(provider => {
+ const reader = new GithubUrlReader(provider, { treeResponseFactory });
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
};
- constructor(config: ProviderConfig) {
- this.config = config;
+ constructor(
+ private readonly config: GitHubIntegrationConfig,
+ private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
+ ) {
+ if (!config.apiBaseUrl && !config.rawBaseUrl) {
+ throw new Error(
+ `GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
+ );
+ }
}
async read(url: string): Promise {
@@ -229,6 +171,51 @@ export class GithubUrlReader implements UrlReader {
throw new Error(message);
}
+ async readTree(
+ url: string,
+ options?: ReadTreeOptions,
+ ): Promise {
+ const {
+ name: repoName,
+ ref,
+ protocol,
+ source,
+ full_name,
+ filepath,
+ } = parseGitUri(url);
+
+ if (!ref) {
+ // TODO(Rugvip): We should add support for defaulting to the default branch
+ throw new InputError(
+ 'GitHub URL must contain branch to be able to fetch tree',
+ );
+ }
+
+ // TODO(Rugvip): use API to fetch URL instead
+ const response = await fetch(
+ new URL(
+ `${protocol}://${source}/${full_name}/archive/${ref}.tar.gz`,
+ ).toString(),
+ );
+ if (!response.ok) {
+ const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
+ if (response.status === 404) {
+ throw new NotFoundError(message);
+ }
+ throw new Error(message);
+ }
+
+ const path = `${repoName}-${ref}/${filepath}`;
+
+ return this.deps.treeResponseFactory.fromTarArchive({
+ // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
+ // to stick to using that in exclusively backend code.
+ stream: (response.body as unknown) as Readable,
+ path,
+ filter: options?.filter,
+ });
+ }
+
toString() {
const { host, token } = this.config;
return `github{host=${host},authed=${Boolean(token)}}`;
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
index 4b3aa471c3..3a76c0631b 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -20,9 +20,14 @@ import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
import { GitlabUrlReader } from './GitlabUrlReader';
import { msw } from '@backstage/test-utils';
+import { ReadTreeResponseFactory } from './tree';
const logger = getVoidLogger();
+const treeResponseFactory = ReadTreeResponseFactory.create({
+ config: new ConfigReader({}),
+});
+
describe('GitlabUrlReader', () => {
const worker = setupServer();
@@ -98,7 +103,11 @@ describe('GitlabUrlReader', () => {
}),
},
])('should handle happy path %#', async ({ url, config, response }) => {
- const [{ reader }] = GitlabUrlReader.factory({ config, logger });
+ const [{ reader }] = GitlabUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
const data = await reader.read(url);
const res = await JSON.parse(data.toString('utf-8'));
@@ -114,7 +123,11 @@ describe('GitlabUrlReader', () => {
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
- const [{ reader }] = GitlabUrlReader.factory({ config, logger });
+ const [{ reader }] = GitlabUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
await reader.read(url);
}).rejects.toThrow(error);
});
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts
index 378e76fb61..e2d3edfea2 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.ts
@@ -14,48 +14,27 @@
* limitations under the License.
*/
+import {
+ GitLabIntegrationConfig,
+ readGitLabIntegrationConfigs,
+} from '@backstage/integration';
import fetch from 'cross-fetch';
-import { Config } from '@backstage/config';
import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
-
-type Options = {
- host: string;
- token?: string;
-};
-
-function readConfig(config: Config): Options[] {
- const optionsArr = Array();
-
- const providerConfigs =
- config.getOptionalConfigArray('integrations.gitlab') ?? [];
-
- for (const providerConfig of providerConfigs) {
- const host = providerConfig.getOptionalString('host') ?? 'gitlab.com';
- const token = providerConfig.getOptionalString('token');
-
- optionsArr.push({ host, token });
- }
-
- // As a convenience we always make sure there's at least an unauthenticated
- // reader for public gitlab repos.
- if (!optionsArr.some(p => p.host === 'gitlab.com')) {
- optionsArr.push({ host: 'gitlab.com' });
- }
-
- return optionsArr;
-}
+import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
export class GitlabUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(options => {
+ const configs = readGitLabIntegrationConfigs(
+ config.getOptionalConfigArray('integrations.gitlab') ?? [],
+ );
+ return configs.map(options => {
const reader = new GitlabUrlReader(options);
const predicate = (url: URL) => url.host === options.host;
return { reader, predicate };
});
};
- constructor(private readonly options: Options) {}
+ constructor(private readonly options: GitLabIntegrationConfig) {}
async read(url: string): Promise {
// TODO(Rugvip): merged the old GitlabReaderProcessor in here and used
@@ -87,6 +66,10 @@ export class GitlabUrlReader implements UrlReader {
throw new Error(message);
}
+ readTree(): Promise {
+ throw new Error('GitlabUrlReader does not implement readTree');
+ }
+
// Converts
// from: https://gitlab.example.com/a/b/blob/master/c.yaml
// to: https://gitlab.example.com/a/b/raw/master/c.yaml
@@ -129,9 +112,9 @@ export class GitlabUrlReader implements UrlReader {
try {
const url = new URL(target);
- const branchAndfilePath = url.pathname.split('/-/blob/')[1];
+ const branchAndFilePath = url.pathname.split('/-/blob/')[1];
- const [branch, ...filePath] = branchAndfilePath.split('/');
+ const [branch, ...filePath] = branchAndFilePath.split('/');
url.pathname = [
'/api/v4/projects',
diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts
index 7654cc8aac..465c125fda 100644
--- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts
+++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts
@@ -14,7 +14,12 @@
* limitations under the License.
*/
-import { UrlReader, UrlReaderPredicateTuple } from './types';
+import {
+ ReadTreeOptions,
+ ReadTreeResponse,
+ UrlReader,
+ UrlReaderPredicateTuple,
+} from './types';
type Options = {
// UrlReader to fall back to if no other reader is matched
@@ -53,6 +58,22 @@ export class UrlReaderPredicateMux implements UrlReader {
throw new Error(`No reader found that could handle '${url}'`);
}
+ readTree(url: string, options?: ReadTreeOptions): Promise {
+ const parsed = new URL(url);
+
+ for (const { predicate, reader } of this.readers) {
+ if (predicate(parsed)) {
+ return reader.readTree(url, options);
+ }
+ }
+
+ if (this.fallback) {
+ return this.fallback.readTree(url, options);
+ }
+
+ throw new Error(`No reader found that could handle '${url}'`);
+ }
+
toString() {
return `predicateMux{readers=${this.readers
.map(t => t.reader)
diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts
index e1d99a2c49..2bb5617907 100644
--- a/packages/backend-common/src/reading/UrlReaders.ts
+++ b/packages/backend-common/src/reading/UrlReaders.ts
@@ -23,6 +23,7 @@ import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
import { FetchUrlReader } from './FetchUrlReader';
+import { ReadTreeResponseFactory } from './tree';
type CreateOptions = {
/** Root config object */
@@ -49,9 +50,10 @@ export class UrlReaders {
fallback,
}: CreateOptions): UrlReader {
const mux = new UrlReaderPredicateMux({ fallback: fallback });
+ const treeResponseFactory = ReadTreeResponseFactory.create({ config });
for (const factory of factories ?? []) {
- const tuples = factory({ config, logger: logger });
+ const tuples = factory({ config, logger: logger, treeResponseFactory });
for (const tuple of tuples) {
mux.register(tuple);
diff --git a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz
new file mode 100644
index 0000000000..7a8e9902a2
Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz differ
diff --git a/packages/backend-common/src/reading/__fixtures__/repo.zip b/packages/backend-common/src/reading/__fixtures__/repo.zip
new file mode 100644
index 0000000000..f66bf2d612
Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/repo.zip differ
diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts
index 8ecc08ebca..8a6c2bf1a3 100644
--- a/packages/backend-common/src/reading/index.ts
+++ b/packages/backend-common/src/reading/index.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-export type { UrlReader } from './types';
+export type { UrlReader, ReadTreeResponse } from './types';
export { UrlReaders } from './UrlReaders';
export { AzureUrlReader } from './AzureUrlReader';
export { BitbucketUrlReader } from './BitbucketUrlReader';
diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
new file mode 100644
index 0000000000..986a0302bc
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 os from 'os';
+import { Readable } from 'stream';
+import { Config } from '@backstage/config';
+import { ReadTreeResponse } from '../types';
+import { TarArchiveResponse } from './TarArchiveResponse';
+import { ZipArchiveResponse } from './ZipArchiveResponse';
+
+type FromArchiveOptions = {
+ // A binary stream of a tar archive.
+ stream: Readable;
+ // If set, the root of the tree will be set to the given directory path.
+ path?: string;
+ // Filter passed on from the ReadTreeOptions
+ filter?: (path: string) => boolean;
+};
+
+export class ReadTreeResponseFactory {
+ static create(options: { config: Config }): ReadTreeResponseFactory {
+ return new ReadTreeResponseFactory(
+ options.config.getOptionalString('backend.workingDirectory') ??
+ os.tmpdir(),
+ );
+ }
+
+ constructor(private readonly workDir: string) {}
+
+ async fromTarArchive(options: FromArchiveOptions): Promise {
+ return new TarArchiveResponse(
+ options.stream,
+ options.path ?? '',
+ this.workDir,
+ options.filter,
+ );
+ }
+
+ async fromZipArchive(options: FromArchiveOptions): Promise {
+ return new ZipArchiveResponse(
+ options.stream,
+ options.path ?? '',
+ this.workDir,
+ options.filter,
+ );
+ }
+}
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
new file mode 100644
index 0000000000..3589a8d333
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
@@ -0,0 +1,151 @@
+/*
+ * 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 fs from 'fs-extra';
+import mockFs from 'mock-fs';
+import { resolve as resolvePath } from 'path';
+import { TarArchiveResponse } from './TarArchiveResponse';
+
+const archiveData = fs.readFileSync(
+ resolvePath(__filename, '../../__fixtures__/repo.tar.gz'),
+);
+
+describe('TarArchiveResponse', () => {
+ beforeEach(() => {
+ mockFs({
+ '/test-archive.tar.gz': archiveData,
+ '/tmp': mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should read files', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
+ const files = await res.files();
+
+ expect(files).toEqual([
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ {
+ path: 'docs/index.md',
+ content: expect.any(Function),
+ },
+ ]);
+ const contents = await Promise.all(files.map(f => f.content()));
+ expect(contents.map(c => c.toString('utf8').trim())).toEqual([
+ 'site_name: Test',
+ '# Test',
+ ]);
+ });
+
+ it('should read files with filter', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
+ path.endsWith('.yml'),
+ );
+ const files = await res.files();
+
+ expect(files).toEqual([
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ ]);
+ const content = await files[0].content();
+ expect(content.toString('utf8').trim()).toEqual('site_name: Test');
+ });
+
+ it('should read as archive and files', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
+ const buffer = await res.archive();
+
+ await expect(res.archive()).rejects.toThrow(
+ 'Response has already been read',
+ );
+
+ const res2 = new TarArchiveResponse(buffer, '', '/tmp');
+ const files = await res2.files();
+
+ expect(files).toEqual([
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ {
+ path: 'docs/index.md',
+ content: expect.any(Function),
+ },
+ ]);
+ const contents = await Promise.all(files.map(f => f.content()));
+ expect(contents.map(c => c.toString('utf8').trim())).toEqual([
+ 'site_name: Test',
+ '# Test',
+ ]);
+ });
+
+ it('should extract entire archive into directory', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, '', '/tmp');
+ const dir = await res.dir();
+
+ await expect(
+ fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
+ it('should extract archive into directory with a subpath', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
+ const dir = await res.dir();
+
+ expect(dir).toMatch(/^\/tmp\/.*$/);
+ await expect(
+ fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
+ it('should extract archive into directory with a subpath and filter', async () => {
+ const stream = fs.createReadStream('/test-archive.tar.gz');
+
+ const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
+ path.endsWith('.yml'),
+ );
+ const dir = await res.dir({ targetDir: '/tmp' });
+
+ expect(dir).toBe('/tmp');
+ await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe(
+ true,
+ );
+ await expect(
+ fs.pathExists(resolvePath(dir, 'docs/index.md')),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
new file mode 100644
index 0000000000..5d18ec7dc6
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
@@ -0,0 +1,160 @@
+/*
+ * 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 tar, { Parse, ParseStream, ReadEntry } from 'tar';
+import path from 'path';
+import fs from 'fs-extra';
+import { Readable, pipeline as pipelineCb } from 'stream';
+import { promisify } from 'util';
+import concatStream from 'concat-stream';
+import {
+ ReadTreeResponse,
+ ReadTreeResponseFile,
+ ReadTreeResponseDirOptions,
+} from '../types';
+
+// Tar types for `Parse` is not a proper constructor, but it should be
+const TarParseStream = (Parse as unknown) as { new (): ParseStream };
+
+const pipeline = promisify(pipelineCb);
+
+/**
+ * Wraps a tar archive stream into a tree response reader.
+ */
+export class TarArchiveResponse implements ReadTreeResponse {
+ private read = false;
+
+ constructor(
+ private readonly stream: Readable,
+ private readonly subPath: string,
+ private readonly workDir: string,
+ private readonly filter?: (path: string) => boolean,
+ ) {
+ if (subPath) {
+ if (!subPath.endsWith('/')) {
+ this.subPath += '/';
+ }
+ if (subPath.startsWith('/')) {
+ throw new TypeError(
+ `TarArchiveResponse subPath must not start with a /, got '${subPath}'`,
+ );
+ }
+ }
+ }
+
+ // Make sure the input stream is only read once
+ private onlyOnce() {
+ if (this.read) {
+ throw new Error('Response has already been read');
+ }
+ this.read = true;
+ }
+
+ async files(): Promise {
+ this.onlyOnce();
+
+ const files = Array();
+ const parser = new TarParseStream();
+
+ parser.on('entry', (entry: ReadEntry & Readable) => {
+ if (entry.type === 'Directory') {
+ entry.resume();
+ return;
+ }
+
+ if (this.subPath) {
+ if (!entry.path.startsWith(this.subPath)) {
+ entry.resume();
+ return;
+ }
+ }
+
+ const path = entry.path.slice(this.subPath.length);
+ if (this.filter) {
+ if (!this.filter(path)) {
+ entry.resume();
+ return;
+ }
+ }
+
+ const content = new Promise(async resolve => {
+ await pipeline(entry, concatStream(resolve));
+ });
+
+ files.push({ path, content: () => content });
+
+ entry.resume();
+ });
+
+ await pipeline(this.stream, parser);
+
+ return files;
+ }
+
+ async archive(): Promise {
+ if (!this.subPath) {
+ this.onlyOnce();
+
+ return this.stream;
+ }
+
+ // TODO(Rugvip): method for repacking a tar with a subpath is to simply extract into a
+ // tmp dir and recreate the archive. Would be nicer to stream things instead.
+ const tmpDir = await this.dir();
+
+ try {
+ const data = await new Promise(async resolve => {
+ await pipeline(
+ tar.create({ cwd: tmpDir }, ['']),
+ concatStream(resolve),
+ );
+ });
+ return Readable.from(data);
+ } finally {
+ await fs.remove(tmpDir);
+ }
+ }
+
+ async dir(options?: ReadTreeResponseDirOptions): Promise {
+ this.onlyOnce();
+
+ const dir =
+ options?.targetDir ??
+ (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+
+ const strip = this.subPath ? this.subPath.split('/').length - 1 : 0;
+
+ await pipeline(
+ this.stream,
+ tar.extract({
+ strip,
+ cwd: dir,
+ filter: path => {
+ if (this.subPath && !path.startsWith(this.subPath)) {
+ return false;
+ }
+ if (this.filter) {
+ const innerPath = path.split('/').slice(strip).join('/');
+ return this.filter(innerPath);
+ }
+ return true;
+ },
+ }),
+ );
+
+ return dir;
+ }
+}
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
new file mode 100644
index 0000000000..922284c759
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
@@ -0,0 +1,151 @@
+/*
+ * 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 fs from 'fs-extra';
+import mockFs from 'mock-fs';
+import { resolve as resolvePath } from 'path';
+import { ZipArchiveResponse } from './ZipArchiveResponse';
+
+const archiveData = fs.readFileSync(
+ resolvePath(__filename, '../../__fixtures__/repo.zip'),
+);
+
+describe('ZipArchiveResponse', () => {
+ beforeEach(() => {
+ mockFs({
+ '/test-archive.zip': archiveData,
+ '/tmp': mockFs.directory(),
+ });
+ });
+
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should read files', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
+ const files = await res.files();
+
+ expect(files).toEqual([
+ {
+ path: 'docs/index.md',
+ content: expect.any(Function),
+ },
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ ]);
+ const contents = await Promise.all(files.map(f => f.content()));
+ expect(contents.map(c => c.toString('utf8').trim())).toEqual([
+ '# Test',
+ 'site_name: Test',
+ ]);
+ });
+
+ it('should read files with filter', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
+ path.endsWith('.yml'),
+ );
+ const files = await res.files();
+
+ expect(files).toEqual([
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ ]);
+ const content = await files[0].content();
+ expect(content.toString('utf8').trim()).toEqual('site_name: Test');
+ });
+
+ it('should read as archive and files', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
+ const buffer = await res.archive();
+
+ await expect(res.archive()).rejects.toThrow(
+ 'Response has already been read',
+ );
+
+ const res2 = new ZipArchiveResponse(buffer, '', '/tmp');
+ const files = await res2.files();
+
+ expect(files).toEqual([
+ {
+ path: 'docs/index.md',
+ content: expect.any(Function),
+ },
+ {
+ path: 'mkdocs.yml',
+ content: expect.any(Function),
+ },
+ ]);
+ const contents = await Promise.all(files.map(f => f.content()));
+ expect(contents.map(c => c.toString('utf8').trim())).toEqual([
+ '# Test',
+ 'site_name: Test',
+ ]);
+ });
+
+ it('should extract entire archive into directory', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, '', '/tmp');
+ const dir = await res.dir();
+
+ await expect(
+ fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'),
+ ).resolves.toBe('site_name: Test\n');
+ await expect(
+ fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
+ it('should extract archive into directory with a subpath', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
+ const dir = await res.dir();
+
+ expect(dir).toMatch(/^\/tmp\/.*$/);
+ await expect(
+ fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
+ ).resolves.toBe('# Test\n');
+ });
+
+ it('should extract archive into directory with a subpath and filter', async () => {
+ const stream = fs.createReadStream('/test-archive.zip');
+
+ const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
+ path.endsWith('.yml'),
+ );
+ const dir = await res.dir({ targetDir: '/tmp' });
+
+ expect(dir).toBe('/tmp');
+ await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe(
+ true,
+ );
+ await expect(
+ fs.pathExists(resolvePath(dir, 'docs/index.md')),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
new file mode 100644
index 0000000000..4106d49a11
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
@@ -0,0 +1,153 @@
+/*
+ * 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 path from 'path';
+import fs from 'fs-extra';
+import unzipper, { Entry } from 'unzipper';
+import archiver from 'archiver';
+import { Readable } from 'stream';
+import {
+ ReadTreeResponse,
+ ReadTreeResponseFile,
+ ReadTreeResponseDirOptions,
+} from '../types';
+
+/**
+ * Wraps a zip archive stream into a tree response reader.
+ */
+export class ZipArchiveResponse implements ReadTreeResponse {
+ private read = false;
+
+ constructor(
+ private readonly stream: Readable,
+ private readonly subPath: string,
+ private readonly workDir: string,
+ private readonly filter?: (path: string) => boolean,
+ ) {
+ if (subPath) {
+ if (!subPath.endsWith('/')) {
+ this.subPath += '/';
+ }
+ if (subPath.startsWith('/')) {
+ throw new TypeError(
+ `ZipArchiveResponse subPath must not start with a /, got '${subPath}'`,
+ );
+ }
+ }
+ }
+
+ // Make sure the input stream is only read once
+ private onlyOnce() {
+ if (this.read) {
+ throw new Error('Response has already been read');
+ }
+ this.read = true;
+ }
+
+ private getPath(entry: Entry): string {
+ return entry.path.slice(this.subPath.length);
+ }
+
+ private shouldBeIncluded(entry: Entry): boolean {
+ if (this.subPath) {
+ if (!entry.path.startsWith(this.subPath)) {
+ return false;
+ }
+ }
+ if (this.filter) {
+ return this.filter(this.getPath(entry));
+ }
+ return true;
+ }
+
+ async files(): Promise {
+ this.onlyOnce();
+
+ const files = Array();
+
+ await this.stream
+ .pipe(unzipper.Parse())
+ .on('entry', (entry: Entry) => {
+ if (entry.type === 'Directory') {
+ entry.resume();
+ return;
+ }
+
+ if (this.shouldBeIncluded(entry)) {
+ files.push({
+ path: this.getPath(entry),
+ content: () => entry.buffer(),
+ });
+ } else {
+ entry.autodrain();
+ }
+ })
+ .promise();
+
+ return files;
+ }
+
+ async archive(): Promise {
+ this.onlyOnce();
+
+ if (!this.subPath) {
+ return this.stream;
+ }
+
+ const archive = archiver('zip');
+ await this.stream
+ .pipe(unzipper.Parse())
+ .on('entry', (entry: Entry) => {
+ if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
+ archive.append(entry, { name: this.getPath(entry) });
+ } else {
+ entry.autodrain();
+ }
+ })
+ .promise();
+ archive.finalize();
+
+ return archive;
+ }
+
+ async dir(options?: ReadTreeResponseDirOptions): Promise {
+ this.onlyOnce();
+
+ const dir =
+ options?.targetDir ??
+ (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+
+ await this.stream
+ .pipe(unzipper.Parse())
+ .on('entry', async (entry: Entry) => {
+ // Ignore directory entries since we handle that with the file entries
+ // as a zip can have files with directories without directory entries
+ if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
+ const entryPath = this.getPath(entry);
+ const dirname = path.dirname(entryPath);
+ if (dirname) {
+ await fs.mkdirp(path.join(dir, dirname));
+ }
+ entry.pipe(fs.createWriteStream(path.join(dir, entryPath)));
+ } else {
+ entry.autodrain();
+ }
+ })
+ .promise();
+
+ return dir;
+ }
+}
diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/packages/backend-common/src/reading/tree/index.ts
similarity index 88%
rename from plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts
rename to packages/backend-common/src/reading/tree/index.ts
index ee77a483e6..858cf15877 100644
--- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts
+++ b/packages/backend-common/src/reading/tree/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
+export { ReadTreeResponseFactory } from './ReadTreeResponseFactory';
diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts
index e423db3eca..f9dca3e1d5 100644
--- a/packages/backend-common/src/reading/types.ts
+++ b/packages/backend-common/src/reading/types.ts
@@ -16,12 +16,30 @@
import { Logger } from 'winston';
import { Config } from '@backstage/config';
+import { ReadTreeResponseFactory } from './tree';
+
+export type ReadTreeOptions = {
+ /**
+ * A filter that can be used to select which files should be included.
+ *
+ * The path passed to the filter function is the relative path from the URL
+ * that the file tree is fetched from, without any leading '/'.
+ *
+ * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file
+ * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will
+ * be represented as my-subdir/my-file.txt
+ *
+ * If no filter is provided all files are extracted.
+ */
+ filter?(path: string): boolean;
+};
/**
* A generic interface for fetching plain data from URLs.
*/
export type UrlReader = {
read(url: string): Promise;
+ readTree(url: string, options?: ReadTreeOptions): Promise;
};
export type UrlReaderPredicateTuple = {
@@ -36,4 +54,21 @@ export type UrlReaderPredicateTuple = {
export type ReaderFactory = (options: {
config: Config;
logger: Logger;
+ treeResponseFactory: ReadTreeResponseFactory;
}) => UrlReaderPredicateTuple[];
+
+export type ReadTreeResponseFile = {
+ path: string;
+ content(): Promise;
+};
+
+export type ReadTreeResponseDirOptions = {
+ /** The directory to write files to. Defaults to the OS tmpdir or `backend.workingDirectory` if set in config */
+ targetDir?: string;
+};
+
+export type ReadTreeResponse = {
+ files(): Promise;
+ archive(): Promise;
+ dir(options?: ReadTreeResponseDirOptions): Promise;
+};
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index a3e5535e40..a8bc1fef7d 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,43 @@
# example-backend
+## 0.2.2
+
+### Patch Changes
+
+- Updated dependencies [1722cb53c]
+- Updated dependencies [1722cb53c]
+- Updated dependencies [1722cb53c]
+- Updated dependencies [f531d307c]
+- Updated dependencies [3efd03c0e]
+- Updated dependencies [7b37e6834]
+- Updated dependencies [8e2effb53]
+- Updated dependencies [d33f5157c]
+ - @backstage/backend-common@0.3.0
+ - @backstage/plugin-app-backend@0.3.0
+ - @backstage/plugin-catalog-backend@0.2.1
+ - example-app@0.2.2
+ - @backstage/plugin-scaffolder-backend@0.3.1
+ - @backstage/plugin-auth-backend@0.2.2
+ - @backstage/plugin-graphql-backend@0.1.3
+ - @backstage/plugin-kubernetes-backend@0.1.3
+ - @backstage/plugin-proxy-backend@0.2.1
+ - @backstage/plugin-rollbar-backend@0.1.3
+ - @backstage/plugin-sentry-backend@0.1.3
+ - @backstage/plugin-techdocs-backend@0.2.1
+
+## 0.2.1
+
+### Patch Changes
+
+- Updated dependencies [752808090]
+- Updated dependencies [462876399]
+- Updated dependencies [59166e5ec]
+- Updated dependencies [33b7300eb]
+ - @backstage/plugin-auth-backend@0.2.1
+ - @backstage/plugin-scaffolder-backend@0.3.0
+ - @backstage/backend-common@0.2.1
+ - example-app@0.2.1
+
## 0.2.0
### Patch Changes
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 051b83a146..014a3f78d5 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.2.0",
+ "version": "0.2.2",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
@@ -18,24 +18,24 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.2.0",
+ "@backstage/backend-common": "^0.3.0",
"@backstage/catalog-model": "^0.2.0",
"@backstage/config": "^0.1.1",
- "@backstage/plugin-app-backend": "^0.2.0",
- "@backstage/plugin-auth-backend": "^0.2.0",
- "@backstage/plugin-catalog-backend": "^0.2.0",
- "@backstage/plugin-graphql-backend": "^0.1.2",
- "@backstage/plugin-kubernetes-backend": "^0.1.2",
- "@backstage/plugin-proxy-backend": "^0.2.0",
- "@backstage/plugin-rollbar-backend": "^0.1.2",
- "@backstage/plugin-scaffolder-backend": "^0.2.0",
- "@backstage/plugin-sentry-backend": "^0.1.2",
- "@backstage/plugin-techdocs-backend": "^0.2.0",
+ "@backstage/plugin-app-backend": "^0.3.0",
+ "@backstage/plugin-auth-backend": "^0.2.2",
+ "@backstage/plugin-catalog-backend": "^0.2.1",
+ "@backstage/plugin-graphql-backend": "^0.1.3",
+ "@backstage/plugin-kubernetes-backend": "^0.1.3",
+ "@backstage/plugin-proxy-backend": "^0.2.1",
+ "@backstage/plugin-rollbar-backend": "^0.1.3",
+ "@backstage/plugin-scaffolder-backend": "^0.3.1",
+ "@backstage/plugin-sentry-backend": "^0.1.3",
+ "@backstage/plugin-techdocs-backend": "^0.2.1",
"@gitbeaker/node": "^25.2.0",
"@octokit/rest": "^18.0.0",
"azure-devops-node-api": "^10.1.1",
"dockerode": "^3.2.0",
- "example-app": "^0.2.0",
+ "example-app": "^0.2.2",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.6",
@@ -45,7 +45,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.2.0",
+ "@backstage/cli": "^0.3.0",
"@types/dockerode": "^2.5.32",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
diff --git a/packages/backend/src/plugins/app.ts b/packages/backend/src/plugins/app.ts
index c9f7c0622a..637af80974 100644
--- a/packages/backend/src/plugins/app.ts
+++ b/packages/backend/src/plugins/app.ts
@@ -17,9 +17,13 @@
import { createRouter } from '@backstage/plugin-app-backend';
import { PluginEnvironment } from '../types';
-export default async function createPlugin({ logger }: PluginEnvironment) {
+export default async function createPlugin({
+ logger,
+ config,
+}: PluginEnvironment) {
return await createRouter({
logger,
+ config,
appPackageName: 'example-app',
});
}
diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts
index dea31c763b..de48280e64 100644
--- a/packages/backend/src/plugins/techdocs.ts
+++ b/packages/backend/src/plugins/techdocs.ts
@@ -22,6 +22,7 @@ import {
LocalPublish,
TechdocsGenerator,
CommonGitPreparer,
+ UrlPreparer,
} from '@backstage/plugin-techdocs-backend';
import { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -30,21 +31,26 @@ export default async function createPlugin({
logger,
config,
discovery,
+ reader,
}: PluginEnvironment) {
const generators = new Generators();
const techdocsGenerator = new TechdocsGenerator(logger, config);
generators.register('techdocs', techdocsGenerator);
const preparers = new Preparers();
- const commonGitPreparer = new CommonGitPreparer(logger);
const directoryPreparer = new DirectoryPreparer(logger);
preparers.register('dir', directoryPreparer);
+
+ const commonGitPreparer = new CommonGitPreparer(logger);
preparers.register('github', commonGitPreparer);
preparers.register('gitlab', commonGitPreparer);
preparers.register('azure/api', commonGitPreparer);
- const publisher = new LocalPublish(logger);
+ const urlPreparer = new UrlPreparer(reader, logger);
+ preparers.register('url', urlPreparer);
+
+ const publisher = new LocalPublish(logger, discovery);
const dockerClient = new Docker();
diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md
new file mode 100644
index 0000000000..574cd301bf
--- /dev/null
+++ b/packages/catalog-client/CHANGELOG.md
@@ -0,0 +1,7 @@
+# @backstage/catalog-client
+
+## 0.3.0
+
+### Minor Changes
+
+- 717e43de1: Changed the getEntities interface to (1) nest parameters in an object, (2) support field selection, and (3) return an object with an items field for future extension
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index c4aa632ca9..2482f9579d 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-client",
- "version": "0.2.0",
+ "version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -25,7 +25,7 @@
"cross-fetch": "^3.0.6"
},
"devDependencies": {
- "@backstage/cli": "^0.2.0",
+ "@backstage/cli": "^0.3.0",
"@types/jest": "^26.0.7",
"msw": "^0.21.2"
},
diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts
index 65625c11b8..6369f95b76 100644
--- a/packages/catalog-client/src/CatalogClient.test.ts
+++ b/packages/catalog-client/src/CatalogClient.test.ts
@@ -14,11 +14,11 @@
* limitations under the License.
*/
+import { Entity } from '@backstage/catalog-model';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
-import { Entity } from '@backstage/catalog-model';
-import { DiscoveryApi } from './types';
+import { CatalogListResponse, DiscoveryApi } from './types';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
@@ -40,7 +40,7 @@ describe('CatalogClient', () => {
});
describe('getEntities', () => {
- const defaultResponse: Entity[] = [
+ const defaultServiceResponse: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
@@ -58,22 +58,26 @@ describe('CatalogClient', () => {
},
},
];
+ const defaultResponse: CatalogListResponse = {
+ items: defaultServiceResponse,
+ };
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => {
- return res(ctx.json(defaultResponse));
+ return res(ctx.json(defaultServiceResponse));
}),
);
});
it('should entities from correct endpoint', async () => {
- const entities = await client.getEntities();
- expect(entities).toEqual(defaultResponse);
+ const response = await client.getEntities();
+ expect(response).toEqual(defaultResponse);
});
it('builds entity search filters properly', async () => {
expect.assertions(2);
+
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D');
@@ -81,13 +85,32 @@ describe('CatalogClient', () => {
}),
);
- const entities = await client.getEntities({
- a: '1',
- b: ['2', '3'],
- ö: '=',
+ const response = await client.getEntities({
+ filter: {
+ a: '1',
+ b: ['2', '3'],
+ ö: '=',
+ },
});
- expect(entities).toEqual([]);
+ expect(response.items).toEqual([]);
+ });
+
+ it('builds entity field selectors properly', async () => {
+ expect.assertions(2);
+
+ server.use(
+ rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
+ expect(req.url.search).toBe('?fields=a.b,%C3%B6');
+ return res(ctx.json([]));
+ }),
+ );
+
+ const response = await client.getEntities({
+ fields: ['a.b', 'ö'],
+ });
+
+ expect(response.items).toEqual([]);
});
});
});
diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts
index e39759a88d..362b1e71da 100644
--- a/packages/catalog-client/src/CatalogClient.ts
+++ b/packages/catalog-client/src/CatalogClient.ts
@@ -25,6 +25,8 @@ import {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
+ CatalogEntitiesRequest,
+ CatalogListResponse,
DiscoveryApi,
} from './types';
@@ -35,55 +37,33 @@ export class CatalogClient implements CatalogApi {
this.discoveryApi = options.discoveryApi;
}
- private async getRequired(path: string): Promise {
- const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
- const response = await fetch(url);
-
- if (!response.ok) {
- const payload = await response.text();
- const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
- throw new Error(message);
- }
-
- return await response.json();
- }
-
- private async getOptional(path: string): Promise {
- const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
- const response = await fetch(url);
-
- if (!response.ok) {
- if (response.status === 404) {
- return undefined;
- }
-
- const payload = await response.text();
- const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
- throw new Error(message);
- }
-
- return await response.json();
- }
-
async getLocationById(id: String): Promise {
return await this.getOptional(`/locations/${id}`);
}
async getEntities(
- filter?: Record,
- ): Promise {
- let path = `/entities`;
- if (filter) {
- const parts: string[] = [];
- for (const [key, value] of Object.entries(filter)) {
- for (const v of [value].flat()) {
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
- }
+ request?: CatalogEntitiesRequest,
+ ): Promise> {
+ const { filter = {}, fields = [] } = request ?? {};
+ const params: string[] = [];
+
+ const filterParts: string[] = [];
+ for (const [key, value] of Object.entries(filter)) {
+ for (const v of [value].flat()) {
+ filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
- path += `?filter=${parts.join(',')}`;
+ }
+ if (filterParts.length) {
+ params.push(`filter=${filterParts.join(',')}`);
}
- return await this.getRequired(path);
+ if (fields.length) {
+ params.push(`fields=${fields.map(encodeURIComponent).join(',')}`);
+ }
+
+ const query = params.length ? `?${params.join('&')}` : '';
+ const entities: Entity[] = await this.getRequired(`/entities${query}`);
+ return { items: entities };
}
async getEntityByName(compoundName: EntityName): Promise {
@@ -153,4 +133,38 @@ export class CatalogClient implements CatalogApi {
}
return undefined;
}
+
+ //
+ // Private methods
+ //
+
+ private async getRequired(path: string): Promise {
+ const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ const payload = await response.text();
+ const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
+ throw new Error(message);
+ }
+
+ return await response.json();
+ }
+
+ private async getOptional(path: string): Promise {
+ const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ return undefined;
+ }
+
+ const payload = await response.text();
+ const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
+ throw new Error(message);
+ }
+
+ return await response.json();
+ }
}
diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts
index 575b8d6ca2..e72317d444 100644
--- a/packages/catalog-client/src/types.ts
+++ b/packages/catalog-client/src/types.ts
@@ -16,10 +16,21 @@
import { Entity, EntityName, Location } from '@backstage/catalog-model';
+export type CatalogEntitiesRequest = {
+ filter?: Record | undefined;
+ fields?: string[] | undefined;
+};
+
+export type CatalogListResponse = {
+ items: T[];
+};
+
export interface CatalogApi {
getLocationById(id: String): Promise;
getEntityByName(name: EntityName): Promise;
- getEntities(filter?: Record): Promise;
+ getEntities(
+ request?: CatalogEntitiesRequest,
+ ): Promise>;
addLocation(location: AddLocationRequest): Promise;
getLocationByEntity(entity: Entity): Promise;
removeEntityByUid(uid: string): Promise;
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index 47d047fe9a..e712e66f58 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -29,7 +29,7 @@
"yup": "^0.29.3"
},
"devDependencies": {
- "@backstage/cli": "^0.2.0",
+ "@backstage/cli": "^0.3.0",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index 9d46dea03f..0e1e369e44 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -30,6 +30,15 @@ const schema = yup.object>({
lifecycle: yup.string().required().min(1),
owner: yup.string().required().min(1),
implementsApis: yup.array(yup.string().required()).notRequired(),
+ kubernetes: yup
+ .object({
+ selector: yup
+ .object({
+ matchLabels: yup.object().required(),
+ })
+ .required(),
+ })
+ .notRequired(),
})
.required(),
});
@@ -42,6 +51,13 @@ export interface ComponentEntityV1alpha1 extends Entity {
lifecycle: string;
owner: string;
implementsApis?: string[];
+ kubernetes?: {
+ selector: {
+ matchLabels: {
+ [key: string]: string;
+ };
+ };
+ };
};
}
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index f6345741f7..7be432ad1f 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -57,8 +57,22 @@ export interface GroupEntityV1alpha1 extends Entity {
spec: {
type: string;
parent?: string;
+ /**
+ * @deprecated This field will disappear on Dec 6th, 2020. Please remove
+ * any consuming code. Producers can stop producing this field
+ * before that date, as long as the catalog backend uses the
+ * BuiltinKindsEntityProcessor which inserts the fields in the
+ * mean time.
+ */
ancestors: string[];
children: string[];
+ /**
+ * @deprecated This field will disappear on Dec 6th, 2020. Please remove
+ * any consuming code. Producers can stop producing this field
+ * before that date, as long as the catalog backend uses the
+ * BuiltinKindsEntityProcessor which inserts the fields in the
+ * mean time.
+ */
descendants: string[];
};
}
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 387f5e416f..a5bcc6bcfd 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,38 @@
# @backstage/cli
+## 0.3.0
+
+### Minor Changes
+
+- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas.
+
+ The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project.
+
+ A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging.
+
+ Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format.
+
+ TypeScript configuration schema files should export a single `Config` type, for example:
+
+ ```ts
+ export interface Config {
+ app: {
+ /**
+ * Frontend root URL
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+ }
+ ```
+
+### Patch Changes
+
+- 1722cb53c: Added configuration schema
+- 902340451: Support specifying listen host/port for frontend
+- Updated dependencies [1722cb53c]
+ - @backstage/config-loader@0.3.0
+
## 0.2.0
### Minor Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7ae244866d..eb56b19971 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": false,
"publishConfig": {
"access": "public"
@@ -30,7 +30,7 @@
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.1",
- "@backstage/config-loader": "^0.2.0",
+ "@backstage/config-loader": "^0.3.0",
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
@@ -51,6 +51,7 @@
"@types/webpack-node-externals": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^v3.10.1",
"@typescript-eslint/parser": "^v3.10.1",
+ "@yarnpkg/lockfile": "^1.1.0",
"bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
@@ -76,6 +77,7 @@
"jest": "^26.0.1",
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
+ "lodash": "^4.17.19",
"mini-css-extract-plugin": "^0.9.0",
"ora": "^4.0.3",
"raw-loader": "^4.0.1",
@@ -91,6 +93,7 @@
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.27.3",
"rollup-pluginutils": "^2.8.2",
+ "semver": "^7.3.2",
"start-server-webpack-plugin": "^2.2.5",
"style-loader": "^1.2.1",
"sucrase": "^3.16.0",
@@ -108,12 +111,12 @@
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/backend-common": "^0.2.0",
+ "@backstage/backend-common": "^0.3.0",
"@backstage/config": "^0.1.1",
- "@backstage/core": "^0.2.0",
- "@backstage/dev-utils": "^0.1.2",
- "@backstage/test-utils": "^0.1.2",
- "@backstage/theme": "^0.2.0",
+ "@backstage/core": "^0.3.1",
+ "@backstage/dev-utils": "^0.1.4",
+ "@backstage/test-utils": "^0.1.3",
+ "@backstage/theme": "^0.2.1",
"@types/diff": "^4.0.2",
"@types/fs-extra": "^9.0.1",
"@types/html-webpack-plugin": "^3.2.2",
@@ -130,6 +133,7 @@
"@types/tar": "^4.0.3",
"@types/webpack": "^4.41.7",
"@types/webpack-dev-server": "^3.11.0",
+ "@types/yarnpkg__lockfile": "^1.1.4",
"del": "^5.1.0",
"mock-fs": "^4.13.0",
"nodemon": "^2.0.2",
@@ -146,5 +150,47 @@
"watch": "./src",
"exec": "bin/backstage-cli",
"ext": "ts"
+ },
+ "configSchema": {
+ "$schema": "https://backstage.io/schema/config-v1",
+ "title": "@backstage/cli",
+ "type": "object",
+ "properties": {
+ "app": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string",
+ "visibility": "frontend"
+ },
+ "title": {
+ "type": "string",
+ "visibility": "frontend"
+ },
+ "googleAnalyticsTrackingId": {
+ "type": "string",
+ "visibility": "frontend",
+ "description": "Tracking ID for Google Analytics",
+ "example": "UA-000000-0"
+ },
+ "listen": {
+ "type": "object",
+ "description": "Listening configuration for local development",
+ "properties": {
+ "host": {
+ "type": "number",
+ "visibility": "frontend",
+ "description": "The host that the frontend should be bound to. Only used for local development."
+ },
+ "post": {
+ "type": "number",
+ "visibility": "frontend",
+ "description": "The port that the frontend should be bound to. Only used for local development."
+ }
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts
new file mode 100644
index 0000000000..e604ae9733
--- /dev/null
+++ b/packages/cli/src/commands/backend/bundle.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { Command } from 'commander';
+import fs from 'fs-extra';
+import { createDistWorkspace } from '../../lib/packager';
+import { paths } from '../../lib/paths';
+import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
+
+const PKG_PATH = 'package.json';
+const TARGET_DIR = 'dist-workspace';
+
+export default async (cmd: Command) => {
+ const targetDir = paths.resolveTarget(TARGET_DIR);
+ const pkgPath = paths.resolveTarget(PKG_PATH);
+ const pkg = await fs.readJson(pkgPath);
+
+ await fs.remove(targetDir);
+ await fs.mkdir(targetDir);
+ await createDistWorkspace([pkg.name], {
+ targetDir: targetDir,
+ buildDependencies: Boolean(cmd.build),
+ parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
+ skeleton: 'skeleton.tar',
+ });
+};
diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts
index eb814a5bee..9649c70ef3 100644
--- a/packages/cli/src/commands/config/print.ts
+++ b/packages/cli/src/commands/config/print.ts
@@ -16,16 +16,52 @@
import { Command } from 'commander';
import { stringify as stringifyYaml } from 'yaml';
+import { AppConfig, ConfigReader } from '@backstage/config';
import { loadCliConfig } from '../../lib/config';
+import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader';
export default async (cmd: Command) => {
- const { config } = await loadCliConfig(cmd.config, cmd.withSecrets ?? false);
-
- const flatConfig = config.get();
+ const { schema, appConfigs } = await loadCliConfig(cmd.config);
+ const visibility = getVisiblityOption(cmd);
+ const data = serializeConfigData(appConfigs, schema, visibility);
if (cmd.format === 'json') {
- process.stdout.write(`${JSON.stringify(flatConfig, null, 2)}\n`);
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
} else {
- process.stdout.write(`${stringifyYaml(flatConfig)}\n`);
+ process.stdout.write(`${stringifyYaml(data)}\n`);
}
};
+
+function getVisiblityOption(cmd: Command): ConfigVisibility {
+ if (cmd.frontend && cmd.withSecrets) {
+ throw new Error('Not allowed to combine frontend and secret config');
+ }
+ if (cmd.frontend) {
+ return 'frontend';
+ } else if (cmd.withSecrets) {
+ return 'secret';
+ }
+ return 'backend';
+}
+
+function serializeConfigData(
+ appConfigs: AppConfig[],
+ schema: ConfigSchema,
+ visiblity: ConfigVisibility,
+) {
+ if (visiblity === 'frontend') {
+ const frontendConfigs = schema.process(appConfigs, {
+ visiblity: ['frontend'],
+ });
+ return ConfigReader.fromConfigs(frontendConfigs).get();
+ } else if (visiblity === 'secret') {
+ return ConfigReader.fromConfigs(appConfigs).get();
+ }
+
+ const sanitizedConfigs = schema.process(appConfigs, {
+ valueTransform: (value, { visibility }) =>
+ visibility === 'secret' ? '' : value,
+ });
+
+ return ConfigReader.fromConfigs(sanitizedConfigs).get();
+}
diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/cli/src/commands/config/validate.ts
similarity index 80%
rename from packages/cli/src/commands/plugin/export.ts
rename to packages/cli/src/commands/config/validate.ts
index 69955a2e5f..229f9e07e9 100644
--- a/packages/cli/src/commands/plugin/export.ts
+++ b/packages/cli/src/commands/config/validate.ts
@@ -15,13 +15,8 @@
*/
import { Command } from 'commander';
-import { buildBundle } from '../../lib/bundler';
import { loadCliConfig } from '../../lib/config';
export default async (cmd: Command) => {
- await buildBundle({
- entry: 'dev/index',
- statsJsonEnabled: cmd.stats,
- ...(await loadCliConfig(cmd.config)),
- });
+ await loadCliConfig(cmd.config);
};
diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts
index 7f028919f7..7302559498 100644
--- a/packages/cli/src/commands/index.ts
+++ b/packages/cli/src/commands/index.ts
@@ -44,6 +44,11 @@ export function registerCommands(program: CommanderStatic) {
.description('Build a backend plugin')
.action(lazy(() => import('./backend/build').then(m => m.default)));
+ program
+ .command('backend:__experimental__bundle__', { hidden: true })
+ .description('Bundle all backend packages into dist-workspace')
+ .action(lazy(() => import('./backend/bundle').then(m => m.default)));
+
program
.command('backend:build-image')
.allowUnknownOption(true)
@@ -103,13 +108,6 @@ export function registerCommands(program: CommanderStatic) {
.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
.command('plugin:diff')
.option('--check', 'Fail if changes are required')
@@ -143,6 +141,7 @@ export function registerCommands(program: CommanderStatic) {
program
.command('config:print')
+ .option('--frontend', 'Print only the frontend configuration')
.option('--with-secrets', 'Include secrets in the printed configuration')
.option(
'--format ',
@@ -152,6 +151,25 @@ export function registerCommands(program: CommanderStatic) {
.description('Print the app configuration for the current package')
.action(lazy(() => import('./config/print').then(m => m.default)));
+ program
+ .command('config:check')
+ .option(...configOption)
+ .description(
+ 'Validate that the given configuration loads and matches schema',
+ )
+ .action(lazy(() => import('./config/validate').then(m => m.default)));
+
+ program
+ .command('versions:bump')
+ .description('Bump Backstage packages to the latest versions')
+ .action(lazy(() => import('./versions/bump').then(m => m.default)));
+
+ program
+ .command('versions:check')
+ .option('--fix', 'Fix any auto-fixable versioning problems')
+ .description('Check Backstage package versioning')
+ .action(lazy(() => import('./versions/lint').then(m => m.default)));
+
program
.command('prepack')
.description('Prepares a package for packaging before publishing')
diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts
new file mode 100644
index 0000000000..687708b074
--- /dev/null
+++ b/packages/cli/src/commands/versions/bump.test.ts
@@ -0,0 +1,161 @@
+/*
+ * 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 fs from 'fs-extra';
+import mockFs from 'mock-fs';
+import { resolve as resolvePath } from 'path';
+import { paths } from '../../lib/paths';
+import { mapDependencies } from '../../lib/versioning';
+import * as runObj from '../../lib/run';
+import bump from './bump';
+import { withLogCollector } from '@backstage/test-utils';
+
+const REGISTRY_VERSIONS: { [name: string]: string } = {
+ '@backstage/core': '1.0.7',
+ '@backstage/theme': '2.0.0',
+};
+
+const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+`;
+
+const lockfileMock = `${HEADER}
+"@backstage/core@^1.0.5":
+ version "1.0.6"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+
+"@backstage/core@^1.0.3":
+ version "1.0.3"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+
+"@backstage/theme@^1.0.0":
+ version "1.0.0"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+`;
+
+const lockfileMockResult = `${HEADER}
+"@backstage/core@^1.0.3", "@backstage/core@^1.0.5":
+ version "1.0.6"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+
+"@backstage/theme@^1.0.0":
+ version "1.0.0"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+`;
+
+describe('bump', () => {
+ afterEach(() => {
+ mockFs.restore();
+ jest.resetAllMocks();
+ });
+
+ it('should bump backstage dependencies', async () => {
+ // Make sure all modules involved in package discovery are in the module cache before we mock fs
+ await mapDependencies(paths.targetDir);
+
+ mockFs({
+ '/yarn.lock': lockfileMock,
+ '/lerna.json': JSON.stringify({
+ packages: ['packages/*'],
+ }),
+ '/packages/a/package.json': JSON.stringify({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '^1.0.5',
+ },
+ }),
+ '/packages/b/package.json': JSON.stringify({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '^1.0.3',
+ '@backstage/theme': '^1.0.0',
+ },
+ }),
+ });
+
+ paths.targetDir = '/';
+ jest
+ .spyOn(paths, 'resolveTargetRoot')
+ .mockImplementation((...paths) => resolvePath('/', ...paths));
+ jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
+ JSON.stringify({
+ type: 'inspect',
+ data: {
+ name: name,
+ 'dist-tags': {
+ latest: REGISTRY_VERSIONS[name],
+ },
+ },
+ }),
+ );
+ jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
+
+ const { log: logs } = await withLogCollector(['log'], async () => {
+ await bump();
+ });
+ expect(logs.filter(Boolean)).toEqual([
+ 'Checking for updates of @backstage/theme',
+ 'Checking for updates of @backstage/core',
+ 'Some packages are outdated, updating',
+ 'Bumping @backstage/theme in b to ^2.0.0',
+ "Running 'yarn install' to install new versions",
+ 'Removing duplicate dependencies from yarn.lock',
+ "Running 'yarn install' to remove duplicates from node_modules",
+ ]);
+
+ expect(runObj.runPlain).toHaveBeenCalledTimes(2);
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ '@backstage/core',
+ );
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ '@backstage/theme',
+ );
+
+ expect(runObj.run).toHaveBeenCalledTimes(2);
+ expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']);
+
+ const lockfileContents = await fs.readFile('/yarn.lock', 'utf8');
+ expect(lockfileContents).toBe(lockfileMockResult);
+
+ const packageA = await fs.readJson('/packages/a/package.json');
+ expect(packageA).toEqual({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '^1.0.5', // not bumped since new version is within range
+ },
+ });
+ const packageB = await fs.readJson('/packages/b/package.json');
+ expect(packageB).toEqual({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '^1.0.3', // not bumped
+ '@backstage/theme': '^2.0.0', // bumped since newer
+ },
+ });
+ });
+});
diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts
new file mode 100644
index 0000000000..00944c5db9
--- /dev/null
+++ b/packages/cli/src/commands/versions/bump.ts
@@ -0,0 +1,162 @@
+/*
+ * 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 fs from 'fs-extra';
+import semver from 'semver';
+import { resolve as resolvePath } from 'path';
+import { run } from '../../lib/run';
+import { paths } from '../../lib/paths';
+import {
+ mapDependencies,
+ fetchPackageInfo,
+ Lockfile,
+} from '../../lib/versioning';
+
+const DEP_TYPES = [
+ 'dependencies',
+ 'devDependencies',
+ 'peerDependencies',
+ 'optionalDependencies',
+];
+
+type PkgVersionInfo = {
+ range: string;
+ name: string;
+ location: string;
+};
+
+export default async () => {
+ // First we discover all Backstage dependencies within our own repo
+ const dependencyMap = await mapDependencies(paths.targetDir);
+
+ // Next check with the package registry what the latest version of all of those dependencies are
+ const targetVersions = new Map();
+ await workerThreads(16, dependencyMap.keys(), async name => {
+ console.log(`Checking for updates of ${name}`);
+ const info = await fetchPackageInfo(name);
+ const latest = info['dist-tags'].latest;
+ if (!latest) {
+ throw new Error(`No latest version found for ${name}`);
+ }
+
+ targetVersions.set(name, latest);
+ });
+
+ // Then figure out which local packages need to have their dependencies bumped
+ const versionBumps = new Map();
+ for (const [name, pkgs] of dependencyMap) {
+ const targetVersion = targetVersions.get(name)!;
+ for (const pkg of pkgs) {
+ if (semver.satisfies(targetVersion, pkg.range)) {
+ continue;
+ }
+
+ versionBumps.set(
+ pkg.name,
+ (versionBumps.get(pkg.name) ?? []).concat({
+ name,
+ location: pkg.location,
+ range: `^${targetVersion}`, // TODO(Rugvip): Option to use something else than ^?
+ }),
+ );
+ }
+ }
+
+ console.log();
+
+ // Write all discovered version bumps to package.json in this repo
+ if (versionBumps.size === 0) {
+ console.log('All Backstage packages are up to date!');
+ } else {
+ console.log('Some packages are outdated, updating');
+ console.log();
+
+ await workerThreads(16, versionBumps.entries(), async ([name, deps]) => {
+ const pkgPath = resolvePath(deps[0].location, 'package.json');
+ const pkgJson = await fs.readJson(pkgPath);
+
+ for (const dep of deps) {
+ console.log(`Bumping ${dep.name} in ${name} to ${dep.range}`);
+
+ for (const depType of DEP_TYPES) {
+ if (depType in pkgJson && dep.name in pkgJson[depType]) {
+ pkgJson[depType][dep.name] = dep.range;
+ }
+ }
+ }
+
+ await fs.writeJson(pkgPath, pkgJson, { spaces: 2 });
+ });
+
+ console.log();
+ console.log("Running 'yarn install' to install new versions");
+ console.log();
+ await run('yarn', ['install']);
+ }
+
+ console.log();
+
+ // Finally we make sure the new lockfile doesn't have any duplicates
+ const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
+ const result = lockfile.analyze({
+ filter: name => dependencyMap.has(name),
+ });
+
+ if (result.newVersions.length > 0) {
+ console.log();
+ console.log('Removing duplicate dependencies from yarn.lock');
+ lockfile.replaceVersions(result.newVersions);
+ await lockfile.save();
+
+ console.log(
+ "Running 'yarn install' to remove duplicates from node_modules",
+ );
+ console.log();
+ await run('yarn', ['install']);
+
+ console.log();
+ }
+
+ if (result.newRanges.length > 0) {
+ throw new Error(
+ `Version bump failed for ${result.newRanges.map(i => i.name).join(', ')}`,
+ );
+ }
+};
+
+async function workerThreads(
+ count: number,
+ items: IterableIterator,
+ fn: (item: T) => Promise,
+) {
+ const queue = Array.from(items);
+
+ async function pop() {
+ const item = queue.pop();
+ if (!item) {
+ return;
+ }
+
+ await fn(item);
+ await pop();
+ }
+
+ return Promise.all(
+ Array(count)
+ .fill(0)
+ .map(() => pop()),
+ );
+}
diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts
new file mode 100644
index 0000000000..e2c36ba094
--- /dev/null
+++ b/packages/cli/src/commands/versions/lint.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Command } from 'commander';
+import { Lockfile } from '../../lib/versioning';
+import { paths } from '../../lib/paths';
+import partition from 'lodash/partition';
+
+// Packages that we try to avoid duplicates for
+const INCLUDED = [/^@backstage\//];
+
+// Packages that are not allowed to have any duplicates
+const FORBID_DUPLICATES = [
+ /^@backstage\/core$/,
+ /^@backstage\/core-api$/,
+ /^@backstage\/plugin-/,
+];
+
+export default async (cmd: Command) => {
+ const fix = Boolean(cmd.fix);
+
+ let success = true;
+
+ const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
+ const result = lockfile.analyze({
+ filter: name => INCLUDED.some(pattern => pattern.test(name)),
+ });
+
+ logArray(
+ result.invalidRanges,
+ "The following packages versions are invalid and can't be analyzed:",
+ e => ` ${e.name} @ ${e.range}`,
+ );
+
+ if (fix) {
+ lockfile.replaceVersions(result.newVersions);
+ await lockfile.save();
+ } else {
+ const [
+ newVersionsForbidden,
+ newVersionsAllowed,
+ ] = partition(result.newVersions, ({ name }) =>
+ FORBID_DUPLICATES.some(pattern => pattern.test(name)),
+ );
+ if (newVersionsForbidden.length && !fix) {
+ success = false;
+ }
+
+ logArray(
+ newVersionsForbidden,
+ 'The following packages must be deduplicated, this can be done automatically with --fix',
+ e =>
+ ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`,
+ );
+ logArray(
+ newVersionsAllowed,
+ 'The following packages can be deduplicated, this can be done automatically with --fix',
+ e =>
+ ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`,
+ );
+ }
+
+ const [newRangesForbidden, newRangesAllowed] = partition(
+ result.newRanges,
+ ({ name }) => FORBID_DUPLICATES.some(pattern => pattern.test(name)),
+ );
+ if (newRangesForbidden.length) {
+ success = false;
+ }
+
+ logArray(
+ newRangesForbidden,
+ 'The following packages must be deduplicated by updating dependencies in package.json',
+ e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`,
+ );
+ logArray(
+ newRangesAllowed,
+ 'The following packages can be deduplicated by updating dependencies in package.json',
+ e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`,
+ );
+
+ if (!success) {
+ throw new Error('Failed versioning check');
+ }
+};
+
+function logArray(arr: T[], header: string, each: (item: T) => string) {
+ if (arr.length === 0) {
+ return;
+ }
+
+ console.log(header);
+ console.log();
+ for (const e of arr) {
+ console.log(each(e));
+ }
+ console.log();
+}
diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts
index 8746c807e7..30fef2e8b7 100644
--- a/packages/cli/src/lib/bundler/bundle.ts
+++ b/packages/cli/src/lib/bundler/bundle.ts
@@ -33,14 +33,14 @@ const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
export async function buildBundle(options: BuildOptions) {
- const { statsJsonEnabled } = options;
+ const { statsJsonEnabled, schema: configSchema } = options;
const paths = resolveBundlingPaths(options);
const config = await createConfig(paths, {
...options,
checksEnabled: false,
isDev: false,
- baseUrl: resolveBaseUrl(options.config),
+ baseUrl: resolveBaseUrl(options.frontendConfig),
});
const compiler = webpack(config);
@@ -56,6 +56,14 @@ export async function buildBundle(options: BuildOptions) {
});
}
+ if (configSchema) {
+ await fs.writeJson(
+ resolvePath(paths.targetDist, '.config-schema.json'),
+ configSchema.serialize(),
+ { spaces: 2 },
+ );
+ }
+
const { stats } = await build(compiler, isCi).catch(error => {
console.log(chalk.red('Failed to compile.\n'));
throw new Error(`Failed to compile.\n${error.message || error}`);
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index 34b9f498ad..b5a27dd624 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -70,15 +70,29 @@ async function readBuildInfo() {
};
}
+async function loadLernaPackages(): Promise<
+ { name: string; location: string }[]
+> {
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(cliPaths.targetDir);
+ return project.getPackages();
+}
+
export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
): Promise {
- const { checksEnabled, isDev } = options;
+ const { checksEnabled, isDev, frontendConfig } = options;
- const { plugins, loaders } = transforms(options);
+ const packages = await loadLernaPackages();
+ const { plugins, loaders } = transforms({
+ ...options,
+ externalTransforms: packages.map(({ name }) =>
+ cliPaths.resolveTargetRoot('node_modules', name),
+ ),
+ });
- const baseUrl = options.config.getString('app.baseUrl');
+ const baseUrl = frontendConfig.getString('app.baseUrl');
const validBaseUrl = new URL(baseUrl);
if (checksEnabled) {
@@ -99,7 +113,7 @@ export async function createConfig(
plugins.push(
new webpack.EnvironmentPlugin({
- APP_CONFIG: options.appConfigs,
+ APP_CONFIG: options.frontendAppConfigs,
}),
);
@@ -109,9 +123,9 @@ export async function createConfig(
templateParameters: {
publicPath: validBaseUrl.pathname.replace(/\/$/, ''),
app: {
- title: options.config.getString('app.title'),
+ title: frontendConfig.getString('app.title'),
baseUrl: validBaseUrl.href,
- googleAnalyticsTrackingId: options.config.getOptionalString(
+ googleAnalyticsTrackingId: frontendConfig.getOptionalString(
'app.googleAnalyticsTrackingId',
),
},
@@ -159,6 +173,10 @@ export async function createConfig(
alias: {
'react-dom': '@hot-loader/react-dom',
},
+ // Enables proper resolution of packages when linking in external packages.
+ // Without this the packages would depend on dependencies in the node_modules
+ // of the external packages themselves, leading to module duplication
+ symlinks: false,
},
module: {
rules: loaders,
@@ -181,17 +199,20 @@ export async function createBackendConfig(
): Promise {
const { checksEnabled, isDev } = options;
- const { loaders } = transforms(options);
-
// Find all local monorepo packages and their node_modules, and mark them as external.
- const LernaProject = require('@lerna/project');
- const project = new LernaProject(cliPaths.targetDir);
- const packages = await project.getPackages();
+ const packages = await await loadLernaPackages();
const localPackageNames = packages.map((p: any) => p.name);
const moduleDirs = packages.map((p: any) =>
resolvePath(p.location, 'node_modules'),
);
+ const { loaders } = transforms({
+ ...options,
+ externalTransforms: packages.map(({ name }) =>
+ cliPaths.resolveTargetRoot('node_modules', name),
+ ),
+ });
+
return {
mode: isDev ? 'development' : 'production',
profile: false,
@@ -240,6 +261,7 @@ export async function createBackendConfig(
alias: {
'react-dom': '@hot-loader/react-dom',
},
+ symlinks: false, // See frontend config, added here for the same reason
},
module: {
rules: loaders,
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index e1fb560e22..d198f5507c 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -23,9 +23,14 @@ import { ServeOptions } from './types';
import { resolveBundlingPaths } from './paths';
export async function serveBundle(options: ServeOptions) {
- const url = resolveBaseUrl(options.config);
+ const url = resolveBaseUrl(options.frontendConfig);
- const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
+ const host =
+ options.frontendConfig.getOptionalString('app.listen.host') || url.hostname;
+ const port =
+ options.frontendConfig.getOptionalNumber('app.listen.port') ||
+ Number(url.port) ||
+ (url.protocol === 'https:' ? 443 : 80);
const paths = resolveBundlingPaths(options);
const pkgPath = paths.targetPackageJson;
@@ -50,7 +55,7 @@ export async function serveBundle(options: ServeOptions) {
clientLogLevel: 'warning',
stats: 'errors-warnings',
https: url.protocol === 'https:',
- host: url.hostname,
+ host,
port,
proxy: pkg.proxy,
});
diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts
index d8186dac66..6dc32e6563 100644
--- a/packages/cli/src/lib/bundler/transforms.ts
+++ b/packages/cli/src/lib/bundler/transforms.ts
@@ -16,7 +16,6 @@
import webpack, { Module, Plugin } from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
-import { BundlingOptions, BackendBundlingOptions } from './types';
import { svgrTemplate } from '../svgrTemplate';
type Transforms = {
@@ -24,17 +23,25 @@ type Transforms = {
plugins: Plugin[];
};
-export const transforms = (
- options: BundlingOptions | BackendBundlingOptions,
-): Transforms => {
- const { isDev } = options;
+type TransformOptions = {
+ isDev: boolean;
+ // External paths that should be transformed
+ externalTransforms: string[];
+};
+
+export const transforms = (options: TransformOptions): Transforms => {
+ const { isDev, externalTransforms } = options;
const extraTransforms = isDev ? ['react-hot-loader'] : [];
+ const transformExcludeCondition = {
+ and: [/node_modules/, { not: externalTransforms }],
+ };
+
const loaders = [
{
test: /\.(tsx?)$/,
- exclude: /node_modules/,
+ exclude: transformExcludeCondition,
loader: require.resolve('@sucrase/webpack-loader'),
options: {
transforms: ['typescript', 'jsx', ...extraTransforms],
@@ -43,7 +50,7 @@ export const transforms = (
},
{
test: /\.(jsx?|mjs)$/,
- exclude: /node_modules/,
+ exclude: transformExcludeCondition,
loader: require.resolve('@sucrase/webpack-loader'),
options: {
transforms: ['jsx', ...extraTransforms],
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
index 00f1895725..71343a1761 100644
--- a/packages/cli/src/lib/bundler/types.ts
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -17,27 +17,29 @@
import { AppConfig, Config } from '@backstage/config';
import { BundlingPathsOptions } from './paths';
import { ParallelOption } from '../parallel';
+import { ConfigSchema } from '@backstage/config-loader';
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
- config: Config;
- appConfigs: AppConfig[];
+ frontendConfig: Config;
+ frontendAppConfigs: AppConfig[];
baseUrl: URL;
parallel?: ParallelOption;
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
- config: Config;
- appConfigs: AppConfig[];
+ frontendConfig: Config;
+ frontendAppConfigs: AppConfig[];
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
parallel?: ParallelOption;
- config: Config;
- appConfigs: AppConfig[];
+ schema?: ConfigSchema;
+ frontendConfig: Config;
+ frontendAppConfigs: AppConfig[];
};
export type BackendBundlingOptions = {
diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts
index 5801632ea5..08a8193818 100644
--- a/packages/cli/src/lib/config.ts
+++ b/packages/cli/src/lib/config.ts
@@ -14,18 +14,23 @@
* limitations under the License.
*/
-import { loadConfig } from '@backstage/config-loader';
+import { loadConfig, loadConfigSchema } from '@backstage/config-loader';
import { ConfigReader } from '@backstage/config';
import { paths } from './paths';
-export async function loadCliConfig(
- configArgs: string[],
- shouldReadSecrets: boolean = false,
-) {
+export async function loadCliConfig(configArgs: string[]) {
const configPaths = configArgs.map(arg => paths.resolveTarget(arg));
+ // Consider all packages in the monorepo when loading in config
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(paths.targetDir);
+ const packages = await project.getPackages();
+ const localPackageNames = packages.map((p: any) => p.name);
+ const schema = await loadConfigSchema({
+ dependencies: localPackageNames,
+ });
+
const appConfigs = await loadConfig({
- shouldReadSecrets,
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
configRoot: paths.targetRoot,
configPaths,
@@ -35,8 +40,24 @@ export async function loadCliConfig(
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
);
- return {
- appConfigs,
- config: ConfigReader.fromConfigs(appConfigs),
- };
+ try {
+ const frontendAppConfigs = schema.process(appConfigs, {
+ visiblity: ['frontend'],
+ });
+ const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
+
+ return {
+ schema,
+ appConfigs,
+ frontendConfig,
+ frontendAppConfigs,
+ };
+ } catch (error) {
+ const maybeSchemaError = error as Error & { messages?: string[] };
+ if (maybeSchemaError.messages) {
+ const messages = maybeSchemaError.messages.join('\n ');
+ throw new Error(`Configuration does not match schema\n\n ${messages}`);
+ }
+ throw error;
+ }
}
diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts
index 40e3d4c587..a210986b2a 100644
--- a/packages/cli/src/lib/diff/handlers.ts
+++ b/packages/cli/src/lib/diff/handlers.ts
@@ -61,7 +61,7 @@ class PackageJsonHandler {
await this.syncField('main:src');
}
await this.syncField('types');
- await this.syncField('files');
+ await this.syncFiles();
await this.syncScripts();
await this.syncPublishConfig();
await this.syncDependencies('dependencies');
@@ -105,6 +105,15 @@ class PackageJsonHandler {
}
}
+ private async syncFiles() {
+ if (typeof this.targetPkg.configSchema === 'string') {
+ const files = [...this.pkg.files, this.targetPkg.configSchema];
+ await this.syncField('files', { files });
+ } else {
+ await this.syncField('files');
+ }
+ }
+
private async syncScripts() {
const pkgScripts = this.pkg.scripts;
const targetScripts = (this.targetPkg.scripts =
diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts
index 56f1ee453c..8653e66d37 100644
--- a/packages/cli/src/lib/packager/index.ts
+++ b/packages/cli/src/lib/packager/index.ts
@@ -24,8 +24,16 @@ import { tmpdir } from 'os';
import tar, { CreateOptions } from 'tar';
import { paths } from '../paths';
import { run } from '../run';
+import { packageVersions } from '../version';
import { ParallelOption } from '../parallel';
+// These packages aren't safe to pack in parallel since the CLI depends on them
+const UNSAFE_PACKAGES = [
+ ...Object.keys(packageVersions),
+ '@backstage/cli-common',
+ '@backstage/config-loader',
+];
+
type LernaPackage = {
name: string;
private: boolean;
@@ -135,51 +143,68 @@ async function moveToDistWorkspace(
workspaceDir: string,
localPackages: LernaPackage[],
): Promise {
+ async function pack(target: LernaPackage, archive: string) {
+ console.log(`Repacking ${target.name} into dist workspace`);
+ const archivePath = resolvePath(workspaceDir, archive);
+
+ await run('yarn', ['pack', '--filename', archivePath], {
+ cwd: target.location,
+ });
+ // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed
+ if (target.scripts.postpack) {
+ await run('yarn', ['postpack'], { cwd: target.location });
+ }
+
+ const outputDir = relativePath(paths.targetRoot, target.location);
+ const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
+ await fs.ensureDir(absoluteOutputPath);
+
+ await tar.extract({
+ file: archivePath,
+ cwd: absoluteOutputPath,
+ strip: 1,
+ });
+ await fs.remove(archivePath);
+
+ // We remove the dependencies from package.json of packages that are marked
+ // as bundled, so that yarn doesn't try to install them.
+ if (target.get('bundled')) {
+ const pkgJson = await fs.readJson(
+ resolvePath(absoluteOutputPath, 'package.json'),
+ );
+ delete pkgJson.dependencies;
+ delete pkgJson.devDependencies;
+ delete pkgJson.peerDependencies;
+ delete pkgJson.optionalDependencies;
+
+ await fs.writeJson(
+ resolvePath(absoluteOutputPath, 'package.json'),
+ pkgJson,
+ {
+ spaces: 2,
+ },
+ );
+ }
+ }
+
+ const unsafePackages = localPackages.filter(p =>
+ UNSAFE_PACKAGES.includes(p.name),
+ );
+ const safePackages = localPackages.filter(
+ p => !UNSAFE_PACKAGES.includes(p.name),
+ );
+
+ // The unsafe package are packed first one by one in order to avoid race conditions
+ // where the CLI is being executed with broken dependencies.
+ for (const target of unsafePackages) {
+ await pack(target, `temp-package.tgz`);
+ }
+
+ // Repacking in parallel is much faster and safe for all packages outside of the Backstage repo
await Promise.all(
- localPackages.map(async (target, index) => {
- console.log(`Repacking ${target.name} into dist workspace`);
- const archive = `temp-package-${index}.tgz`;
- const archivePath = resolvePath(workspaceDir, archive);
-
- await run('yarn', ['pack', '--filename', archivePath], {
- cwd: target.location,
- });
- // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed
- if (target.scripts.postpack) {
- await run('yarn', ['postpack'], { cwd: target.location });
- }
-
- const outputDir = relativePath(paths.targetRoot, target.location);
- const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
- await fs.ensureDir(absoluteOutputPath);
-
- await tar.extract({
- file: archivePath,
- cwd: absoluteOutputPath,
- strip: 1,
- });
- await fs.remove(archivePath);
-
- // We remove the dependencies from package.json of packages that are marked
- // as bundled, so that yarn doesn't try to install them.
- if (target.get('bundled')) {
- const pkgJson = await fs.readJson(
- resolvePath(absoluteOutputPath, 'package.json'),
- );
- delete pkgJson.dependencies;
- delete pkgJson.devDependencies;
- delete pkgJson.peerDependencies;
- delete pkgJson.optionalDependencies;
-
- await fs.writeJson(
- resolvePath(absoluteOutputPath, 'package.json'),
- pkgJson,
- {
- spaces: 2,
- },
- );
- }
- }),
+ safePackages.map(async (target, index) =>
+ pack(target, `temp-package-${index}.tgz`),
+ ),
);
}
diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts
new file mode 100644
index 0000000000..89126c7ce1
--- /dev/null
+++ b/packages/cli/src/lib/versioning/Lockfile.test.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
+import { Lockfile } from './Lockfile';
+
+const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+`;
+
+const mockA = `${HEADER}
+a@^1:
+ version "1.0.1"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+ dependencies:
+ b "^2"
+
+b@2.0.x:
+ version "2.0.1"
+
+b@^2:
+ version "2.0.0"
+`;
+
+const mockADedup = `${HEADER}
+a@^1:
+ version "1.0.1"
+ resolved "https://my-registry/a-1.0.01.tgz#abc123"
+ integrity sha512-xyz
+ dependencies:
+ b "^2"
+
+b@2.0.x, b@^2:
+ version "2.0.1"
+`;
+
+const mockB = `${HEADER}
+"@s/a@*", "@s/a@1 || 2", "@s/a@^1":
+ version "1.0.1"
+
+"@s/a@^2.0.x":
+ version "2.0.0"
+`;
+
+const mockBDedup = `${HEADER}
+"@s/a@*", "@s/a@1 || 2", "@s/a@^2.0.x":
+ version "2.0.0"
+
+"@s/a@^1":
+ version "1.0.1"
+`;
+
+describe('Lockfile', () => {
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should load and serialize mockA', async () => {
+ mockFs({
+ '/yarn.lock': mockA,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ expect(lockfile.get('a')).toEqual([{ range: '^1', version: '1.0.1' }]);
+ expect(lockfile.get('b')).toEqual([
+ { range: '2.0.x', version: '2.0.1' },
+ { range: '^2', version: '2.0.0' },
+ ]);
+ expect(lockfile.toString()).toBe(mockA);
+ });
+
+ it('should deduplicate and save mockA', async () => {
+ mockFs({
+ '/yarn.lock': mockA,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ const result = lockfile.analyze();
+ expect(result).toEqual({
+ invalidRanges: [],
+ newRanges: [],
+ newVersions: [
+ {
+ name: 'b',
+ range: '^2',
+ oldVersion: '2.0.0',
+ newVersion: '2.0.1',
+ },
+ ],
+ });
+
+ expect(lockfile.toString()).toBe(mockA);
+ lockfile.replaceVersions(result.newVersions);
+ expect(lockfile.toString()).toBe(mockADedup);
+
+ await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA);
+ await expect(lockfile.save()).resolves.toBeUndefined();
+ await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup);
+ });
+
+ it('should deduplicate mockB', async () => {
+ mockFs({
+ '/yarn.lock': mockB,
+ });
+
+ const lockfile = await Lockfile.load('/yarn.lock');
+ const result = lockfile.analyze();
+ expect(result).toEqual({
+ invalidRanges: [],
+ newRanges: [
+ {
+ name: '@s/a',
+ oldRange: '^1',
+ newRange: '^2.0.x',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ ],
+ newVersions: [
+ {
+ name: '@s/a',
+ range: '*',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ {
+ name: '@s/a',
+ range: '1 || 2',
+ oldVersion: '1.0.1',
+ newVersion: '2.0.0',
+ },
+ ],
+ });
+
+ expect(lockfile.toString()).toBe(mockB);
+ lockfile.replaceVersions(result.newVersions);
+ expect(lockfile.toString()).toBe(mockBDedup);
+ });
+});
diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts
new file mode 100644
index 0000000000..893beca6e2
--- /dev/null
+++ b/packages/cli/src/lib/versioning/Lockfile.ts
@@ -0,0 +1,253 @@
+/*
+ * 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 fs from 'fs-extra';
+import semver from 'semver';
+import {
+ parse as parseLockfile,
+ stringify as stringifyLockfile,
+} from '@yarnpkg/lockfile';
+
+const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
+
+type LockfileData = {
+ [entry: string]: {
+ version: string;
+ resolved?: string;
+ integrity?: string;
+ dependencies?: { [name: string]: string };
+ };
+};
+
+type LockfileQueryEntry = {
+ range: string;
+ version: string;
+};
+
+/** Entries that have an invalid version range, for example an NPM tag */
+type AnalyzeResultInvalidRange = {
+ name: string;
+ range: string;
+};
+
+/** Entries that can be deduplicated by bumping to an existing higher version */
+type AnalyzeResultNewVersion = {
+ name: string;
+ range: string;
+ oldVersion: string;
+ newVersion: string;
+};
+
+/** Entries that would need a dependency update in package.json to be deduplicated */
+type AnalyzeResultNewRange = {
+ name: string;
+ oldRange: string;
+ newRange: string;
+ oldVersion: string;
+ newVersion: string;
+};
+
+type AnalyzeResult = {
+ invalidRanges: AnalyzeResultInvalidRange[];
+ newVersions: AnalyzeResultNewVersion[];
+ newRanges: AnalyzeResultNewRange[];
+};
+
+export class Lockfile {
+ static async load(path: string) {
+ const lockfileContents = await fs.readFile(path, 'utf8');
+ const lockfile = parseLockfile(lockfileContents);
+ if (lockfile.type !== 'success') {
+ throw new Error(`Failed yarn.lock parse with ${lockfile.type}`);
+ }
+
+ const data = lockfile.object as LockfileData;
+ const packages = new Map();
+
+ for (const [key, value] of Object.entries(data)) {
+ const [, name, range] = ENTRY_PATTERN.exec(key) ?? [];
+ if (!name) {
+ throw new Error(`Failed to parse yarn.lock entry '${key}'`);
+ }
+
+ let queries = packages.get(name);
+ if (!queries) {
+ queries = [];
+ packages.set(name, queries);
+ }
+ queries.push({ range, version: value.version });
+ }
+
+ return new Lockfile(path, packages, data);
+ }
+
+ private constructor(
+ private readonly path: string,
+ private readonly packages: Map,
+ private readonly data: LockfileData,
+ ) {}
+
+ get(name: string): LockfileQueryEntry[] | undefined {
+ return this.packages.get(name);
+ }
+
+ /** Analyzes the lockfile to identify possible actions and warnings for the entries */
+ analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult {
+ const { filter } = options ?? {};
+ const result: AnalyzeResult = {
+ invalidRanges: [],
+ newVersions: [],
+ newRanges: [],
+ };
+
+ for (const [name, allEntries] of this.packages) {
+ if (filter && !filter(name)) {
+ continue;
+ }
+
+ // Get rid of and signal any invalid ranges upfront
+ const invalid = allEntries.filter(e => !semver.validRange(e.range));
+ result.invalidRanges.push(
+ ...invalid.map(({ range }) => ({ name, range })),
+ );
+
+ // Grab all valid entries, if there aren't at least 2 different valid ones we're done
+ const entries = allEntries.filter(e => semver.validRange(e.range));
+ if (entries.length < 2) {
+ continue;
+ }
+
+ // Find all versions currently in use
+ const versions = Array.from(
+ new Set(entries.map(e => e.version)),
+ ).sort((v1, v2) => semver.rcompare(v1, v2));
+
+ // If we're not using at least 2 different versions we're done
+ if (versions.length < 2) {
+ continue;
+ }
+
+ const acceptedVersions = new Set();
+ for (const { version, range } of entries) {
+ // Finds the highest matching version from the the known versions
+ // TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one
+ const acceptedVersion = versions.find(v => semver.satisfies(v, range));
+ if (!acceptedVersion) {
+ throw new Error(
+ `No existing version was accepted for range ${range}, searching through ${versions}`,
+ );
+ }
+
+ if (acceptedVersion !== version) {
+ result.newVersions.push({
+ name,
+ range,
+ newVersion: acceptedVersion,
+ oldVersion: version,
+ });
+ }
+
+ acceptedVersions.add(acceptedVersion);
+ }
+
+ // If all ranges were able to accept the same version, we're done
+ if (acceptedVersions.size === 1) {
+ continue;
+ }
+
+ // Find the max version that we may want bump older packages to
+ const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0];
+ // Find all existing ranges that satisfy the new max version, and pick the one that
+ // results in the highest minimum allowed version, usually being the more specific one
+ const maxEntry = entries
+ .filter(e => semver.satisfies(maxVersion, e.range))
+ .map(e => ({ e, min: semver.minVersion(e.range) }))
+ .filter(p => p.min)
+ .sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e;
+ if (!maxEntry) {
+ throw new Error(
+ `No entry found that satisfies max version '${maxVersion}'`,
+ );
+ }
+
+ // Find all entries that don't satisfy the max version
+ for (const { version, range } of entries) {
+ if (semver.satisfies(maxVersion, range)) {
+ continue;
+ }
+
+ result.newRanges.push({
+ name,
+ oldRange: range,
+ newRange: maxEntry.range,
+ oldVersion: version,
+ newVersion: maxVersion,
+ });
+ }
+ }
+
+ return result;
+ }
+
+ /** Modifies the lockfile by bumping packages to the suggested versions */
+ replaceVersions(results: AnalyzeResultNewVersion[]) {
+ for (const { name, range, oldVersion, newVersion } of results) {
+ const query = `${name}@${range}`;
+
+ // Update the backing data
+ const entryData = this.data[query];
+ if (!entryData) {
+ throw new Error(`No entry data for ${query}`);
+ }
+ if (entryData.version !== oldVersion) {
+ throw new Error(
+ `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
+ );
+ }
+
+ // Modifying the data in the entry is not enough, we need to reference an existing version object
+ const matchingEntry = Object.entries(this.data).find(
+ ([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion,
+ );
+ if (!matchingEntry) {
+ throw new Error(
+ `No matching entry found for ${name} at version ${newVersion}`,
+ );
+ }
+ this.data[query] = matchingEntry[1];
+
+ // Update our internal data structure
+ const entry = this.packages.get(name)?.find(e => e.range === range);
+ if (!entry) {
+ throw new Error(`No entry data for ${query}`);
+ }
+ if (entry.version !== oldVersion) {
+ throw new Error(
+ `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
+ );
+ }
+ entry.version = newVersion;
+ }
+ }
+
+ async save() {
+ await fs.writeFile(this.path, this.toString(), 'utf8');
+ }
+
+ toString() {
+ return stringifyLockfile(this.data);
+ }
+}
diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/packages/cli/src/lib/versioning/index.ts
similarity index 76%
rename from plugins/cost-insights/src/components/Tooltip/index.ts
rename to packages/cli/src/lib/versioning/index.ts
index b26409480e..71fb7647ce 100644
--- a/plugins/cost-insights/src/components/Tooltip/index.ts
+++ b/packages/cli/src/lib/versioning/index.ts
@@ -14,7 +14,5 @@
* limitations under the License.
*/
-export { Tooltip } from './Tooltip';
-export type { TooltipProps } from './Tooltip';
-export { TooltipItem } from './TooltipItem';
-export type { TooltipItemProps } from './TooltipItem';
+export { Lockfile } from './Lockfile';
+export { fetchPackageInfo, mapDependencies } from './packages';
diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts
new file mode 100644
index 0000000000..8155d4b5c0
--- /dev/null
+++ b/packages/cli/src/lib/versioning/packages.test.ts
@@ -0,0 +1,109 @@
+/*
+ * 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 * as runObj from '../run';
+import { paths } from '../paths';
+import { fetchPackageInfo, mapDependencies } from './packages';
+
+describe('fetchPackageInfo', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should forward info', async () => {
+ jest
+ .spyOn(runObj, 'runPlain')
+ .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
+
+ await expect(fetchPackageInfo('my-package')).resolves.toEqual({
+ the: 'data',
+ });
+ expect(runObj.runPlain).toHaveBeenCalledWith(
+ 'yarn',
+ 'info',
+ '--json',
+ 'my-package',
+ );
+ });
+});
+
+describe('mapDependencies', () => {
+ afterEach(() => {
+ mockFs.restore();
+ jest.resetAllMocks();
+ });
+
+ it('should read dependencies', async () => {
+ // Make sure all modules involved in package discovery are in the module cache before we mock fs
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(paths.targetDir);
+ await project.getPackages();
+
+ mockFs({
+ '/lerna.json': JSON.stringify({
+ packages: ['pkgs/*'],
+ }),
+ '/pkgs/a/package.json': JSON.stringify({
+ name: 'a',
+ dependencies: {
+ '@backstage/core': '1 || 2',
+ },
+ }),
+ '/pkgs/b/package.json': JSON.stringify({
+ name: 'b',
+ dependencies: {
+ '@backstage/core': '3',
+ '@backstage/cli': '^0',
+ },
+ }),
+ });
+
+ const oldDir = paths.targetDir;
+ paths.targetDir = '/';
+
+ const dependencyMap = await mapDependencies(paths.targetDir);
+ expect(Array.from(dependencyMap)).toEqual([
+ [
+ '@backstage/core',
+ [
+ {
+ name: 'a',
+ range: '1 || 2',
+ location: '/pkgs/a',
+ },
+ {
+ name: 'b',
+ range: '3',
+ location: '/pkgs/b',
+ },
+ ],
+ ],
+ [
+ '@backstage/cli',
+ [
+ {
+ name: 'b',
+ range: '^0',
+ location: '/pkgs/b',
+ },
+ ],
+ ],
+ ]);
+
+ paths.targetDir = oldDir;
+ });
+});
diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts
new file mode 100644
index 0000000000..76e5dc49b0
--- /dev/null
+++ b/packages/cli/src/lib/versioning/packages.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { runPlain } from '../../lib/run';
+
+const PREFIX = '@backstage';
+
+const DEP_TYPES = [
+ 'dependencies',
+ 'devDependencies',
+ 'peerDependencies',
+ 'optionalDependencies',
+];
+
+// Package data as returned by `yarn info`
+type YarnInfoInspectData = {
+ name: string;
+ 'dist-tags': { latest: string };
+ versions: string[];
+ time: { [version: string]: string };
+};
+
+// Possible `yarn info` output
+type YarnInfo = {
+ type: 'inspect';
+ data: YarnInfoInspectData | { type: string; data: unknown };
+};
+
+type PkgVersionInfo = {
+ range: string;
+ name: string;
+ location: string;
+};
+
+export async function fetchPackageInfo(
+ name: string,
+): Promise {
+ const output = await runPlain('yarn', 'info', '--json', name);
+ const info = JSON.parse(output) as YarnInfo;
+ if (info.type !== 'inspect') {
+ throw new Error(`Received unknown yarn info for ${name}, ${output}`);
+ }
+
+ return info.data as YarnInfoInspectData;
+}
+
+/** Map all dependencies in the repo as dependency => dependents */
+export async function mapDependencies(
+ targetDir: string,
+): Promise