See what plugins are already{' '}
-
+
in progress
{' '}
and 👍. Missing a plugin for your favorite tool? Please{' '}
-
+
suggest
{' '}
a new one.
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index 7081e2bb1c..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": [
@@ -28,6 +29,7 @@
"label": "Deployment",
"ids": [
"getting-started/deployment-k8s",
+ "getting-started/deployment-helm",
"getting-started/deployment-other"
]
}
@@ -44,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"
@@ -163,7 +166,7 @@
"architecture-decisions/adrs-adr009"
],
"Contribute": ["../CONTRIBUTING"],
- "Support": ["overview/support"],
+ "Support": ["support/support", "support/project-structure"],
"FAQ": ["FAQ"]
}
}
diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js
index d034321d6c..6e8f8edddf 100644
--- a/microsite/siteConfig.js
+++ b/microsite/siteConfig.js
@@ -17,7 +17,7 @@ const siteConfig = {
url: 'https://backstage.io', // Your website URL
cname: 'backstage.io',
baseUrl: '/', // Base URL for your project */
- editUrl: 'https://github.com/spotify/backstage/edit/master/docs/',
+ editUrl: 'https://github.com/backstage/backstage/edit/master/docs/',
// Used for publishing and more
projectName: 'backstage',
@@ -30,7 +30,7 @@ const siteConfig = {
// For no header links in the top nav bar -> headerLinks: [],
headerLinks: [
{
- href: 'https://github.com/spotify/backstage',
+ href: 'https://github.com/backstage/backstage',
label: 'GitHub',
},
{
@@ -60,7 +60,7 @@ const siteConfig = {
/* path to images for header/footer */
// headerIcon: "img/android-chrome-192x192.png",
footerIcon: 'img/android-chrome-192x192.png',
- favicon: 'img/favicon.svg',
+ favicon: 'img/favicon.ico',
/* Colors for website */
colors: {
@@ -78,7 +78,7 @@ const siteConfig = {
},
// This copyright info is used in /core/Footer.js and blog RSS/Atom feeds.
- copyright: `Copyright © ${new Date().getFullYear()} Spotify AB`,
+ copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`,
highlight: {
// Highlight.js theme to use for syntax highlighting in code blocks.
@@ -111,7 +111,7 @@ const siteConfig = {
// You may provide arbitrary config keys to be used as needed by your
// template. For example, if you need your repo's URL...
- repoUrl: 'https://github.com/spotify/backstage',
+ repoUrl: 'https://github.com/backstage/backstage',
twitterUsername: 'SpotifyEng',
stylesheets: [
diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css
index 738478724f..22855a2b4d 100644
--- a/microsite/static/css/custom.css
+++ b/microsite/static/css/custom.css
@@ -15,9 +15,6 @@ table tr:nth-child(2n) {
background-color: #2b2b2b;
}
-@media only screen and (min-device-width: 360px) and (max-device-width: 736px) {
-}
-
@media only screen and (min-width: 1024px) and (max-width: 1500px) {
/* Experience */
.content-experience {
@@ -857,7 +854,6 @@ code {
position: relative;
padding-top: 75%;
width: 100%;
- /* border: 1px dotted #f00; */
}
.Block__Graphic {
@@ -1084,3 +1080,17 @@ code {
margin-bottom: 40px;
margin-top: 20px;
}
+
+.nav-footer .copyright {
+ width: 600px;
+ color: #fff;
+ margin: 0 auto;
+ font-size: 10pt;
+}
+
+@media only screen and (max-width: 485px) {
+ .nav-footer .copyright {
+ width: auto;
+ margin: 0 1.5em;
+ }
+}
diff --git a/microsite/static/css/plugins.css b/microsite/static/css/plugins.css
index 9a8bbafa46..8a0d843dc9 100644
--- a/microsite/static/css/plugins.css
+++ b/microsite/static/css/plugins.css
@@ -6,7 +6,7 @@
flex-direction: column;
}
-.grid {
+.PluginGrid {
display: grid;
grid-gap: 1rem;
grid-template-columns: repeat(4, 1fr);
@@ -15,17 +15,23 @@
}
@media (max-width: 1200px) {
- .grid {
+ .PluginGrid {
grid-template-columns: repeat(3, 1fr);
}
}
@media only screen and (max-width: 815px) {
- .grid {
+ .PluginGrid {
grid-template-columns: repeat(2, 1fr);
}
}
+@media only screen and (max-width: 485px) {
+ .PluginGrid {
+ grid-template-columns: 1fr;
+ }
+}
+
.PluginCard img {
float: left;
margin: 0px 16px 8px 0px;
@@ -34,14 +40,32 @@
}
.PluginCardHeader {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
max-height: fit-content;
min-height: fit-content;
}
+.PluginCardImage {
+ width: 80px;
+ height: 80px;
+ margin-right: 16px;
+}
+
+.PluginCardImage img {
+ width: 100%;
+ max-width: 100%;
+}
+
.PluginCardTitle {
color: white;
vertical-align: top;
- margin: 8px 0px 0px 16px;
+ margin: 8px 0 0;
+}
+
+.PluginCardInfo {
+ flex: 1;
}
.PluginAddNewButton {
@@ -50,11 +74,16 @@
right: 0px;
}
+@media only screen and (max-width: 485px) {
+ .PluginAddNewButton {
+ bottom: -4px;
+ }
+}
+
.ButtonFilled {
padding: 4px 8px;
border-radius: 4px;
color: #69ddc7;
- margin-top: 36px;
}
.ButtonFilled:hover {
@@ -62,7 +91,7 @@
background-color: transparent;
}
-.ChipOutlined {
+.PluginCardChipOutlined {
font-size: small;
border-radius: 16px;
padding: 2px 8px;
@@ -70,11 +99,16 @@
color: #69ddc7;
}
-.PluginCardLink {
+.PluginCardFooter {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ margin-top: auto;
+ min-height: 2em;
+}
+
+.PluginCardFooter a {
padding: 2px 8px;
- position: absolute;
- bottom: 0;
- right: 0;
}
.PluginPageLayout {
@@ -95,18 +129,13 @@
padding-top: 8px;
}
-.PluginCardFooter {
- position: relative;
- min-height: 2em;
-}
-
-.Author,
-.Author a {
+.PluginCardAuthor,
+.PluginCardAuthor a {
margin-bottom: 0.25em;
color: rgba(255, 255, 255, 0.6);
}
-.Author a:hover {
+.PluginCardAuthor a:hover {
color: white;
}
diff --git a/microsite/static/img/sonarqube-icon.svg b/microsite/static/img/sonarqube-icon.svg
new file mode 100644
index 0000000000..6e685d988a
--- /dev/null
+++ b/microsite/static/img/sonarqube-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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 40e46c3545..b120cb2d47 100644
--- a/microsite/yarn.lock
+++ b/microsite/yarn.lock
@@ -959,10 +959,10 @@
resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==
-"@spotify/prettier-config@^8.0.0":
- version "8.0.0"
- resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-8.0.0.tgz#8b6c2bd579ddc54887155a0721fe04e96c89f7f2"
- integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ==
+"@spotify/prettier-config@^9.0.0":
+ version "9.0.0"
+ resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-9.0.0.tgz#7b562d56573c6fc0094446fbc92b22bc318945dc"
+ integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw==
"@types/cheerio@^0.22.8":
version "0.22.21"
@@ -2233,10 +2233,10 @@ dir-glob@2.0.0:
arrify "^1.0.1"
path-type "^3.0.0"
-docusaurus@^2.0.0-alpha.65:
- version "2.0.0-alpha.65"
- resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.65.tgz#e2b84985529deb79797aff52aa39ffbe03cd8a20"
- integrity sha512-OEnKbXLPy3EpbQA/Dj0kakB6fg/Qni1LORTIero+5Q5G0QT5OH4FAbBQOy3L1qQH0S5Bv18zESxX9Nvclg20ag==
+docusaurus@^2.0.0-alpha.66:
+ version "2.0.0-alpha.66"
+ resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.66.tgz#4dece48b838f773c4c973d9fe2546dd0bf2637cf"
+ integrity sha512-/HmRt3hEqpvZRdR2JRYKpwNUgalO14exxuwQu2vXW5eYzcKbQ922+3SxG7mF+LE8f7KOmgY+A3eS3SEyq+S3XA==
dependencies:
"@babel/core" "^7.9.0"
"@babel/plugin-proposal-class-properties" "^7.8.3"
diff --git a/mkdocs.yml b/mkdocs.yml
index 4fbec4650d..8a4a78ddf1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -13,7 +13,6 @@ nav:
- Getting started:
- Getting Started: 'getting-started/index.md'
- Running Backstage locally: 'getting-started/running-backstage-locally.md'
- - Installation: 'getting-started/installation.md'
- Local development: 'getting-started/development-environment.md'
- Demo deployment: https://backstage-demo.roadie.io
- Production deployments:
@@ -23,6 +22,7 @@ nav:
- Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md'
- Deployment scenarios:
- Kubernetes: 'getting-started/deployment-k8s.md'
+ - Kubernetes and Helm: 'getting-started/deployment-helm.md'
- Other: 'getting-started/deployment-other.md'
- Features:
- Software Catalog:
@@ -107,7 +107,9 @@ nav:
- ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md'
- ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md'
- Contribute: '../CONTRIBUTING.md'
- - Support: 'overview/support.md'
+ - Support:
+ - 'support/support.md'
+ - 'support/project-structure.md'
- FAQ: FAQ.md
plugins:
diff --git a/package.json b/package.json
index b9c84a4d90..4140886bc3 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"docker-build": "yarn tsc && yarn workspace example-backend build-image",
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
"remove-plugin": "backstage-cli remove-plugin",
- "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push --force-publish; fi",
+ "release": "changeset version && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}'",
"prettier:check": "prettier --check .",
"lerna": "lerna",
"storybook": "yarn workspace storybook start",
@@ -36,11 +36,15 @@
"plugins/*"
]
},
+ "resolutions": {
+ "**/@roadiehq/backstage-plugin-*/@backstage/core": "*"
+ },
"version": "1.0.0",
"devDependencies": {
- "@changesets/cli": "2.10.2",
+ "@changesets/cli": "^2.11.0",
"@spotify/eslint-config-oss": "^1.0.1",
"@spotify/prettier-config": "^8.0.0",
+ "command-exists": "^1.2.9",
"concurrently": "^5.2.0",
"fs-extra": "^9.0.0",
"husky": "^4.2.3",
@@ -65,7 +69,7 @@
"prettier --write"
],
"*.md": [
- "vale"
+ "node ./scripts/check-docs-quality"
]
},
"jest": {
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
new file mode 100644
index 0000000000..ab00924a26
--- /dev/null
+++ b/packages/app/CHANGELOG.md
@@ -0,0 +1,193 @@
+# 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
+
+- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead.
+
+ When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items.
+ Set to the `'gridItem'` variant to display the InfoCard with full height suitable for Grid:
+ `... `
+
+ Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse'
+ to pass an optional variant to the corresponding card of the plugin.
+
+ As a result the overview content of the EntityPage shows cards with full height suitable for Grid.
+
+### Patch Changes
+
+- 65d722455: Add Pull Request tab to components view.
+- 26e69ab1a: Remove cost insights example client from demo app and export from plugin
+ Create cost insights dev plugin using example client
+ Make PluginConfig and dependent types public
+- e7f5471fd: cleaning up because external plugins have already implemented new api for creating
+- Updated dependencies [28edd7d29]
+- Updated dependencies [819a70229]
+- Updated dependencies [2846ef95c]
+- Updated dependencies [3a4236570]
+- Updated dependencies [ae5983387]
+- Updated dependencies [0d4459c08]
+- Updated dependencies [d67c529ab]
+- Updated dependencies [482b6313d]
+- Updated dependencies [e0be86b6f]
+- Updated dependencies [f70a52868]
+- Updated dependencies [12b5fe940]
+- Updated dependencies [8351ad79b]
+- Updated dependencies [30dd11122]
+- Updated dependencies [1297dcb3a]
+- Updated dependencies [368fd8243]
+- Updated dependencies [fb74f1db6]
+- Updated dependencies [3472c8be7]
+- Updated dependencies [cab473771]
+- Updated dependencies [1d0aec70f]
+- Updated dependencies [1c60f716e]
+- Updated dependencies [a73979d45]
+- Updated dependencies [144c66d50]
+- Updated dependencies [a768a07fb]
+- Updated dependencies [a3840bed2]
+- Updated dependencies [339668995]
+- Updated dependencies [b79017fd3]
+- Updated dependencies [6d97d2d6f]
+- Updated dependencies [5adfc005e]
+- Updated dependencies [f0aa01bcc]
+- Updated dependencies [8d1360aa9]
+- Updated dependencies [72f6cda35]
+- Updated dependencies [0ee9e9f66]
+- Updated dependencies [5c70f3d35]
+- Updated dependencies [bb48b9833]
+- Updated dependencies [fd8384d7e]
+- Updated dependencies [8c2b76e45]
+- Updated dependencies [0aecfded0]
+- Updated dependencies [93a3fa3ae]
+- Updated dependencies [782f3b354]
+- Updated dependencies [c5ef12926]
+- Updated dependencies [8b9c8196f]
+- Updated dependencies [2713f28f4]
+- Updated dependencies [6a84cb072]
+- Updated dependencies [406015b0d]
+- Updated dependencies [82759d3e4]
+- Updated dependencies [60d40892c]
+- Updated dependencies [cba4e4d97]
+- Updated dependencies [ac8d5d5c7]
+- Updated dependencies [2ebcfac8d]
+- Updated dependencies [4fc1d440e]
+- Updated dependencies [fa56f4615]
+- Updated dependencies [8afce088a]
+- Updated dependencies [4512b9967]
+- Updated dependencies [ebca83d48]
+- Updated dependencies [aca79334f]
+- Updated dependencies [c0d5242a0]
+- Updated dependencies [b3d57961c]
+- Updated dependencies [9a3b3dbf1]
+- Updated dependencies [e7d4ac7ce]
+- Updated dependencies [0b956f21b]
+- Updated dependencies [1c8c43756]
+- Updated dependencies [0e67c6b40]
+- Updated dependencies [26e69ab1a]
+- Updated dependencies [97c2cb19b]
+- Updated dependencies [02c60b5f8]
+- Updated dependencies [3beb5c9fc]
+- Updated dependencies [754e31db5]
+- Updated dependencies [57b54c8ed]
+- Updated dependencies [1611c6dbc]
+- Updated dependencies [7bbeb049f]
+ - @backstage/cli@0.2.0
+ - @backstage/plugin-api-docs@0.2.0
+ - @backstage/plugin-catalog@0.2.0
+ - @backstage/plugin-circleci@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-welcome@0.2.0
+ - @backstage/core@0.2.0
+ - @backstage/plugin-cloudbuild@0.2.0
+ - @backstage/catalog-model@0.2.0
+ - @backstage/theme@0.2.0
+ - @backstage/plugin-cost-insights@0.2.0
+ - @backstage/plugin-user-settings@0.2.0
+ - @backstage/test-utils@0.1.2
diff --git a/packages/app/package.json b/packages/app/package.json
index 20b9e24701..529c5d4f63 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,40 +1,44 @@
{
"name": "example-app",
- "version": "0.1.1-alpha.24",
+ "version": "0.2.2",
"private": true,
"bundled": true,
"dependencies": {
- "@backstage/catalog-model": "^0.1.1-alpha.24",
- "@backstage/cli": "^0.1.1-alpha.24",
- "@backstage/core": "^0.1.1-alpha.24",
- "@backstage/plugin-api-docs": "^0.1.1-alpha.24",
- "@backstage/plugin-catalog": "^0.1.1-alpha.24",
- "@backstage/plugin-circleci": "^0.1.1-alpha.24",
- "@backstage/plugin-cloudbuild": "^0.1.1-alpha.24",
- "@backstage/plugin-cost-insights": "^0.1.1-alpha.24",
- "@backstage/plugin-explore": "^0.1.1-alpha.24",
- "@backstage/plugin-gcp-projects": "^0.1.1-alpha.24",
- "@backstage/plugin-github-actions": "^0.1.1-alpha.24",
- "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.24",
- "@backstage/plugin-graphiql": "^0.1.1-alpha.24",
- "@backstage/plugin-jenkins": "^0.1.1-alpha.24",
- "@backstage/plugin-kubernetes": "^0.1.1-alpha.24",
- "@backstage/plugin-lighthouse": "^0.1.1-alpha.24",
- "@backstage/plugin-newrelic": "^0.1.1-alpha.24",
- "@backstage/plugin-register-component": "^0.1.1-alpha.24",
- "@backstage/plugin-rollbar": "^0.1.1-alpha.24",
- "@backstage/plugin-scaffolder": "^0.1.1-alpha.24",
- "@backstage/plugin-sentry": "^0.1.1-alpha.24",
- "@backstage/plugin-tech-radar": "^0.1.1-alpha.24",
- "@backstage/plugin-techdocs": "^0.1.1-alpha.24",
- "@backstage/plugin-welcome": "^0.1.1-alpha.24",
- "@backstage/test-utils": "^0.1.1-alpha.24",
- "@backstage/theme": "^0.1.1-alpha.24",
+ "@backstage/catalog-model": "^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-pull-requests": "^0.4.3",
- "@roadiehq/backstage-plugin-travis-ci": "^0.2.3",
+ "@roadiehq/backstage-plugin-github-insights": "^0.2.14",
+ "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.2",
+ "@roadiehq/backstage-plugin-travis-ci": "^0.2.7",
+ "@roadiehq/backstage-plugin-buildkite": "^0.1.2",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
@@ -46,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",
@@ -83,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/public/index.html b/packages/app/public/index.html
index ea9208ca57..77e5c01e19 100644
--- a/packages/app/public/index.html
+++ b/packages/app/public/index.html
@@ -48,6 +48,23 @@
}
<%= app.title %>
+
+ <% if (app.googleAnalyticsTrackingId && typeof app.googleAnalyticsTrackingId
+ === 'string') { %>
+
+
+ <% } %>
You need to enable JavaScript to run this app.
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 667b14366f..81d08f58a9 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -33,6 +33,7 @@ import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
+import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { Route, Routes, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
@@ -81,6 +82,7 @@ const AppRoutes = () => (
path="/register-component"
element={ }
/>
+ } />
{...deprecatedAppRoutes}
);
diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts
index c129bebfa2..bd16cd74ef 100644
--- a/packages/app/src/apis.ts
+++ b/packages/app/src/apis.ts
@@ -26,17 +26,9 @@ import {
} from '@backstage/plugin-graphiql';
import {
- TravisCIApi,
- travisCIApiRef,
-} from '@roadiehq/backstage-plugin-travis-ci';
-
-import {
- GithubPullRequestsClient,
- githubPullRequestsApiRef,
-} from '@roadiehq/backstage-plugin-github-pull-requests';
-
-import { costInsightsApiRef } from '@backstage/plugin-cost-insights';
-import { ExampleCostInsightsClient } from './plugins/cost-insights';
+ costInsightsApiRef,
+ ExampleCostInsightsClient,
+} from '@backstage/plugin-cost-insights';
export const apis = [
createApiFactory({
@@ -59,8 +51,4 @@ export const apis = [
}),
createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()),
-
- // TODO: move to plugins
- createApiFactory(travisCIApiRef, new TravisCIApi()),
- createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()),
];
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 45ffca7801..e947e5c91d 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -35,11 +35,10 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
- SidebarUserSettings,
- DefaultProviderSettings,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
import { graphiQLRouteRef } from '@backstage/plugin-graphiql';
+import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -103,7 +102,7 @@ const Root: FC<{}> = ({ children }) => (
/>
- } />
+
{children}
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..f4dfafc7d3
--- /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 393ffe2631..73d84ef1ca 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -40,6 +40,13 @@ import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
+import {
+ Router as GitHubInsightsRouter,
+ isPluginApplicableToEntity as isGitHubAvailable,
+ ReadMeCard,
+ LanguagesCard,
+ ReleasesCard,
+} from '@roadiehq/backstage-plugin-github-insights';
import React, { ReactNode } from 'react';
import {
AboutCard,
@@ -54,13 +61,24 @@ import {
LastLighthouseAuditCard,
isPluginApplicableToEntity as isLighthouseAvailable,
} from '@backstage/plugin-lighthouse/';
+import {
+ Router as PullRequestsRouter,
+ 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):
@@ -93,10 +111,12 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
let content: ReactNode;
switch (true) {
case isJenkinsAvailable(entity):
- content = ;
+ content = ;
break;
case isGitHubActionsAvailable(entity):
- content = ;
+ content = (
+
+ );
break;
case isTravisCIAvailable(entity):
content = ;
@@ -115,14 +135,30 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
};
const OverviewContent = ({ entity }: { entity: Entity }) => (
-
+
-
+
+ {isGitHubAvailable(entity) && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
{isLighthouseAvailable(entity) && (
-
+
+
+ )}
+ {isPullRequestsAvailable(entity) && (
+
+
)}
@@ -160,6 +196,16 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
title="Kubernetes"
element={ }
/>
+ }
+ />
+ }
+ />
);
@@ -195,6 +241,16 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="Kubernetes"
element={ }
/>
+ }
+ />
+ }
+ />
);
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts
index 388bc8d8b6..06ff6b07d3 100644
--- a/packages/app/src/identityProviders.ts
+++ b/packages/app/src/identityProviders.ts
@@ -21,6 +21,7 @@ import {
githubAuthApiRef,
samlAuthApiRef,
microsoftAuthApiRef,
+ oneloginAuthApiRef,
} from '@backstage/core';
export const providers = [
@@ -60,4 +61,10 @@ export const providers = [
message: 'Sign In using SAML',
apiRef: samlAuthApiRef,
},
+ {
+ id: 'onelogin-auth-provider',
+ title: 'OneLogin',
+ message: 'Sign In using OneLogin',
+ apiRef: oneloginAuthApiRef,
+ },
];
diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts
index 85c4977d67..d6577b4ce4 100644
--- a/packages/app/src/plugins.ts
+++ b/packages/app/src/plugins.ts
@@ -36,3 +36,7 @@ export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects';
export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
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/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts
deleted file mode 100644
index c01092dc51..0000000000
--- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/* eslint-disable no-restricted-imports */
-
-import {
- CostInsightsApi,
- Alert,
- Cost,
- Duration,
- Project,
- ProductCost,
- Group,
-} from '@backstage/plugin-cost-insights';
-
-export class ExampleCostInsightsClient implements CostInsightsApi {
- private request(_: any, res: any): Promise {
- return new Promise(resolve => setTimeout(resolve, 0, res));
- }
-
- async getUserGroups(userId: string): Promise {
- const groups: Group[] = await this.request({ userId }, [
- { id: 'pied-piper' },
- ]);
-
- return groups;
- }
-
- async getGroupProjects(group: string): Promise {
- const projects: Project[] = await this.request({ group }, [
- { id: 'project-a' },
- { id: 'project-b' },
- { id: 'project-c' },
- ]);
-
- return projects;
- }
-
- async getGroupDailyCost(
- group: string,
- metric: string | null,
- intervals: string,
- ): Promise {
- const groupDailyCost: Cost = await this.request(
- { group, metric, intervals },
- {
- id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel
- aggregation: [
- { date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) },
- { date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) },
- { date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) },
- ],
- change: {
- ratio: 0.86,
- amount: 65_000,
- },
- trendline: {
- slope: 0,
- intercept: 90_000,
- },
- },
- );
-
- return groupDailyCost;
- }
-
- async getProjectDailyCost(
- project: string,
- metric: string | null,
- intervals: string,
- ): Promise {
- const projectDailyCost: Cost = await this.request(
- { project, metric, intervals },
- {
- id: 'project-a',
- aggregation: [
- { date: '2020-08-01', amount: 1000 },
- { date: '2020-08-02', amount: 2000 },
- { date: '2020-08-03', amount: 3000 },
- { date: '2020-08-04', amount: 4000 },
- { date: '2020-08-05', amount: 5000 },
- { date: '2020-08-06', amount: 6000 },
- { date: '2020-08-07', amount: 7000 },
- { date: '2020-08-08', amount: 8000 },
- { date: '2020-08-09', amount: 9000 },
- { date: '2020-08-10', amount: 10_000 },
- ],
- change: {
- ratio: 0.5,
- amount: 10000,
- },
- trendline: {
- slope: 0,
- intercept: 0,
- },
- },
- );
-
- return projectDailyCost;
- }
-
- async getProductInsights(
- product: string,
- group: string,
- duration: Duration,
- ): Promise {
- const productInsights: ProductCost = await this.request(
- { product, group, duration },
- {
- aggregation: [200_000, 250_000],
- change: {
- ratio: 0.2,
- amount: 50_000,
- },
- entities: [
- {
- id: null, // entities with null ids will be appear as "Unlabeled" in product panels
- aggregation: [45_000, 50_000],
- },
- {
- id: 'entity-a',
- aggregation: [15_000, 20_000],
- },
- {
- id: 'entity-b',
- aggregation: [20_000, 30_000],
- },
- {
- id: 'entity-c',
- aggregation: [18_000, 25_000],
- },
- {
- id: 'entity-d',
- aggregation: [36_000, 42_000],
- },
- {
- id: 'entity-e',
- aggregation: [0, 10_000],
- },
- {
- id: 'entity-f',
- aggregation: [17_000, 19_000],
- },
- {
- id: 'entity-g',
- aggregation: [49_000, 30_000],
- },
- {
- id: 'entity-h',
- aggregation: [0, 34_000],
- },
- ],
- },
- );
-
- return productInsights;
- }
-
- async getAlerts(group: string): Promise {
- const alerts: Alert[] = await this.request({ group }, [
- {
- id: 'projectGrowth',
- project: 'example-project',
- periodStart: 'Q1 2020',
- periodEnd: 'Q2 2020',
- aggregation: [60_000, 120_000],
- change: {
- ratio: 1,
- amount: 60000,
- },
- products: [
- {
- id: 'Compute Engine',
- aggregation: [58_000, 118_000],
- },
- {
- id: 'Cloud Dataflow',
- aggregation: [1200, 1500],
- },
- {
- id: 'Cloud Storage',
- aggregation: [800, 500],
- },
- ],
- },
- ]);
-
- return alerts;
- }
-}
diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md
new file mode 100644
index 0000000000..4822032c7f
--- /dev/null
+++ b/packages/backend-common/CHANGELOG.md
@@ -0,0 +1,108 @@
+# @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
+
+- 5249594c5: Add service discovery interface and implement for single host deployments
+
+ Fixes #1847, #2596
+
+ Went with an interface similar to the frontend DiscoveryApi, since it's dead simple but still provides a lot of flexibility in the implementation.
+
+ Also ended up with two different methods, one for internal endpoint discovery and one for external. The two use-cases are explained a bit more in the docs, but basically it's service-to-service vs callback URLs.
+
+ This did get me thinking about uniqueness and that we're heading towards a global namespace for backend plugin IDs. That's probably fine, but if we're happy with that we should leverage it a bit more to simplify the backend setup. For example we'd have each plugin provide its own ID and not manually mount on paths in the backend.
+
+ Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication.
+
+- 56e4eb589: Make CSP configurable to fix app-backend served app not being able to fetch
+
+ See discussion [here on discord](https://discordapp.com/channels/687207715902193673/687235481154617364/758721460163575850)
+
+- e37c0a005: Use localhost to fall back to IPv4 if IPv6 isn't available
+- f00ca3cb8: Auto-create plugin databases
+
+ Relates to #1598.
+
+ This creates databases for plugins before handing off control to plugins.
+
+ The list of plugins currently need to be hard-coded depending on the installed plugins. A later PR will properly refactor the code to provide a factory pattern where plugins specify what they need, and Knex instances will be provided based on the input.
+
+- 6579769df: Add the ability to import components from Bitbucket Server to the service catalog
+- 8c2b76e45: **BREAKING CHANGE**
+
+ The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed.
+ Instead, the CLI and backend process now accept one or more `--config` flags to load config files.
+
+ Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded.
+ If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one.
+
+ The old behaviour of for example `APP_ENV=development` can be replicated using the following flags:
+
+ ```bash
+ --config ../../app-config.yaml --config ../../app-config.development.yaml
+ ```
+
+- 8afce088a: Use APP_ENV before NODE_ENV for determining what config to load
+- 7bbeb049f: Change loadBackendConfig to return the config directly
+
+### Patch Changes
+
+- 440a17b39: Added new UrlReader interface for reading opaque data from URLs with different providers.
+
+ This new URL reading system is intended as a replacement for the various integrations towards
+ external systems in the catalog, scaffolder, and techdocs. It is configured via a new top-level
+ config section called 'integrations'.
+
+ Along with the UrlReader interface is a new UrlReaders class, which exposes static factory
+ methods for instantiating readers that can read from many different integrations simultaneously.
+
+- Updated dependencies [8c2b76e45]
+- Updated dependencies [ce5512bc0]
+ - @backstage/config-loader@0.2.0
+ - @backstage/test-utils@0.1.2
diff --git a/packages/backend-common/README.md b/packages/backend-common/README.md
index 82db0454d2..dc08d89416 100644
--- a/packages/backend-common/README.md
+++ b/packages/backend-common/README.md
@@ -34,5 +34,5 @@ app.listen(PORT, () => {
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts
new file mode 100644
index 0000000000..7ebcd01e8c
--- /dev/null
+++ b/packages/backend-common/config.d.ts
@@ -0,0 +1,148 @@
+/*
+ * 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 */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ }>;
+
+ /** Integration configuration for BitBucket */
+ bitbucket?: Array<{
+ /** The hostname of the given Bitbucket instance */
+ 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 */
+ 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 */
+ host: string;
+ /**
+ * Token used to authenticate requests.
+ * @visibility secret
+ */
+ token?: string;
+ /** The base url for the GitHub API, for example https://api.github.com */
+ apiBaseUrl?: string;
+ /** The base url for GitHub raw resources, for example https://raw.githubusercontent.com */
+ rawBaseUrl?: string;
+ }>;
+
+ /** Integration configuration for GitLab */
+ gitlab?: Array<{
+ /** The hostname of the given GitLab instance */
+ 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 1c888ab8fe..89150302b5 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.1.1-alpha.24",
+ "version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -13,7 +13,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/backend-common"
},
"keywords": [
@@ -29,26 +29,32 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/cli-common": "^0.1.1-alpha.24",
- "@backstage/config": "^0.1.1-alpha.24",
- "@backstage/config-loader": "^0.1.1-alpha.24",
+ "@backstage/cli-common": "^0.1.1",
+ "@backstage/config": "^0.1.1",
+ "@backstage/config-loader": "^0.3.0",
+ "@backstage/integration": "^0.1.1",
+ "@backstage/test-utils": "^0.1.3",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"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",
- "git-url-parse": "^11.2.0",
+ "fs-extra": "^9.0.1",
+ "git-url-parse": "^11.4.0",
"helmet": "^4.0.0",
- "knex": "^0.21.1",
+ "knex": "^0.21.6",
"lodash": "^4.17.15",
"logform": "^2.1.1",
+ "minimist": "^1.2.5",
"morgan": "^1.10.0",
- "node-fetch": "^2.6.0",
"prom-client": "^12.0.0",
"selfsigned": "^1.10.7",
"stoppable": "^1.1.0",
+ "tar": "^6.0.5",
"winston": "^3.2.1"
},
"peerDependencies": {
@@ -60,23 +66,31 @@
}
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.24",
+ "@backstage/cli": "^0.3.0",
"@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/node-fetch": "^2.5.7",
+ "@types/recursive-readdir": "^2.2.0",
"@types/stoppable": "^1.1.0",
"@types/supertest": "^2.0.8",
+ "@types/tar": "^4.0.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",
- "jest-fetch-mock": "^3.0.3",
- "msw": "^0.20.5",
+ "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 247eb593cc..86beb65805 100644
--- a/packages/backend-common/src/config.ts
+++ b/packages/backend-common/src/config.ts
@@ -14,18 +14,37 @@
* limitations under the License.
*/
+import { resolve as resolvePath } from 'path';
+import parseArgs from 'minimist';
+import { Logger } from 'winston';
import { findPaths } from '@backstage/cli-common';
+import { Config, ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
+type Options = {
+ logger: Logger;
+ // process.argv or any other overrides
+ argv: string[];
+};
+
/**
* Load configuration for a Backend
*/
-export async function loadBackendConfig() {
+export async function loadBackendConfig(options: Options): Promise {
+ const args = parseArgs(options.argv);
+ const configOpts: string[] = [args.config ?? []].flat();
+
+ /* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
const configs = await loadConfig({
- env: process.env.NODE_ENV ?? 'development',
- rootPaths: [paths.targetRoot, paths.targetDir],
- shouldReadSecrets: true,
+ env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
+ configRoot: paths.targetRoot,
+ configPaths: configOpts.map(opt => resolvePath(opt)),
});
- return configs;
+
+ options.logger.info(
+ `Loaded config from ${configs.map(c => c.context).join(', ')}`,
+ );
+
+ return ConfigReader.fromConfigs(configs);
}
diff --git a/packages/backend-common/src/database/SingleConnection.test.ts b/packages/backend-common/src/database/SingleConnection.test.ts
new file mode 100644
index 0000000000..6278e4ecad
--- /dev/null
+++ b/packages/backend-common/src/database/SingleConnection.test.ts
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ConfigReader } from '@backstage/config';
+import { createDatabaseClient } from './connection';
+import { SingleConnectionDatabaseManager } from './SingleConnection';
+
+jest.mock('./connection');
+
+describe('SingleConnectionDatabaseManager', () => {
+ const createConfig = (data: any) =>
+ ConfigReader.fromConfigs([
+ {
+ context: '',
+ data,
+ },
+ ]);
+
+ const defaultConfigOptions = {
+ backend: {
+ database: {
+ client: 'pg',
+ connection: {
+ host: 'localhost',
+ user: 'foo',
+ password: 'bar',
+ database: 'foodb',
+ },
+ },
+ },
+ };
+ const defaultConfig = () => createConfig(defaultConfigOptions);
+
+ // This is similar to the ts-jest `mocked` helper.
+ const mocked = (f: Function) => f as jest.Mock;
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('SingleConnectionDatabaseManager.fromConfig', () => {
+ it('accesses the backend.database key', () => {
+ const getConfig = jest.fn();
+ const config = defaultConfig();
+ config.getConfig = getConfig;
+
+ SingleConnectionDatabaseManager.fromConfig(config);
+
+ expect(getConfig.mock.calls[0][0]).toEqual('backend.database');
+ });
+ });
+
+ describe('SingleConnectionDatabaseManager.forPlugin', () => {
+ const manager = SingleConnectionDatabaseManager.fromConfig(defaultConfig());
+
+ it('connects to a database scoped to the plugin', async () => {
+ const pluginId = 'test1';
+ await manager.forPlugin(pluginId).getClient();
+
+ expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(1);
+
+ const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
+ const callArgs = mockCalls[0];
+ expect(callArgs[0].get()).toEqual(defaultConfigOptions.backend.database);
+ expect(callArgs[1].connection.database).toEqual(
+ `backstage_plugin_${pluginId}`,
+ );
+ });
+
+ it('provides different plugins different databases', async () => {
+ const plugin1Id = 'test1';
+ const plugin2Id = 'test2';
+ await manager.forPlugin(plugin1Id).getClient();
+ await manager.forPlugin(plugin2Id).getClient();
+
+ expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(2);
+
+ const mockCalls = mocked(createDatabaseClient).mock.calls;
+ const plugin1CallArgs = mockCalls[0];
+ const plugin2CallArgs = mockCalls[1];
+ expect(plugin1CallArgs[1].connection.database).not.toEqual(
+ plugin2CallArgs[1].connection.database,
+ );
+ });
+ });
+});
diff --git a/packages/backend-common/src/database/SingleConnection.ts b/packages/backend-common/src/database/SingleConnection.ts
new file mode 100644
index 0000000000..777ced54a0
--- /dev/null
+++ b/packages/backend-common/src/database/SingleConnection.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 Knex from 'knex';
+import { Config } from '@backstage/config';
+import { createDatabaseClient, ensureDatabaseExists } from './connection';
+import { PluginDatabaseManager } from './types';
+
+/**
+ * Implements a Database Manager which will automatically create new databases
+ * for plugins when requested. All requested databases are created with the
+ * credentials provided; if the database already exists no attempt to create
+ * the database will be made.
+ */
+export class SingleConnectionDatabaseManager {
+ /**
+ * Creates a new SingleConnectionDatabaseManager instance by reading from the `backend`
+ * config section, specifically the `.database` key for discovering the management
+ * database configuration.
+ *
+ * @param config The loaded application configuration.
+ */
+ static fromConfig(config: Config): SingleConnectionDatabaseManager {
+ return new SingleConnectionDatabaseManager(
+ config.getConfig('backend.database'),
+ );
+ }
+
+ private constructor(private readonly config: Config) {}
+
+ /**
+ * Generates a PluginDatabaseManager for consumption by plugins.
+ *
+ * @param pluginId The plugin that the database manager should be created for. Plugin names should be unique.
+ */
+ forPlugin(pluginId: string): PluginDatabaseManager {
+ const _this = this;
+
+ return {
+ getClient(): Promise {
+ return _this.getDatabase(pluginId);
+ },
+ };
+ }
+
+ private async getDatabase(pluginId: string): Promise {
+ const config = this.config;
+ const overrides = SingleConnectionDatabaseManager.getDatabaseOverrides(
+ pluginId,
+ );
+ const overrideConfig = overrides.connection as Knex.ConnectionConfig;
+ await this.ensureDatabase(overrideConfig.database);
+
+ return createDatabaseClient(config, overrides);
+ }
+
+ private static getDatabaseOverrides(pluginId: string): Knex.Config {
+ return {
+ connection: {
+ database: `backstage_plugin_${pluginId}`,
+ },
+ };
+ }
+
+ private async ensureDatabase(database: string) {
+ const config = this.config;
+ await ensureDatabaseExists(config, database);
+ }
+}
diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts
index ab29bc19ba..eb26013b4b 100644
--- a/packages/backend-common/src/database/config.test.ts
+++ b/packages/backend-common/src/database/config.test.ts
@@ -17,7 +17,19 @@
import { mergeDatabaseConfig } from './config';
describe('config', () => {
- describe(mergeDatabaseConfig, () => {
+ describe('mergeDatabaseConfig', () => {
+ it('does not mutate the input object', () => {
+ const input = {
+ original: 'key',
+ };
+ const override = {
+ added: 'value',
+ };
+
+ mergeDatabaseConfig(input, override);
+ expect(input).not.toHaveProperty('added');
+ });
+
it('does not require overrides', () => {
expect(
mergeDatabaseConfig({
diff --git a/packages/backend-common/src/database/config.ts b/packages/backend-common/src/database/config.ts
index 80abc06aa2..af32556c28 100644
--- a/packages/backend-common/src/database/config.ts
+++ b/packages/backend-common/src/database/config.ts
@@ -19,9 +19,9 @@ import { merge } from 'lodash';
/**
* Merges database objects together
*
- * @param config The base config
+ * @param config The base config. The input is not modified
* @param overrides Any additional overrides
*/
export function mergeDatabaseConfig(config: any, ...overrides: any[]) {
- return merge(config, ...overrides);
+ return merge({}, config, ...overrides);
}
diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts
index 2cc3a54e06..de0b0e40dc 100644
--- a/packages/backend-common/src/database/connection.test.ts
+++ b/packages/backend-common/src/database/connection.test.ts
@@ -26,7 +26,7 @@ describe('database connection', () => {
},
]);
- describe(createDatabaseClient, () => {
+ describe('createDatabaseClient', () => {
it('returns a postgres connection', () => {
expect(
createDatabaseClient(
diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts
index 38d3d6224b..c81153aa62 100644
--- a/packages/backend-common/src/database/index.ts
+++ b/packages/backend-common/src/database/index.ts
@@ -15,3 +15,5 @@
*/
export * from './connection';
+export * from './types';
+export * from './SingleConnection';
diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts
index 82161dc53c..8d139e4481 100644
--- a/packages/backend-common/src/database/postgres.test.ts
+++ b/packages/backend-common/src/database/postgres.test.ts
@@ -44,7 +44,7 @@ describe('postgres', () => {
},
]);
- describe(buildPgDatabaseConfig, () => {
+ describe('buildPgDatabaseConfig', () => {
it('builds a postgres config', () => {
const mockConnection = createMockConnection();
@@ -125,7 +125,7 @@ describe('postgres', () => {
});
});
- describe(getPgConnectionConfig, () => {
+ describe('getPgConnectionConfig', () => {
it('returns the connection object back', () => {
const mockConnection = createMockConnection();
const config = createConfig(mockConnection);
@@ -163,7 +163,7 @@ describe('postgres', () => {
});
});
- describe(createPgDatabaseClient, () => {
+ describe('createPgDatabaseClient', () => {
it('creates a postgres knex instance', () => {
expect(
createPgDatabaseClient(
@@ -188,8 +188,8 @@ describe('postgres', () => {
});
});
- describe(parsePgConnectionString, () => {
- it('parses a connection string uri ', () => {
+ describe('parsePgConnectionString', () => {
+ it('parses a connection string uri', () => {
expect(
parsePgConnectionString(
'postgresql://postgres:pass@foobar:5432/dbname?ssl=true',
diff --git a/packages/backend-common/src/database/postgres.ts b/packages/backend-common/src/database/postgres.ts
index 82e971f081..04bd3615b6 100644
--- a/packages/backend-common/src/database/postgres.ts
+++ b/packages/backend-common/src/database/postgres.ts
@@ -57,7 +57,7 @@ export function buildPgDatabaseConfig(
* Gets the postgres connection config
*
* @param dbConfig The database config
- * @param parseConnectionString Flag to explictly control connection string parsing
+ * @param parseConnectionString Flag to explicitly control connection string parsing
*/
export function getPgConnectionConfig(
dbConfig: Config,
diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts
index dbff5ee354..a3ab331c2d 100644
--- a/packages/backend-common/src/database/sqlite3.test.ts
+++ b/packages/backend-common/src/database/sqlite3.test.ts
@@ -32,7 +32,7 @@ describe('sqlite3', () => {
},
]);
- describe(buildSqliteDatabaseConfig, () => {
+ describe('buildSqliteDatabaseConfig', () => {
it('buidls a string connection', () => {
expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({
client: 'sqlite3',
@@ -72,7 +72,7 @@ describe('sqlite3', () => {
});
});
- describe(createSqliteDatabaseClient, () => {
+ describe('createSqliteDatabaseClient', () => {
it('creates an in memory knex instance', () => {
expect(
createSqliteDatabaseClient(
diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/packages/backend-common/src/database/types.ts
similarity index 59%
rename from plugins/cost-insights/src/utils/graphs.tsx
rename to packages/backend-common/src/database/types.ts
index 5200b14b1f..7eaa05f173 100644
--- a/plugins/cost-insights/src/utils/graphs.tsx
+++ b/packages/backend-common/src/database/types.ts
@@ -14,18 +14,17 @@
* limitations under the License.
*/
-import {
- currencyFormatter,
- dateFormatter,
- lengthyCurrencyFormatter,
-} from './formatters';
+import knex from 'knex';
-export function formatGraphValue(value: number) {
- if (value < 1) {
- return lengthyCurrencyFormatter.format(value);
- }
- return currencyFormatter.format(value);
+/**
+ * The PluginDatabaseManager manages access to databases that Plugins get.
+ */
+export interface PluginDatabaseManager {
+ /**
+ * getClient provides backend plugins database connections for itself.
+ *
+ * The purpose of this method is to allow plugins to get isolated data
+ * stores so that plugins are discouraged from database integration.
+ */
+ getClient(): Promise;
}
-
-export const overviewGraphTickFormatter = (millis: string | number) =>
- typeof millis === 'number' ? dateFormatter.format(millis) : millis;
diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts
index 402c0e2252..262be366f6 100644
--- a/packages/backend-common/src/paths.ts
+++ b/packages/backend-common/src/paths.ts
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-/* eslint-disable @typescript-eslint/camelcase */
-
import { resolve as resolvePath } from 'path';
/**
diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts
index c9e1fc5bc7..6efc7641b8 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.test.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts
@@ -19,14 +19,18 @@ import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
import { AzureUrlReader } 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();
-
- beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
- afterAll(() => worker.close());
+ msw.setupDefaultHandlers(worker);
beforeEach(() => {
worker.use(
@@ -41,7 +45,6 @@ describe('AzureUrlReader', () => {
),
);
});
- afterEach(() => worker.resetHandlers());
const createConfig = (token?: string) =>
new ConfigReader(
@@ -89,7 +92,11 @@ describe('AzureUrlReader', () => {
}),
},
])('should handle happy path %#', async ({ url, config, response }) => {
- const [{ reader }] = AzureUrlReader.factory({ config, logger });
+ const [{ reader }] = AzureUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
const data = await reader.read(url);
const res = await JSON.parse(data.toString('utf-8'));
@@ -117,7 +124,11 @@ describe('AzureUrlReader', () => {
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
- const [{ reader }] = AzureUrlReader.factory({ config, logger });
+ const [{ reader }] = AzureUrlReader.factory({
+ config,
+ logger,
+ treeResponseFactory,
+ });
await reader.read(url);
}).rejects.toThrow(error);
});
diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts
index 28fbf25eea..db8b738667 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.ts
@@ -14,49 +14,27 @@
* limitations under the License.
*/
-import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch';
-import { Config } from '@backstage/config';
+import {
+ AzureIntegrationConfig,
+ readAzureIntegrationConfigs,
+} from '@backstage/integration';
+import fetch from 'cross-fetch';
import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
-
-type Options = {
- // TODO: added here for future support, but we only allow dev.azure.com for now
- host: string;
- token?: string;
-};
-
-function readConfig(config: Config): Options[] {
- const optionsArr = Array();
-
- 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;
-}
+import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
export class AzureUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(options => {
+ const configs = readAzureIntegrationConfigs(
+ config.getOptionalConfigArray('integrations.azure') ?? [],
+ );
+ return configs.map(options => {
const reader = new AzureUrlReader(options);
const predicate = (url: URL) => url.host === options.host;
return { reader, predicate };
});
};
- constructor(private readonly options: Options) {
+ constructor(private readonly options: AzureIntegrationConfig) {
if (options.host !== 'dev.azure.com') {
throw Error(
`Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`,
@@ -76,7 +54,7 @@ export class AzureUrlReader implements UrlReader {
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
if (response.ok && response.status !== 203) {
- return response.buffer();
+ return Buffer.from(await response.text());
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
@@ -86,6 +64,10 @@ export class AzureUrlReader implements UrlReader {
throw new Error(message);
}
+ readTree(): Promise {
+ throw new Error('AzureUrlReader does not implement readTree');
+ }
+
// 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}
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index c3e61fb821..01744db28a 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -14,140 +14,105 @@
* limitations under the License.
*/
-import { rest } from 'msw';
-import { setupServer } from 'msw/node';
-import { ConfigReader } from '@backstage/config';
-import { getVoidLogger } from '../logging';
-import { BitbucketUrlReader } from './BitbucketUrlReader';
-
-const logger = getVoidLogger();
+import { BitbucketIntegrationConfig } from '@backstage/integration';
+import {
+ BitbucketUrlReader,
+ getApiRequestOptions,
+ getApiUrl,
+} from './BitbucketUrlReader';
describe('BitbucketUrlReader', () => {
- const worker = setupServer();
+ describe('getApiRequestOptions', () => {
+ it('inserts a token when needed', () => {
+ const withToken: BitbucketIntegrationConfig = {
+ host: '',
+ apiBaseUrl: '',
+ token: 'A',
+ };
+ const withoutToken: BitbucketIntegrationConfig = {
+ host: '',
+ apiBaseUrl: '',
+ };
+ expect(
+ (getApiRequestOptions(withToken).headers as any).Authorization,
+ ).toEqual('Bearer A');
+ expect(
+ (getApiRequestOptions(withoutToken).headers as any).Authorization,
+ ).toBeUndefined();
+ });
- beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
- afterAll(() => worker.close());
+ it('insert basic auth when needed', () => {
+ const withUsernameAndPassword: BitbucketIntegrationConfig = {
+ host: '',
+ apiBaseUrl: '',
+ username: 'some-user',
+ appPassword: 'my-secret',
+ };
+ const withoutUsernameAndPassword: BitbucketIntegrationConfig = {
+ host: '',
+ apiBaseUrl: '',
+ };
+ expect(
+ (getApiRequestOptions(withUsernameAndPassword).headers as any)
+ .Authorization,
+ ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
+ expect(
+ (getApiRequestOptions(withoutUsernameAndPassword).headers as any)
+ .Authorization,
+ ).toBeUndefined();
+ });
+ });
- beforeEach(() => {
- worker.use(
- rest.get('*', (req, res, ctx) =>
- res(
- ctx.status(200),
- ctx.json({
- url: req.url.toString(),
- headers: req.headers.getAllHeaders(),
- }),
+ describe('getApiUrl', () => {
+ it('rejects targets that do not look like URLs', () => {
+ const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' };
+ expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
+ });
+ it('happy path for Bitbucket Cloud', () => {
+ const config: BitbucketIntegrationConfig = {
+ host: 'bitbucket.org',
+ apiBaseUrl: 'https://api.bitbucket.org/2.0',
+ };
+ expect(
+ getApiUrl(
+ 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
+ config,
),
- ),
- );
- });
- afterEach(() => worker.resetHandlers());
-
- const createConfig = (username?: string, appPassword?: string) =>
- new ConfigReader(
- {
- integrations: {
- bitbucket: [
- {
- host: 'bitbucket.org',
- username: username,
- appPassword: appPassword,
- },
- ],
- },
- },
- 'test-config',
- );
-
- it.each([
- {
- url:
- 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
- config: createConfig(),
- response: expect.objectContaining({
- url:
+ ).toEqual(
+ new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
- }),
- },
- {
- url:
- 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
- config: createConfig('some-user', 'my-secret'),
- response: expect.objectContaining({
- headers: expect.objectContaining({
- authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==',
- }),
- }),
- },
- {
- url:
- 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
- config: createConfig(),
- response: expect.objectContaining({
- headers: expect.not.objectContaining({
- authorization: expect.anything(),
- }),
- }),
- },
- {
- url:
- 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
- config: createConfig(undefined, 'only-password-provided'),
- response: expect.objectContaining({
- headers: expect.not.objectContaining({
- authorization: expect.anything(),
- }),
- }),
- },
- ])('should handle happy path %#', async ({ url, config, response }) => {
- const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
-
- const data = await reader.read(url);
- const res = await JSON.parse(data.toString('utf-8'));
- expect(res).toEqual(response);
+ ),
+ );
+ });
+ it('happy path for Bitbucket Server', () => {
+ const config: BitbucketIntegrationConfig = {
+ host: 'bitbucket.mycompany.net',
+ apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
+ };
+ expect(
+ getApiUrl(
+ 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
+ config,
+ ),
+ ).toEqual(
+ new URL(
+ 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
+ ),
+ );
+ });
});
- 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 Bitbucket 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.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
- },
- {
- url: '',
- config: createConfig('only-user-provided', ''),
- error:
- "Invalid type in config for key 'integrations.bitbucket[0].appPassword' in 'test-config', got empty-string, wanted string",
- },
- {
- url: '',
- config: createConfig('', 'only-password-provided'),
- error:
- "Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
- },
- {
- url: '',
- config: createConfig('only-user-provided', undefined),
- error:
- "Missing required config value at 'integrations.bitbucket[0].appPassword'",
- },
- ])('should handle error path %#', async ({ url, config, error }) => {
- await expect(async () => {
- const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
- await reader.read(url);
- }).rejects.toThrow(error);
+ describe('implementation', () => {
+ it('rejects unknown targets', async () => {
+ const processor = new BitbucketUrlReader({
+ host: 'bitbucket.org',
+ apiBaseUrl: 'https://api.bitbucket.org/2.0',
+ });
+ await expect(
+ processor.read('https://not.bitbucket.com/apa'),
+ ).rejects.toThrow(
+ 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
+ );
+ });
});
});
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts
index e2576eed47..9694c1d987 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts
@@ -14,149 +14,140 @@
* limitations under the License.
*/
-import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch';
-import { Config } from '@backstage/config';
-import { ReaderFactory, UrlReader } from './types';
+import {
+ BitbucketIntegrationConfig,
+ readBitbucketIntegrationConfigs,
+} from '@backstage/integration';
+import fetch from 'cross-fetch';
+import parseGitUri from 'git-url-parse';
import { NotFoundError } from '../errors';
+import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
-type Options = {
- // TODO: added here for future support, but we only allow bitbucket.org for now
- host: string;
- auth?: {
- username: string;
- appPassword: string;
+export function getApiRequestOptions(
+ provider: BitbucketIntegrationConfig,
+): RequestInit {
+ const headers: HeadersInit = {};
+
+ if (provider.token) {
+ headers.Authorization = `Bearer ${provider.token}`;
+ } else if (provider.username && provider.appPassword) {
+ headers.Authorization = `Basic ${Buffer.from(
+ `${provider.username}:${provider.appPassword}`,
+ 'utf8',
+ ).toString('base64')}`;
+ }
+
+ return {
+ headers,
};
-};
-
-function readConfig(config: Config): Options[] {
- const optionsArr = Array();
-
- const providerConfigs =
- config.getOptionalConfigArray('integrations.bitbucket') ?? [];
-
- for (const providerConfig of providerConfigs) {
- const host = providerConfig.getOptionalString('host') ?? 'bitbucket.org';
-
- let auth;
- if (providerConfig.has('username')) {
- const username = providerConfig.getString('username');
- const appPassword = providerConfig.getString('appPassword');
- auth = { username, appPassword };
- }
-
- optionsArr.push({ host, auth });
- }
-
- // As a convenience we always make sure there's at least an unauthenticated
- // reader for public bitbucket repos.
- if (!optionsArr.some(p => p.host === 'bitbucket.org')) {
- optionsArr.push({ host: 'bitbucket.org' });
- }
-
- return optionsArr;
}
+// 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: BitbucketIntegrationConfig,
+): URL {
+ try {
+ const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
+ if (
+ !owner ||
+ !name ||
+ (filepathtype !== 'browse' &&
+ filepathtype !== 'raw' &&
+ filepathtype !== 'src')
+ ) {
+ throw new Error('Invalid Bitbucket URL or file path');
+ }
+
+ const pathWithoutSlash = filepath.replace(/^\//, '');
+
+ if (provider.host === 'bitbucket.org') {
+ if (!ref) {
+ throw new Error('Invalid Bitbucket URL or file path');
+ }
+ return new URL(
+ `${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
+ );
+ }
+ return new URL(
+ `${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
+ );
+ } catch (e) {
+ throw new Error(`Incorrect URL: ${target}, ${e}`);
+ }
+}
+
+/**
+ * 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 readonly config: BitbucketIntegrationConfig;
+
static factory: ReaderFactory = ({ config }) => {
- return readConfig(config).map(options => {
- const reader = new BitbucketUrlReader(options);
- const predicate = (url: URL) => url.host === options.host;
+ 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(private readonly options: Options) {
- if (options.host !== 'bitbucket.org') {
- throw Error(
- `Bitbucket integration currently only supports 'bitbucket.org', tried to use host '${options.host}'`,
+ 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;
}
async read(url: string): Promise {
- const builtUrl = this.buildRawUrl(url);
+ const bitbucketUrl = getApiUrl(url, this.config);
+
+ const options = getApiRequestOptions(this.config);
let response: Response;
try {
- response = await fetch(builtUrl.toString(), this.getRequestOptions());
+ response = await fetch(bitbucketUrl.toString(), options);
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.ok) {
- return response.buffer();
+ return Buffer.from(await response.text());
}
- const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
+ const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
- // Converts
- // from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
- // to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
- private buildRawUrl(target: string): URL {
- try {
- const url = new URL(target);
-
- const [
- empty,
- userOrOrg,
- repoName,
- srcKeyword,
- ref,
- ...restOfPath
- ] = url.pathname.split('/');
-
- if (
- url.hostname !== 'bitbucket.org' ||
- empty !== '' ||
- userOrOrg === '' ||
- repoName === '' ||
- srcKeyword !== 'src'
- ) {
- throw new Error('Wrong Bitbucket URL or Invalid file path');
- }
-
- // transform to api
- url.pathname = [
- empty,
- '2.0',
- 'repositories',
- userOrOrg,
- repoName,
- 'src',
- ref,
- ...restOfPath,
- ].join('/');
- url.hostname = 'api.bitbucket.org';
- url.protocol = 'https';
-
- return url;
- } catch (e) {
- throw new Error(`Incorrect url: ${target}, ${e}`);
- }
- }
-
- private getRequestOptions(): RequestInit {
- const headers: HeadersInit = {};
-
- if (this.options.auth) {
- headers.Authorization = `Basic ${Buffer.from(
- `${this.options.auth.username}:${this.options.auth.appPassword}`,
- 'utf8',
- ).toString('base64')}`;
- }
-
- return {
- headers,
- };
+ readTree(): Promise {
+ throw new Error('BitbucketUrlReader does not implement readTree');
}
toString() {
- const { host, auth } = this.options;
- return `bitbucket{host=${host},authed=${Boolean(auth)}}`;
+ const { host, token, username, appPassword } = this.config;
+ let authed = Boolean(token);
+ if (!authed) {
+ authed = Boolean(username && appPassword);
+ }
+ return `bitbucket{host=${host},authed=${authed}}`;
}
}
diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts
index ea56f2182d..1d1784590c 100644
--- a/packages/backend-common/src/reading/FetchUrlReader.ts
+++ b/packages/backend-common/src/reading/FetchUrlReader.ts
@@ -14,9 +14,9 @@
* limitations under the License.
*/
-import fetch, { Response } from 'node-fetch';
+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.
@@ -31,7 +31,7 @@ export class FetchUrlReader implements UrlReader {
}
if (response.ok) {
- return response.buffer();
+ return Buffer.from(await response.text());
}
const message = `could not read ${url}, ${response.status} ${response.statusText}`;
@@ -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 e5bed6dd26..2fbaa0b32b 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 {
+ GitHubIntegrationConfig,
+ readGitHubIntegrationConfigs,
+} from '@backstage/integration';
+import fetch from 'cross-fetch';
import parseGitUri from 'git-url-parse';
-import fetch, { HeadersInit, RequestInit, Response } from 'node-fetch';
-import { NotFoundError } from '../errors';
-import { ReaderFactory, UrlReader } from './types';
+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 {
@@ -219,7 +161,7 @@ export class GithubUrlReader implements UrlReader {
}
if (response.ok) {
- return response.buffer();
+ return Buffer.from(await response.text());
}
const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`;
@@ -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.fromArchive({
+ // 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 09da9c4e0a..3a76c0631b 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -19,14 +19,19 @@ import { setupServer } from 'msw/node';
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();
- beforeAll(() => worker.listen({ onUnhandledRequest: 'error' }));
- afterAll(() => worker.close());
+ msw.setupDefaultHandlers(worker);
beforeEach(() => {
worker.use(
@@ -44,7 +49,6 @@ describe('GitlabUrlReader', () => {
),
);
});
- afterEach(() => worker.resetHandlers());
const createConfig = (token?: string) =>
new ConfigReader(
@@ -99,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'));
@@ -115,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 0e430d650c..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 fetch, { RequestInit, Response } from 'node-fetch';
-import { Config } from '@backstage/config';
+import {
+ GitLabIntegrationConfig,
+ readGitLabIntegrationConfigs,
+} from '@backstage/integration';
+import fetch from 'cross-fetch';
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
@@ -77,7 +56,7 @@ export class GitlabUrlReader implements UrlReader {
}
if (response.ok) {
- return response.buffer();
+ return Buffer.from(await response.text());
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
@@ -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/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/ArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ArchiveResponse.test.ts
new file mode 100644
index 0000000000..fd351863b2
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ArchiveResponse.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 { ArchiveResponse } from './ArchiveResponse';
+
+const archiveData = fs.readFileSync(
+ resolvePath(__filename, '../../__fixtures__/repo.tar.gz'),
+);
+
+describe('ArchiveResponse', () => {
+ 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 ArchiveResponse(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 ArchiveResponse(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 ArchiveResponse(stream, 'mock-repo/', '/tmp');
+ const buffer = await res.archive();
+
+ await expect(res.archive()).rejects.toThrow(
+ 'Response has already been read',
+ );
+
+ const res2 = new ArchiveResponse(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 ArchiveResponse(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 ArchiveResponse(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 ArchiveResponse(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/ArchiveResponse.ts b/packages/backend-common/src/reading/tree/ArchiveResponse.ts
new file mode 100644
index 0000000000..e16be63127
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ArchiveResponse.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 ArchiveResponse 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(
+ `ArchiveResponse 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/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
new file mode 100644
index 0000000000..a134d185f3
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { ArchiveResponse } from './ArchiveResponse';
+
+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 fromArchive(options: FromArchiveOptions): Promise {
+ return new ArchiveResponse(
+ options.stream,
+ options.path ?? '',
+ this.workDir,
+ options.filter,
+ );
+ }
+}
diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts
new file mode 100644
index 0000000000..858cf15877
--- /dev/null
+++ b/packages/backend-common/src/reading/tree/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { 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-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
index 5ff7ed4271..710de6f1d7 100644
--- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { ConfigReader } from '@backstage/config';
+import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
@@ -77,7 +77,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
this.module = moduleRef;
}
- loadConfig(config: ConfigReader): ServiceBuilder {
+ loadConfig(config: Config): ServiceBuilder {
const backendConfig = config.getOptionalConfig('backend');
if (!backendConfig) {
return this;
diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/backend-common/src/service/lib/metrics.test.ts
similarity index 50%
rename from packages/cli/src/commands/plugin/export.ts
rename to packages/backend-common/src/service/lib/metrics.test.ts
index 8cbbe5b3ee..9126423b7e 100644
--- a/packages/cli/src/commands/plugin/export.ts
+++ b/packages/backend-common/src/service/lib/metrics.test.ts
@@ -14,21 +14,24 @@
* limitations under the License.
*/
-import { Command } from 'commander';
-import { loadConfig } from '@backstage/config-loader';
-import { ConfigReader } from '@backstage/config';
-import { paths } from '../../lib/paths';
-import { buildBundle } from '../../lib/bundler';
+import { normalizePath } from './metrics';
-export default async (cmd: Command) => {
- const appConfigs = await loadConfig({
- env: process.env.NODE_ENV ?? 'production',
- rootPaths: [paths.targetRoot, paths.targetDir],
+describe('normalizePath', () => {
+ it('should normalize /path to /path', async () => {
+ const path = normalizePath({ url: 'http://server/path' });
+
+ expect(path).toBe('/path');
});
- await buildBundle({
- entry: 'dev/index',
- statsJsonEnabled: cmd.stats,
- config: ConfigReader.fromConfigs(appConfigs),
- appConfigs,
+
+ it('should normalize /path/test to /path', async () => {
+ const path = normalizePath({ url: 'http://server/path/test' });
+
+ expect(path).toBe('/path');
});
-};
+
+ it('should normalize /api/plugin-name/test to /api/plugin-name', async () => {
+ const path = normalizePath({ url: 'http://server/api/plugin-name/test' });
+
+ expect(path).toBe('/api/plugin-name');
+ });
+});
diff --git a/packages/backend-common/src/service/lib/metrics.ts b/packages/backend-common/src/service/lib/metrics.ts
index d7b544765b..37d441f53c 100644
--- a/packages/backend-common/src/service/lib/metrics.ts
+++ b/packages/backend-common/src/service/lib/metrics.ts
@@ -16,6 +16,22 @@
import prom from 'prom-client';
import promBundle from 'express-prom-bundle';
import { RequestHandler } from 'express';
+import * as url from 'url';
+
+const rootRegEx = new RegExp('^/([^/]*)/.*');
+const apiRegEx = new RegExp('^/api/([^/]*)/.*');
+
+export function normalizePath(req: any): string {
+ const path = url.parse(req.originalUrl || req.url).pathname || '/';
+
+ // Capture /api/ and the plugin name
+ if (apiRegEx.test(path)) {
+ return path.replace(apiRegEx, '/api/$1');
+ }
+
+ // Only the first path segment at root level
+ return path.replace(rootRegEx, '/$1');
+}
/**
* Adds a /metrics endpoint, register default runtime metrics and instrument the router.
@@ -31,7 +47,7 @@ export function metricsHandler(): RequestHandler {
// cardinality (e.g. path params). Instead we would have to template them. However, this
// is difficult, as every backend plugin might use different routes. Instead we only take
// the first directory of the path, to have at least an idea how each plugin performs:
- normalizePath: [['^/([^/]*)/.*', '/$1']],
+ normalizePath,
promClient: { collectDefaultMetrics: {} },
});
}
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
new file mode 100644
index 0000000000..a8bc1fef7d
--- /dev/null
+++ b/packages/backend/CHANGELOG.md
@@ -0,0 +1,135 @@
+# 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
+
+- 440a17b39: Bump @backstage/catalog-backend and pass the now required UrlReader interface to the plugin
+- 6840a68df: Pass GitHub token into Scaffolder GitHub Preparer
+- 8c2b76e45: **BREAKING CHANGE**
+
+ The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed.
+ Instead, the CLI and backend process now accept one or more `--config` flags to load config files.
+
+ Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded.
+ If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one.
+
+ The old behaviour of for example `APP_ENV=development` can be replicated using the following flags:
+
+ ```bash
+ --config ../../app-config.yaml --config ../../app-config.development.yaml
+ ```
+
+- 7bbeb049f: Change loadBackendConfig to return the config directly
+- Updated dependencies [28edd7d29]
+- Updated dependencies [819a70229]
+- Updated dependencies [3a4236570]
+- Updated dependencies [3e254503d]
+- Updated dependencies [6d29605db]
+- Updated dependencies [e0be86b6f]
+- Updated dependencies [f70a52868]
+- Updated dependencies [12b5fe940]
+- Updated dependencies [5249594c5]
+- Updated dependencies [56e4eb589]
+- Updated dependencies [b4e5466e1]
+- Updated dependencies [6f1768c0f]
+- Updated dependencies [e37c0a005]
+- Updated dependencies [3472c8be7]
+- Updated dependencies [57d555eb2]
+- Updated dependencies [61db1ddc6]
+- Updated dependencies [81cb94379]
+- Updated dependencies [1687b8fbb]
+- Updated dependencies [a768a07fb]
+- Updated dependencies [a768a07fb]
+- Updated dependencies [f00ca3cb8]
+- Updated dependencies [0c370c979]
+- Updated dependencies [ce1f55398]
+- Updated dependencies [e6b00e3af]
+- Updated dependencies [9226c2aaa]
+- Updated dependencies [6d97d2d6f]
+- Updated dependencies [99710b102]
+- Updated dependencies [6579769df]
+- Updated dependencies [002860e7a]
+- Updated dependencies [5adfc005e]
+- Updated dependencies [33454c0f2]
+- Updated dependencies [183e2a30d]
+- Updated dependencies [948052cbb]
+- Updated dependencies [65d722455]
+- Updated dependencies [b652bf2cc]
+- Updated dependencies [4036ff59d]
+- Updated dependencies [991a950e0]
+- Updated dependencies [512d70973]
+- Updated dependencies [8c2b76e45]
+- Updated dependencies [8bdf0bcf5]
+- Updated dependencies [c926765a2]
+- Updated dependencies [5a920c6e4]
+- Updated dependencies [2f62e1804]
+- Updated dependencies [440a17b39]
+- Updated dependencies [fa56f4615]
+- Updated dependencies [8afce088a]
+- Updated dependencies [4c4eab81b]
+- Updated dependencies [22ff8fba5]
+- Updated dependencies [36a71d278]
+- Updated dependencies [b3d57961c]
+- Updated dependencies [6840a68df]
+- Updated dependencies [a5cb46bac]
+- Updated dependencies [49d70ccab]
+- Updated dependencies [1c8c43756]
+- Updated dependencies [26e69ab1a]
+- Updated dependencies [5e4551e3a]
+- Updated dependencies [e142a2767]
+- Updated dependencies [e7f5471fd]
+- Updated dependencies [e3d063ffa]
+- Updated dependencies [440a17b39]
+- Updated dependencies [7bbeb049f]
+ - @backstage/plugin-app-backend@0.2.0
+ - @backstage/plugin-auth-backend@0.2.0
+ - @backstage/catalog-model@0.2.0
+ - @backstage/plugin-scaffolder-backend@0.2.0
+ - @backstage/plugin-techdocs-backend@0.2.0
+ - @backstage/plugin-catalog-backend@0.2.0
+ - @backstage/plugin-proxy-backend@0.2.0
+ - @backstage/backend-common@0.2.0
+ - example-app@0.2.0
+ - @backstage/plugin-graphql-backend@0.1.2
+ - @backstage/plugin-kubernetes-backend@0.1.2
+ - @backstage/plugin-rollbar-backend@0.1.2
+ - @backstage/plugin-sentry-backend@0.1.2
diff --git a/packages/backend/README.md b/packages/backend/README.md
index c45a0d28d9..d458268846 100644
--- a/packages/backend/README.md
+++ b/packages/backend/README.md
@@ -55,9 +55,9 @@ in `app-config.yaml` under `catalog.locations`. For local development you can ov
We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/).
-Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md)
+Read more about the [auth-backend](https://github.com/backstage/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/backstage/backstage/blob/master/docs/auth/add-auth-provider.md)
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/backend/package.json b/packages/backend/package.json
index c3a912c93e..014a3f78d5 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "example-backend",
- "version": "0.1.1-alpha.24",
+ "version": "0.2.2",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
@@ -18,34 +18,34 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
- "@backstage/backend-common": "^0.1.1-alpha.24",
- "@backstage/catalog-model": "^0.1.1-alpha.24",
- "@backstage/config": "^0.1.1-alpha.24",
- "@backstage/plugin-app-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-auth-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-catalog-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-graphql-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-proxy-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-sentry-backend": "^0.1.1-alpha.24",
- "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.24",
- "@gitbeaker/node": "^23.5.0",
+ "@backstage/backend-common": "^0.3.0",
+ "@backstage/catalog-model": "^0.2.0",
+ "@backstage/config": "^0.1.1",
+ "@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.1.1-alpha.24",
+ "example-app": "^0.2.2",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
- "knex": "^0.21.1",
+ "knex": "^0.21.6",
"pg": "^8.3.0",
"pg-connection-string": "^2.3.0",
"sqlite3": "^5.0.0",
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.24",
+ "@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/index.ts b/packages/backend/src/index.ts
index 959af71427..b74954ecc5 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -24,17 +24,16 @@
import Router from 'express-promise-router';
import {
- ensureDatabaseExists,
- createDatabaseClient,
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
+ SingleConnectionDatabaseManager,
SingleHostDiscovery,
UrlReaders,
} from '@backstage/backend-common';
-import { ConfigReader, AppConfig } from '@backstage/config';
+import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
@@ -48,37 +47,28 @@ import graphql from './plugins/graphql';
import app from './plugins/app';
import { PluginEnvironment } from './types';
-function makeCreateEnv(loadedConfigs: AppConfig[]) {
- const config = ConfigReader.fromConfigs(loadedConfigs);
+function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
root.info(`Created UrlReader ${reader}`);
+ const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
+
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
- const database = createDatabaseClient(
- config.getConfig('backend.database'),
- {
- connection: {
- database: `backstage_plugin_${plugin}`,
- },
- },
- );
+ const database = databaseManager.forPlugin(plugin);
return { logger, database, config, reader, discovery };
};
}
async function main() {
- const configs = await loadBackendConfig();
- const configReader = ConfigReader.fromConfigs(configs);
- const createEnv = makeCreateEnv(configs);
- await ensureDatabaseExists(
- configReader.getConfig('backend.database'),
- 'backstage_plugin_catalog',
- 'backstage_plugin_auth',
- );
+ const config = await loadBackendConfig({
+ argv: process.argv,
+ logger: getRootLogger(),
+ });
+ const createEnv = makeCreateEnv(config);
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
@@ -105,7 +95,7 @@ async function main() {
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
- .loadConfig(configReader)
+ .loadConfig(config)
.addRouter('', await healthcheck(healthcheckEnv))
.addRouter('/api', apiRouter)
.addRouter('', await app(appEnv));
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/catalog.ts b/packages/backend/src/plugins/catalog.ts
index c1d390f8cc..3c205a3800 100644
--- a/packages/backend/src/plugins/catalog.ts
+++ b/packages/backend/src/plugins/catalog.ts
@@ -14,35 +14,21 @@
* limitations under the License.
*/
+import { useHotCleanup } from '@backstage/backend-common';
import {
+ CatalogBuilder,
createRouter,
- DatabaseEntitiesCatalog,
- DatabaseLocationsCatalog,
- DatabaseManager,
- HigherOrderOperations,
- LocationReaders,
runPeriodically,
} from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
-import { useHotCleanup } from '@backstage/backend-common';
-export default async function createPlugin({
- logger,
- config,
- reader,
- database,
-}: PluginEnvironment) {
- const locationReader = new LocationReaders({ logger, reader, config });
-
- const db = await DatabaseManager.createDatabase(database, { logger });
- const entitiesCatalog = new DatabaseEntitiesCatalog(db);
- const locationsCatalog = new DatabaseLocationsCatalog(db);
- const higherOrderOperation = new HigherOrderOperations(
+export default async function createPlugin(env: PluginEnvironment) {
+ const builder = new CatalogBuilder(env);
+ const {
entitiesCatalog,
locationsCatalog,
- locationReader,
- logger,
- );
+ higherOrderOperation,
+ } = await builder.build();
useHotCleanup(
module,
@@ -53,6 +39,6 @@ export default async function createPlugin({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
- logger,
+ logger: env.logger,
});
}
diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts
index c5b36ab8da..5d36d508a5 100644
--- a/packages/backend/src/plugins/scaffolder.ts
+++ b/packages/backend/src/plugins/scaffolder.ts
@@ -17,22 +17,13 @@
import {
CookieCutter,
createRouter,
- FilePreparer,
- GithubPreparer,
- GitlabPreparer,
- AzurePreparer,
Preparers,
Publishers,
- GithubPublisher,
- GitlabPublisher,
- AzurePublisher,
CreateReactAppTemplater,
Templaters,
- RepoVisibilityOptions,
+ CatalogEntityClient,
} from '@backstage/plugin-scaffolder-backend';
-import { Octokit } from '@octokit/rest';
-import { Gitlab } from '@gitbeaker/node';
-import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
+import { SingleHostDiscovery } from '@backstage/backend-common';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -46,108 +37,21 @@ export default async function createPlugin({
templaters.register('cookiecutter', cookiecutterTemplater);
templaters.register('cra', craTemplater);
- const filePreparer = new FilePreparer();
- const githubPreparer = new GithubPreparer();
- const gitlabPreparer = new GitlabPreparer(config);
- const azurePreparer = new AzurePreparer(config);
- const preparers = new Preparers();
-
- preparers.register('file', filePreparer);
- preparers.register('github', githubPreparer);
- preparers.register('gitlab', gitlabPreparer);
- preparers.register('gitlab/api', gitlabPreparer);
- preparers.register('azure/api', azurePreparer);
-
- const publishers = new Publishers();
-
- const githubConfig = config.getOptionalConfig('scaffolder.github');
-
- if (githubConfig) {
- try {
- const repoVisibility = githubConfig.getString(
- 'visibility',
- ) as RepoVisibilityOptions;
-
- const githubToken = githubConfig.getString('token');
- const githubClient = new Octokit({ auth: githubToken });
- const githubPublisher = new GithubPublisher({
- client: githubClient,
- token: githubToken,
- repoVisibility,
- });
- publishers.register('file', githubPublisher);
- publishers.register('github', githubPublisher);
- } catch (e) {
- const providerName = 'github';
- if (process.env.NODE_ENV !== 'development') {
- throw new Error(
- `Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
- );
- }
-
- logger.warn(
- `Skipping ${providerName} scaffolding provider, ${e.message}`,
- );
- }
- }
-
- const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api');
- if (gitLabConfig) {
- try {
- const gitLabToken = gitLabConfig.getString('token');
- const gitLabClient = new Gitlab({
- host: gitLabConfig.getOptionalString('baseUrl'),
- token: gitLabToken,
- });
- const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken);
- publishers.register('gitlab', gitLabPublisher);
- publishers.register('gitlab/api', gitLabPublisher);
- } catch (e) {
- const providerName = 'gitlab';
- if (process.env.NODE_ENV !== 'development') {
- throw new Error(
- `Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
- );
- }
-
- logger.warn(
- `Skipping ${providerName} scaffolding provider, ${e.message}`,
- );
- }
- }
-
- const azureConfig = config.getOptionalConfig('scaffolder.azure');
- if (azureConfig) {
- try {
- const baseUrl = azureConfig.getString('baseUrl');
- const azureToken = azureConfig.getConfig('api').getString('token');
-
- const authHandler = getPersonalAccessTokenHandler(azureToken);
- const webApi = new WebApi(baseUrl, authHandler);
- const azureClient = await webApi.getGitApi();
-
- const azurePublisher = new AzurePublisher(azureClient, azureToken);
- publishers.register('azure/api', azurePublisher);
- } catch (e) {
- const providerName = 'azure';
- if (process.env.NODE_ENV !== 'development') {
- throw new Error(
- `Failed to initialize ${providerName} scaffolding provider, ${e.message}`,
- );
- }
-
- logger.warn(
- `Skipping ${providerName} scaffolding provider, ${e.message}`,
- );
- }
- }
+ const preparers = await Preparers.fromConfig(config, { logger });
+ const publishers = await Publishers.fromConfig(config, { logger });
const dockerClient = new Docker();
+
+ const discovery = SingleHostDiscovery.fromConfig(config);
+ const entityClient = new CatalogEntityClient({ discovery });
+
return await createRouter({
preparers,
templaters,
publishers,
logger,
+ config,
dockerClient,
+ entityClient,
});
}
diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts
index e04d2a2732..de48280e64 100644
--- a/packages/backend/src/plugins/techdocs.ts
+++ b/packages/backend/src/plugins/techdocs.ts
@@ -21,8 +21,8 @@ import {
Generators,
LocalPublish,
TechdocsGenerator,
- GithubPreparer,
- GitlabPreparer,
+ CommonGitPreparer,
+ UrlPreparer,
} from '@backstage/plugin-techdocs-backend';
import { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -31,20 +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 githubPreparer = new GithubPreparer(logger);
- const gitlabPreparer = new GitlabPreparer(logger);
+
const directoryPreparer = new DirectoryPreparer(logger);
preparers.register('dir', directoryPreparer);
- preparers.register('github', githubPreparer);
- preparers.register('gitlab', gitlabPreparer);
- const publisher = new LocalPublish(logger);
+ const commonGitPreparer = new CommonGitPreparer(logger);
+ preparers.register('github', commonGitPreparer);
+ preparers.register('gitlab', commonGitPreparer);
+ preparers.register('azure/api', commonGitPreparer);
+
+ const urlPreparer = new UrlPreparer(reader, logger);
+ preparers.register('url', urlPreparer);
+
+ const publisher = new LocalPublish(logger, discovery);
const dockerClient = new Docker();
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 9257fcc9cf..f63b9dd780 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -14,14 +14,17 @@
* limitations under the License.
*/
-import Knex from 'knex';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
-import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common';
+import {
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+ UrlReader,
+} from '@backstage/backend-common';
export type PluginEnvironment = {
logger: Logger;
- database: Knex;
+ database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
diff --git a/packages/catalog-client/.eslintrc.js b/packages/catalog-client/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/packages/catalog-client/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
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/README.md b/packages/catalog-client/README.md
new file mode 100644
index 0000000000..574e23cf62
--- /dev/null
+++ b/packages/catalog-client/README.md
@@ -0,0 +1,17 @@
+# Catalog Client
+
+Contains a frontend and backend compatible client for communicating with the
+Backstage Catalog.
+
+Backend code may import and use this package directly.
+
+However, frontend code will not want to import this package directly - use the
+`@backstage/plugin-catalog` package instead, which re-exports all of the types
+and classes from this package. Thereby, you will also gain access to its
+`catalogApiRef`.
+
+## Links
+
+- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog)
+- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend)
+- [The Backstage homepage](https://backstage.io)
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
new file mode 100644
index 0000000000..2482f9579d
--- /dev/null
+++ b/packages/catalog-client/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@backstage/catalog-client",
+ "version": "0.3.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "scripts": {
+ "build": "backstage-cli build",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "^0.2.0",
+ "@backstage/config": "^0.1.1",
+ "cross-fetch": "^3.0.6"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.3.0",
+ "@types/jest": "^26.0.7",
+ "msw": "^0.21.2"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts
similarity index 61%
rename from plugins/catalog/src/api/CatalogClient.test.ts
rename to packages/catalog-client/src/CatalogClient.test.ts
index 832a114276..6369f95b76 100644
--- a/plugins/catalog/src/api/CatalogClient.test.ts
+++ b/packages/catalog-client/src/CatalogClient.test.ts
@@ -14,29 +14,33 @@
* 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 { UrlPatternDiscovery } from '@backstage/core';
+import { CatalogListResponse, DiscoveryApi } from './types';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
-const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
+const discoveryApi: DiscoveryApi = {
+ async getBaseUrl(_pluginId) {
+ return mockBaseUrl;
+ },
+};
describe('CatalogClient', () => {
- beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
- afterEach(() => server.resetHandlers());
- afterAll(() => server.close());
+ let client: CatalogClient;
- let client = new CatalogClient({ discoveryApi });
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+ afterAll(() => server.close());
+ afterEach(() => server.resetHandlers());
beforeEach(() => {
client = new CatalogClient({ discoveryApi });
});
- describe('getEntiies', () => {
- const defaultResponse: Entity[] = [
+ describe('getEntities', () => {
+ const defaultServiceResponse: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
@@ -54,38 +58,59 @@ 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.searchParams.toString()).toBe(
- 'a=1&b=2&b=3&%C3%B6=%3D',
- );
+ expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D');
return res(ctx.json([]));
}),
);
- 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/plugins/catalog/src/api/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts
similarity index 70%
rename from plugins/catalog/src/api/CatalogClient.ts
rename to packages/catalog-client/src/CatalogClient.ts
index d5ff033caa..362b1e71da 100644
--- a/plugins/catalog/src/api/CatalogClient.ts
+++ b/packages/catalog-client/src/CatalogClient.ts
@@ -16,11 +16,19 @@
import {
Entity,
+ EntityName,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
-import { CatalogApi, EntityCompoundName } from './types';
-import { DiscoveryApi } from '@backstage/core';
+import fetch from 'cross-fetch';
+import {
+ AddLocationRequest,
+ AddLocationResponse,
+ CatalogApi,
+ CatalogEntitiesRequest,
+ CatalogListResponse,
+ DiscoveryApi,
+} from './types';
export class CatalogClient implements CatalogApi {
private readonly discoveryApi: DiscoveryApi;
@@ -29,6 +37,107 @@ export class CatalogClient implements CatalogApi {
this.discoveryApi = options.discoveryApi;
}
+ async getLocationById(id: String): Promise {
+ return await this.getOptional(`/locations/${id}`);
+ }
+
+ async getEntities(
+ 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)}`);
+ }
+ }
+ if (filterParts.length) {
+ params.push(`filter=${filterParts.join(',')}`);
+ }
+
+ 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 {
+ const { kind, namespace = 'default', name } = compoundName;
+ return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
+ }
+
+ async addLocation({
+ type = 'url',
+ target,
+ dryRun,
+ }: AddLocationRequest): Promise {
+ const response = await fetch(
+ `${await this.discoveryApi.getBaseUrl('catalog')}/locations${
+ dryRun ? '?dryRun=true' : ''
+ }`,
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: JSON.stringify({ type, target }),
+ },
+ );
+
+ if (response.status !== 201) {
+ throw new Error(await response.text());
+ }
+
+ const { location, entities } = await response.json();
+
+ if (!location) {
+ throw new Error(`Location wasn't added: ${target}`);
+ }
+
+ if (entities.length === 0) {
+ throw new Error(
+ `Location was added but has no entities specified yet: ${target}`,
+ );
+ }
+ return {
+ location,
+ entities,
+ };
+ }
+
+ async getLocationByEntity(entity: Entity): Promise {
+ const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
+ const all: { data: Location }[] = await this.getRequired('/locations');
+ return all
+ .map(r => r.data)
+ .find(l => locationCompound === `${l.type}:${l.target}`);
+ }
+
+ async removeEntityByUid(uid: string): Promise {
+ const response = await fetch(
+ `${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`,
+ {
+ method: 'DELETE',
+ },
+ );
+ if (!response.ok) {
+ const payload = await response.text();
+ throw new Error(
+ `Request failed with ${response.status} ${response.statusText}, ${payload}`,
+ );
+ }
+ return undefined;
+ }
+
+ //
+ // Private methods
+ //
+
private async getRequired(path: string): Promise {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
@@ -58,87 +167,4 @@ export class CatalogClient implements CatalogApi {
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 params = new URLSearchParams();
- for (const [key, value] of Object.entries(filter)) {
- if (Array.isArray(value)) {
- for (const v of value) {
- params.append(key, v);
- }
- } else {
- params.append(key, value);
- }
- }
- path += `?${params.toString()}`;
- }
-
- return await this.getRequired(path);
- }
-
- async getEntityByName(
- compoundName: EntityCompoundName,
- ): Promise {
- const { kind, namespace = 'default', name } = compoundName;
- return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
- }
-
- async addLocation(type: string, target: string) {
- const response = await fetch(
- `${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
- {
- headers: {
- 'Content-Type': 'application/json',
- },
- method: 'POST',
- body: JSON.stringify({ type, target }),
- },
- );
-
- if (response.status !== 201) {
- throw new Error(await response.text());
- }
-
- const { location, entities } = await response.json();
-
- if (!location || entities.length === 0)
- throw new Error(`Location wasn't added: ${target}`);
-
- return {
- location,
- entities,
- };
- }
-
- async getLocationByEntity(entity: Entity): Promise {
- const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
- const all: { data: Location }[] = await this.getRequired('/locations');
- return all
- .map(r => r.data)
- .find(l => locationCompound === `${l.type}:${l.target}`);
- }
-
- async removeEntityByUid(uid: string): Promise {
- const response = await fetch(
- `${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`,
- {
- method: 'DELETE',
- },
- );
- if (!response.ok) {
- const payload = await response.text();
- throw new Error(
- `Request failed with ${response.status} ${response.statusText}, ${payload}`,
- );
- }
- return undefined;
- }
}
diff --git a/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts
new file mode 100644
index 0000000000..9bd13f9b7c
--- /dev/null
+++ b/packages/catalog-client/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { CatalogClient } from './CatalogClient';
+export type { CatalogApi } from './types';
diff --git a/packages/catalog-client/src/setupTests.ts b/packages/catalog-client/src/setupTests.ts
new file mode 100644
index 0000000000..ba33cf996b
--- /dev/null
+++ b/packages/catalog-client/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {};
diff --git a/plugins/catalog/src/api/types.ts b/packages/catalog-client/src/types.ts
similarity index 55%
rename from plugins/catalog/src/api/types.ts
rename to packages/catalog-client/src/types.ts
index 36c86f2e96..e72317d444 100644
--- a/plugins/catalog/src/api/types.ts
+++ b/packages/catalog-client/src/types.ts
@@ -14,33 +14,42 @@
* limitations under the License.
*/
-import { createApiRef } from '@backstage/core';
-import { Entity, Location } from '@backstage/catalog-model';
+import { Entity, EntityName, Location } from '@backstage/catalog-model';
-export const catalogApiRef = createApiRef({
- id: 'plugin.catalog.service',
- description:
- 'Used by the Catalog plugin to make requests to accompanying backend',
-});
+export type CatalogEntitiesRequest = {
+ filter?: Record | undefined;
+ fields?: string[] | undefined;
+};
-export type EntityCompoundName = {
- kind: string;
- namespace?: string;
- name: string;
+export type CatalogListResponse = {
+ items: T[];
};
export interface CatalogApi {
getLocationById(id: String): Promise;
- getEntityByName(
- compoundName: EntityCompoundName,
- ): Promise;
- getEntities(filter?: Record): Promise;
- addLocation(type: string, target: string): Promise;
+ getEntityByName(name: EntityName): Promise;
+ getEntities(
+ request?: CatalogEntitiesRequest,
+ ): Promise>;
+ addLocation(location: AddLocationRequest): Promise;
getLocationByEntity(entity: Entity): Promise;
removeEntityByUid(uid: string): Promise;
}
+export type AddLocationRequest = {
+ type?: string;
+ target: string;
+ dryRun?: boolean;
+};
+
export type AddLocationResponse = {
location: Location;
entities: Entity[];
};
+
+/**
+ * This is a copy of the core DiscoveryApi, to avoid importing core.
+ */
+export type DiscoveryApi = {
+ getBaseUrl(pluginId: string): Promise;
+};
diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md
new file mode 100644
index 0000000000..051aa82c80
--- /dev/null
+++ b/packages/catalog-model/CHANGELOG.md
@@ -0,0 +1,28 @@
+# @backstage/catalog-model
+
+## 0.2.0
+
+### Minor Changes
+
+- 3a4236570: Add handling and docs for entity references
+- e0be86b6f: Entirely case insensitive read path of entities
+- f70a52868: Add the User & Group entities
+
+ A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog.
+
+ A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User.
+
+- 12b5fe940: Add ApiDefinitionAtLocationProcessor that allows to load a API definition from another location
+- a768a07fb: Add the ability to import users from GitHub Organization into the catalog.
+- 5adfc005e: Changes the various kind policies into a new type `KindValidator`.
+
+ Adds `CatalogProcessor#validateEntityKind` that makes use of the above
+ validators. This moves entity schema validity checking away from entity
+ policies and into processors, centralizing the extension points into the
+ processor chain.
+
+- b3d57961c: Enable adding locations for config files that does not yet exist by adding a flag to api request
+
+### Patch Changes
+
+- fa56f4615: Fix documentation and validation message for tags
diff --git a/packages/catalog-model/README.md b/packages/catalog-model/README.md
index 6dab6e7cae..8218508be9 100644
--- a/packages/catalog-model/README.md
+++ b/packages/catalog-model/README.md
@@ -7,6 +7,6 @@ as well as by others that want to consume catalog data.
## Links
-- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog)
-- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend)
+- [Default frontend part of the catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog)
+- [Default backend part of the catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
- [The Backstage homepage](https://backstage.io)
diff --git a/packages/catalog-model/examples/acme-corp.yaml b/packages/catalog-model/examples/acme-corp.yaml
new file mode 100644
index 0000000000..e8047fc143
--- /dev/null
+++ b/packages/catalog-model/examples/acme-corp.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Location
+metadata:
+ name: acme-corp
+ description: A collection of all Backstage example Groups
+spec:
+ type: github
+ targets:
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml
diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml
new file mode 100644
index 0000000000..f992d155bc
--- /dev/null
+++ b/packages/catalog-model/examples/acme/backstage-group.yaml
@@ -0,0 +1,11 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: backstage
+ description: The backstage sub-department
+spec:
+ type: sub-department
+ parent: infrastructure
+ ancestors: [infrastructure, acme-corp]
+ children: [team-a, team-b]
+ descendants: [team-a, team-b]
diff --git a/packages/catalog-model/examples/acme/boxoffice-group.yaml b/packages/catalog-model/examples/acme/boxoffice-group.yaml
new file mode 100644
index 0000000000..0be1fe58cb
--- /dev/null
+++ b/packages/catalog-model/examples/acme/boxoffice-group.yaml
@@ -0,0 +1,11 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: boxoffice
+ description: The boxoffice sub-department
+spec:
+ type: sub-department
+ parent: infrastructure
+ ancestors: [infrastructure, acme-corp]
+ children: [team-c, team-d]
+ descendants: [team-c, team-d]
diff --git a/packages/catalog-model/examples/acme/infrastructure-group.yaml b/packages/catalog-model/examples/acme/infrastructure-group.yaml
new file mode 100644
index 0000000000..2341782944
--- /dev/null
+++ b/packages/catalog-model/examples/acme/infrastructure-group.yaml
@@ -0,0 +1,11 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: infrastructure
+ description: The infra department
+spec:
+ type: department
+ parent: acme-corp
+ ancestors: [acme-corp]
+ children: [backstage, boxoffice]
+ descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d]
diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml
new file mode 100644
index 0000000000..8c562aaf89
--- /dev/null
+++ b/packages/catalog-model/examples/acme/org.yaml
@@ -0,0 +1,27 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: acme-corp
+ description: The acme-corp organization
+spec:
+ type: organization
+ ancestors: []
+ children: [infrastructure]
+ descendants:
+ [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d]
+---
+apiVersion: backstage.io/v1alpha1
+kind: Location
+metadata:
+ name: example-groups
+ description: A collection of all Backstage example Groups
+spec:
+ type: github
+ targets:
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/infrastructure-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/boxoffice-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/backstage-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-a-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-b-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-c-group.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-d-group.yaml
diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml
new file mode 100644
index 0000000000..dacc4c6c3c
--- /dev/null
+++ b/packages/catalog-model/examples/acme/team-a-group.yaml
@@ -0,0 +1,44 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: team-a
+ description: Team A
+spec:
+ type: team
+ parent: backstage
+ ancestors: [backstage, infrastructure, acme-corp]
+ children: []
+ descendants: []
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: breanna.davison
+spec:
+ profile:
+ displayName: Breanna Davison
+ email: breanna-davison@example.com
+ picture: https://example.com/staff/breanna.jpeg
+ memberOf: [team-a]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: janelle.dawe
+spec:
+ profile:
+ displayName: Janelle Dawe
+ email: janelle-dawe@example.com
+ picture: https://example.com/staff/janelle.jpeg
+ memberOf: [team-a]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: nigel.manning
+spec:
+ profile:
+ displayName: Nigel Manning
+ email: nigel-manning@example.com
+ picture: https://example.com/staff/nigel.jpeg
+ memberOf: [team-a]
diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml
new file mode 100644
index 0000000000..00e9e41d80
--- /dev/null
+++ b/packages/catalog-model/examples/acme/team-b-group.yaml
@@ -0,0 +1,66 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: team-b
+ description: Team B
+spec:
+ type: team
+ parent: backstage
+ ancestors: [backstage, infrastructure, acme-corp]
+ children: []
+ descendants: []
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: amelia.park
+spec:
+ profile:
+ displayName: Amelia Park
+ email: amelia-park@example.com
+ picture: https://example.com/staff/amelia.jpeg
+ memberOf: [team-b]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: colette.brock
+spec:
+ profile:
+ displayName: Colette Brock
+ email: colette-brock@example.com
+ picture: https://example.com/staff/colette.jpeg
+ memberOf: [team-b]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: jenny.doe
+spec:
+ profile:
+ displayName: Jenny Doe
+ email: jenny-doe@example.com
+ picture: https://example.com/staff/jenny.jpeg
+ memberOf: [team-b]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: jonathon.page
+spec:
+ profile:
+ displayName: Jonathon Page
+ email: jonathon-page@example.com
+ picture: https://example.com/staff/jonathon.jpeg
+ memberOf: [team-b]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: justine.barrow
+spec:
+ profile:
+ displayName: Justine Barrow
+ email: justine-barrow@example.com
+ picture: https://example.com/staff/justine.jpeg
+ memberOf: [team-b]
diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml
new file mode 100644
index 0000000000..0f97f69cb3
--- /dev/null
+++ b/packages/catalog-model/examples/acme/team-c-group.yaml
@@ -0,0 +1,66 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: team-c
+ description: Team C
+spec:
+ type: team
+ parent: boxoffice
+ ancestors: [boxoffice, infrastructure, acme-corp]
+ children: []
+ descendants: []
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: calum.leavy
+spec:
+ profile:
+ displayName: Calum Leavy
+ email: calum-leavy@example.com
+ picture: https://example.com/staff/calum.jpeg
+ memberOf: [team-c]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: frank.tiernan
+spec:
+ profile:
+ displayName: Frank Tiernan
+ email: frank-tiernan@example.com
+ picture: https://example.com/staff/frank.jpeg
+ memberOf: [team-c]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: peadar.macmahon
+spec:
+ profile:
+ displayName: Peadar MacMahon
+ email: peadar-macmahon@example.com
+ picture: https://example.com/staff/peadar.jpeg
+ memberOf: [team-c]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: sarah.gilroy
+spec:
+ profile:
+ displayName: Sarah Gilroy
+ email: sarah-gilroy@example.com
+ picture: https://example.com/staff/sarah.jpeg
+ memberOf: [team-c]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: tara.macgovern
+spec:
+ profile:
+ displayName: Tara MacGovern
+ email: tara-macgovern@example.com
+ picture: https://example.com/staff/tara.jpeg
+ memberOf: [team-c]
diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml
new file mode 100644
index 0000000000..5a1a53a2e6
--- /dev/null
+++ b/packages/catalog-model/examples/acme/team-d-group.yaml
@@ -0,0 +1,33 @@
+apiVersion: backstage.io/v1alpha1
+kind: Group
+metadata:
+ name: team-d
+ description: Team D
+spec:
+ type: team
+ parent: boxoffice
+ ancestors: [boxoffice, infrastructure, acme-corp]
+ children: []
+ descendants: []
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: eva.macdowell
+spec:
+ profile:
+ displayName: Eva MacDowell
+ email: eva-macdowell@example.com
+ picture: https://example.com/staff/eva.jpeg
+ memberOf: [team-d]
+---
+apiVersion: backstage.io/v1alpha1
+kind: User
+metadata:
+ name: lucy.sheehan
+spec:
+ profile:
+ displayName: Lucy Sheehan
+ email: lucy-sheehan@example.com
+ picture: https://example.com/staff/lucy.jpeg
+ memberOf: [team-d]
diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml
index 3c4f7804bf..0278fc3078 100644
--- a/packages/catalog-model/examples/all-apis.yaml
+++ b/packages/catalog-model/examples/all-apis.yaml
@@ -6,7 +6,8 @@ metadata:
spec:
type: github
targets:
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/petstore-api.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml
diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml
index cad4eee044..06c44b59d7 100644
--- a/packages/catalog-model/examples/all-components.yaml
+++ b/packages/catalog-model/examples/all-components.yaml
@@ -6,11 +6,12 @@ metadata:
spec:
type: github
targets:
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml
- - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/petstore-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml
+ - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml
diff --git a/packages/catalog-model/examples/hello-world-api.yaml b/packages/catalog-model/examples/apis/hello-world-api.yaml
similarity index 100%
rename from packages/catalog-model/examples/hello-world-api.yaml
rename to packages/catalog-model/examples/apis/hello-world-api.yaml
diff --git a/packages/catalog-model/examples/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml
similarity index 100%
rename from packages/catalog-model/examples/petstore-api.yaml
rename to packages/catalog-model/examples/apis/petstore-api.yaml
diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/apis/spotify-api.yaml
similarity index 64%
rename from packages/catalog-model/examples/spotify-api.yaml
rename to packages/catalog-model/examples/apis/spotify-api.yaml
index 30524dfdd4..14deb17abe 100644
--- a/packages/catalog-model/examples/spotify-api.yaml
+++ b/packages/catalog-model/examples/apis/spotify-api.yaml
@@ -7,8 +7,11 @@ metadata:
- spotify
- rest
annotations:
+ # The annotation is deprecated, we use placeholders (see below) instead, remove it later.
backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml'
spec:
type: openapi
lifecycle: production
owner: spotify@example.com
+ definition:
+ $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml
diff --git a/packages/catalog-model/examples/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml
similarity index 100%
rename from packages/catalog-model/examples/streetlights-api.yaml
rename to packages/catalog-model/examples/apis/streetlights-api.yaml
diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml
similarity index 99%
rename from packages/catalog-model/examples/swapi-graphql.yaml
rename to packages/catalog-model/examples/apis/swapi-graphql.yaml
index 152d9c0afa..bc5e8e60ac 100644
--- a/packages/catalog-model/examples/swapi-graphql.yaml
+++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml
@@ -5,6 +5,8 @@ metadata:
description: SWAPI GraphQL Schema
spec:
type: graphql
+ lifecycle: production
+ owner: yoda@dagobah.space
definition: |
schema {
query: Root
diff --git a/packages/catalog-model/examples/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/artist-lookup-component.yaml
rename to packages/catalog-model/examples/components/artist-lookup-component.yaml
diff --git a/packages/catalog-model/examples/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/petstore-component.yaml
rename to packages/catalog-model/examples/components/petstore-component.yaml
diff --git a/packages/catalog-model/examples/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/playback-lib-component.yaml
rename to packages/catalog-model/examples/components/playback-lib-component.yaml
diff --git a/packages/catalog-model/examples/playback-order-component.yaml b/packages/catalog-model/examples/components/playback-order-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/playback-order-component.yaml
rename to packages/catalog-model/examples/components/playback-order-component.yaml
diff --git a/packages/catalog-model/examples/podcast-api-component.yaml b/packages/catalog-model/examples/components/podcast-api-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/podcast-api-component.yaml
rename to packages/catalog-model/examples/components/podcast-api-component.yaml
diff --git a/packages/catalog-model/examples/queue-proxy-component.yaml b/packages/catalog-model/examples/components/queue-proxy-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/queue-proxy-component.yaml
rename to packages/catalog-model/examples/components/queue-proxy-component.yaml
diff --git a/packages/catalog-model/examples/searcher-component.yaml b/packages/catalog-model/examples/components/searcher-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/searcher-component.yaml
rename to packages/catalog-model/examples/components/searcher-component.yaml
diff --git a/packages/catalog-model/examples/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/shuffle-api-component.yaml
rename to packages/catalog-model/examples/components/shuffle-api-component.yaml
diff --git a/packages/catalog-model/examples/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml
similarity index 100%
rename from packages/catalog-model/examples/www-artist-component.yaml
rename to packages/catalog-model/examples/components/www-artist-component.yaml
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index 6a543f7f84..e712e66f58 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
- "version": "0.1.1-alpha.24",
+ "version": "0.2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,16 +20,16 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/config": "^0.1.1-alpha.24",
+ "@backstage/config": "^0.1.1",
"@types/json-schema": "^7.0.5",
- "@types/yup": "^0.28.2",
+ "@types/yup": "^0.29.8",
"json-schema": "^0.2.5",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
- "yup": "^0.29.1"
+ "yup": "^0.29.3"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.24",
+ "@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/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts
new file mode 100644
index 0000000000..cd869f798c
--- /dev/null
+++ b/packages/catalog-model/src/EntityPolicies.test.ts
@@ -0,0 +1,110 @@
+import { Entity } from './entity';
+/*
+ * 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 { EntityPolicies } from './EntityPolicies';
+import { EntityPolicy } from './types';
+
+describe('EntityPolicies', () => {
+ const p1: jest.Mocked = { enforce: jest.fn() };
+ const p2: jest.Mocked = { enforce: jest.fn() };
+ const entity1: Entity = {
+ apiVersion: 'a1',
+ kind: 'k1',
+ metadata: { name: 'n1' },
+ };
+ const entity2: Entity = {
+ apiVersion: 'a2',
+ kind: 'k2',
+ metadata: { name: 'n2' },
+ };
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('allOf', () => {
+ it('resolves when no policies', async () => {
+ const policy = EntityPolicies.allOf([]);
+ await expect(policy.enforce(entity1)).resolves.toBe(entity1);
+ });
+
+ it('resolves when all resolve', async () => {
+ p1.enforce.mockResolvedValue(entity1);
+ p2.enforce.mockResolvedValue(entity1);
+ const policy = EntityPolicies.allOf([p1, p2]);
+ await expect(policy.enforce(entity1)).resolves.toBe(entity1);
+ });
+
+ it('rejects when any rejects', async () => {
+ p1.enforce.mockResolvedValue(entity1);
+ p2.enforce.mockRejectedValue(new Error('a'));
+ const policy = EntityPolicies.allOf([p1, p2]);
+ await expect(policy.enforce(entity1)).rejects.toThrow('a');
+ });
+
+ it('rejects when any ignores', async () => {
+ p1.enforce.mockResolvedValue(entity1);
+ p2.enforce.mockResolvedValue(undefined);
+ const policy = EntityPolicies.allOf([p1, p2]);
+ await expect(policy.enforce(entity1)).rejects.toThrow(
+ /did not return a result/,
+ );
+ });
+
+ it('passes through transforms properly', async () => {
+ p1.enforce.mockResolvedValue(entity2);
+ p2.enforce.mockResolvedValue(entity2);
+ const policy = EntityPolicies.allOf([p1, p2]);
+ await expect(policy.enforce(entity1)).resolves.toBe(entity2);
+ expect(p1.enforce).toBeCalledWith(entity1);
+ expect(p2.enforce).toBeCalledWith(entity2);
+ });
+ });
+
+ describe('oneOf', () => {
+ it('rejects when no policies', async () => {
+ const policy = EntityPolicies.oneOf([]);
+ await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/);
+ });
+
+ it('resolves when one resolves', async () => {
+ p1.enforce.mockResolvedValue(undefined);
+ p2.enforce.mockResolvedValue(entity1);
+ const policy = EntityPolicies.oneOf([p1, p2]);
+ await expect(policy.enforce(entity1)).resolves.toBe(entity1);
+ });
+
+ it('rejects when one rejects first', async () => {
+ p1.enforce.mockRejectedValue(new Error('a'));
+ p2.enforce.mockResolvedValue(entity1);
+ const policy = EntityPolicies.oneOf([p1, p2]);
+ await expect(policy.enforce(entity1)).rejects.toThrow('a');
+ });
+
+ it('resolves first resolution when several resolve', async () => {
+ p1.enforce.mockResolvedValue(entity1);
+ p2.enforce.mockResolvedValue(entity2);
+ const policy = EntityPolicies.oneOf([p1, p2]);
+ await expect(policy.enforce(entity1)).resolves.toBe(entity1);
+ });
+
+ it('rejects when all ignore', async () => {
+ p1.enforce.mockResolvedValue(undefined);
+ p2.enforce.mockResolvedValue(undefined);
+ const policy = EntityPolicies.oneOf([p1, p2]);
+ await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/);
+ });
+ });
+});
diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts
index ca59338c16..1f304f8f27 100644
--- a/packages/catalog-model/src/EntityPolicies.ts
+++ b/packages/catalog-model/src/EntityPolicies.ts
@@ -14,22 +14,7 @@
* limitations under the License.
*/
-import {
- DefaultNamespaceEntityPolicy,
- Entity,
- FieldFormatEntityPolicy,
- NoForeignRootFieldsEntityPolicy,
- ReservedFieldsEntityPolicy,
- SchemaValidEntityPolicy,
-} from './entity';
-import {
- apiEntityV1alpha1Policy,
- componentEntityV1alpha1Policy,
- groupEntityV1alpha1Policy,
- locationEntityV1alpha1Policy,
- templateEntityV1alpha1Policy,
- userEntityV1alpha1Policy,
-} from './kinds';
+import { Entity } from './entity';
import { EntityPolicy } from './types';
// Helper that requires that all of a set of policies can be successfully
@@ -40,10 +25,13 @@ class AllEntityPolicies implements EntityPolicy {
async enforce(entity: Entity): Promise {
let result = entity;
for (const policy of this.policies) {
- const output = await policy.enforce(entity);
- if (output) {
- result = output;
+ const output = await policy.enforce(result);
+ if (!output) {
+ throw new Error(
+ `Policy ${policy.constructor.name} did not return a result`,
+ );
}
+ result = output;
}
return result;
}
@@ -54,10 +42,10 @@ class AllEntityPolicies implements EntityPolicy {
class AnyEntityPolicy implements EntityPolicy {
constructor(private readonly policies: EntityPolicy[]) {}
- async enforce(entity: Entity): Promise {
+ async enforce(entity: Entity): Promise {
for (const policy of this.policies) {
const output = await policy.enforce(entity);
- if (output !== null) {
+ if (output) {
return output;
}
}
@@ -65,42 +53,11 @@ class AnyEntityPolicy implements EntityPolicy {
}
}
-export class EntityPolicies implements EntityPolicy {
- private readonly policy: EntityPolicy;
-
- static defaultPolicies(): EntityPolicy {
- return EntityPolicies.allOf([
- EntityPolicies.allOf([
- new SchemaValidEntityPolicy(),
- new DefaultNamespaceEntityPolicy(),
- new NoForeignRootFieldsEntityPolicy(),
- new FieldFormatEntityPolicy(),
- new ReservedFieldsEntityPolicy(),
- ]),
- EntityPolicies.anyOf([
- componentEntityV1alpha1Policy,
- groupEntityV1alpha1Policy,
- userEntityV1alpha1Policy,
- locationEntityV1alpha1Policy,
- templateEntityV1alpha1Policy,
- apiEntityV1alpha1Policy,
- ]),
- ]);
- }
-
- static allOf(policies: EntityPolicy[]): EntityPolicy {
+export const EntityPolicies = {
+ allOf(policies: EntityPolicy[]) {
return new AllEntityPolicies(policies);
- }
-
- static anyOf(policies: EntityPolicy[]): EntityPolicy {
+ },
+ oneOf(policies: EntityPolicy[]) {
return new AnyEntityPolicy(policies);
- }
-
- constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) {
- this.policy = policy;
- }
-
- enforce(entity: Entity): Promise {
- return this.policy.enforce(entity);
- }
-}
+ },
+};
diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts
index 9e0aec7de6..ac43137546 100644
--- a/packages/catalog-model/src/entity/Entity.ts
+++ b/packages/catalog-model/src/entity/Entity.ts
@@ -15,6 +15,7 @@
*/
import { JsonObject } from '@backstage/config';
+import { EntityName } from '../types';
/**
* The format envelope that's common to all versions/kinds of entity.
@@ -42,6 +43,11 @@ export type Entity = {
* The specification data describing the entity itself.
*/
spec?: JsonObject;
+
+ /**
+ * The relations that this entity has with other entities.
+ */
+ relations?: EntityRelation[];
};
/**
@@ -120,3 +126,38 @@ export type EntityMeta = JsonObject & {
*/
tags?: string[];
};
+
+/**
+ * A relation of a specific type to another entity in the catalog.
+ */
+export type EntityRelation = {
+ /**
+ * The type of the relation.
+ */
+ type: string;
+
+ /**
+ * The target entity of this relation.
+ */
+ target: EntityName;
+};
+
+/**
+ * Holds the relation data for entities.
+ */
+export type EntityRelationSpec = {
+ /**
+ * The source entity of this relation.
+ */
+ source: EntityName;
+
+ /**
+ * The type of the relation.
+ */
+ type: string;
+
+ /**
+ * The target entity of this relation.
+ */
+ target: EntityName;
+};
diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts
index 759a94ca67..d450119f0a 100644
--- a/packages/catalog-model/src/entity/index.ts
+++ b/packages/catalog-model/src/entity/index.ts
@@ -18,7 +18,12 @@ export {
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
} from './constants';
-export type { Entity, EntityMeta } from './Entity';
+export type {
+ Entity,
+ EntityMeta,
+ EntityRelation,
+ EntityRelationSpec,
+} from './Entity';
export * from './policies';
export {
getEntityName,
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
index 20a3724079..0aabbd9e48 100644
--- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
@@ -15,7 +15,12 @@
*/
import { EntityPolicy } from '../../types';
-import { makeValidator, Validators } from '../../validation';
+import {
+ CommonValidatorFunctions,
+ KubernetesValidatorFunctions,
+ makeValidator,
+ Validators,
+} from '../../validation';
import { Entity } from '../Entity';
/**
@@ -50,7 +55,47 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
}
if (!isValid) {
- throw new Error(`${field} "${value}" is not valid`);
+ let expectation;
+ switch (
+ validator.name as
+ | keyof typeof KubernetesValidatorFunctions
+ | keyof typeof CommonValidatorFunctions
+ ) {
+ case 'isValidLabelValue':
+ case 'isValidObjectName':
+ expectation =
+ 'a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total';
+ break;
+ case 'isValidLabelKey':
+ case 'isValidApiVersion':
+ case 'isValidAnnotationKey':
+ expectation = 'a valid prefix and/or suffix';
+ break;
+ case 'isValidNamespace':
+ case 'isValidDnsLabel':
+ expectation =
+ 'a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total';
+ break;
+ case 'isValidAnnotationValue':
+ expectation = 'a string';
+ break;
+ case 'isValidKind':
+ expectation =
+ 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total';
+ break;
+ default:
+ expectation = undefined;
+ break;
+ }
+
+ // ensure that if there are other/future validators, the error message defaults to a general "is not valid, visit link"
+ const message = expectation
+ ? ` expected ${expectation} but found "${value}".`
+ : '';
+
+ throw new Error(
+ `"${field}" is not valid;${message} To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md`,
+ );
}
}
diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts
deleted file mode 100644
index 5d4e60a1a3..0000000000
--- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import yaml from 'yaml';
-import { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
-
-describe('ReservedFieldsEntityPolicy', () => {
- let data: any;
- let policy: ReservedFieldsEntityPolicy;
-
- beforeEach(() => {
- data = yaml.parse(`
- apiVersion: backstage.io/v1alpha1
- kind: Component
- metadata:
- uid: e01199ab-08cc-44c2-8e19-5c29ded82521
- etag: lsndfkjsndfkjnsdfkjnsd==
- generation: 13
- name: my-component-yay
- namespace: the-namespace
- labels:
- backstage.io/custom: ValueStuff
- annotations:
- example.com/bindings: are-secret
- tags:
- - java
- - data
- spec:
- custom: stuff
- `);
- policy = new ReservedFieldsEntityPolicy();
- });
-
- it('works for the happy path', async () => {
- await expect(policy.enforce(data)).resolves.toBe(data);
- });
-
- it('rejects reserved keys in the spec root', async () => {
- data.spec.apiVersion = 'a/b';
- await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i);
- });
-
- it('rejects reserved keys in labels', async () => {
- data.metadata.labels.apiVersion = 'a';
- await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i);
- });
-
- it('rejects reserved keys in annotations', async () => {
- data.metadata.annotations.apiVersion = 'a';
- await expect(policy.enforce(data)).rejects.toThrow(
- /annotation.*apiVersion/i,
- );
- });
-
- it('rejects core fields mistakenly placed in metadata', async () => {
- data.metadata.owner = 'emma';
- await expect(policy.enforce(data)).rejects.toThrow(/owner/i);
- });
-});
diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts
deleted file mode 100644
index edb63cf05c..0000000000
--- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { EntityPolicy } from '../../types';
-import { Entity } from '../Entity';
-
-const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [
- 'apiVersion',
- 'kind',
- 'spec',
- 'metadata.uid',
- 'metadata.etag',
- 'metadata.generation',
- 'metadata.name',
- 'metadata.namespace',
- 'metadata.description',
- 'metadata.labels',
- 'metadata.annotations',
- 'metadata.tags',
- // The below items are known to appear in core kinds, and therefore should
- // not be appearing in metadata (which would indicate that the user made a
- // mistake in where to place them).
- 'spec.lifecycle',
- 'spec.owner',
-];
-
-/**
- * Ensures that fields are not given certain reserved names.
- */
-export class ReservedFieldsEntityPolicy implements EntityPolicy {
- private readonly reservedFields: string[];
-
- constructor(fields?: string[]) {
- this.reservedFields = [
- ...(fields ?? []),
- ...DEFAULT_RESERVED_ENTITY_FIELDS,
- ];
- }
-
- async enforce(entity: Entity): Promise {
- for (const path of this.reservedFields) {
- const [where, name] = path.includes('.')
- ? path.split('.')
- : [undefined, path];
-
- if (where !== 'metadata' && entity.metadata.hasOwnProperty(name)) {
- throw new Error(
- `The metadata may not contain the field ${name}, because it has reserved meaning`,
- );
- }
- if (where !== 'spec' && entity.spec?.hasOwnProperty(name)) {
- throw new Error(
- `The spec may not contain the field ${name}, because it has reserved meaning`,
- );
- }
- if (where !== 'labels' && entity.metadata.labels?.hasOwnProperty(name)) {
- throw new Error(
- `A label may not have the field ${name}, because it has reserved meaning`,
- );
- }
- if (
- where !== 'annotations' &&
- entity.metadata.annotations?.hasOwnProperty(name)
- ) {
- throw new Error(
- `An annotation may not have the field ${name}, because it has reserved meaning`,
- );
- }
- }
- return entity;
- }
-}
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
index a6d376b4ac..4ff73e9a49 100644
--- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
@@ -18,24 +18,26 @@ import * as yup from 'yup';
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
-const DEFAULT_ENTITY_SCHEMA = yup.object({
- apiVersion: yup.string().required(),
- kind: yup.string().required(),
- metadata: yup
- .object({
- uid: yup.string().notRequired().min(1),
- etag: yup.string().notRequired().min(1),
- generation: yup.number().notRequired().integer().min(1),
- name: yup.string().required(),
- namespace: yup.string().notRequired(),
- description: yup.string().notRequired(),
- labels: yup.object>().notRequired(),
- annotations: yup.object>().notRequired(),
- tags: yup.array().notRequired(),
- })
- .required(),
- spec: yup.object({}).notRequired(),
-});
+const DEFAULT_ENTITY_SCHEMA = yup
+ .object({
+ apiVersion: yup.string().required(),
+ kind: yup.string().required(),
+ metadata: yup
+ .object({
+ uid: yup.string().notRequired().min(1),
+ etag: yup.string().notRequired().min(1),
+ generation: yup.number().notRequired().integer().min(1),
+ name: yup.string().required(),
+ namespace: yup.string().notRequired(),
+ description: yup.string().notRequired(),
+ labels: yup.object>().notRequired(),
+ annotations: yup.object>().notRequired(),
+ tags: yup.array().notRequired(),
+ })
+ .required(),
+ spec: yup.object({}).notRequired(),
+ })
+ .required();
/**
* Ensures that the entity spec is valid according to a schema.
diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts
index eb7c0e8378..c381d29a23 100644
--- a/packages/catalog-model/src/entity/policies/index.ts
+++ b/packages/catalog-model/src/entity/policies/index.ts
@@ -17,5 +17,4 @@
export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
-export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts
index 142a8d8160..a5d5152fab 100644
--- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
ApiEntityV1alpha1,
- apiEntityV1alpha1Policy as policy,
+ apiEntityV1alpha1Validator as validator,
} from './ApiEntityV1alpha1';
-describe('ApiV1alpha1Policy', () => {
+describe('ApiV1alpha1Validator', () => {
let entity: ApiEntityV1alpha1;
beforeEach(() => {
@@ -75,81 +75,81 @@ components:
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects missing lifecycle', async () => {
delete (entity as any).spec.lifecycle;
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects wrong lifecycle', async () => {
(entity as any).spec.lifecycle = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects empty lifecycle', async () => {
(entity as any).spec.lifecycle = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects missing owner', async () => {
delete (entity as any).spec.owner;
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects wrong owner', async () => {
(entity as any).spec.owner = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects missing definition', async () => {
delete (entity as any).spec.definition;
- await expect(policy.enforce(entity)).rejects.toThrow(/definition/);
+ await expect(validator.check(entity)).rejects.toThrow(/definition/);
});
it('rejects wrong definition', async () => {
(entity as any).spec.definition = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/definition/);
+ await expect(validator.check(entity)).rejects.toThrow(/definition/);
});
it('rejects empty definition', async () => {
(entity as any).spec.definition = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/definition/);
+ await expect(validator.check(entity)).rejects.toThrow(/definition/);
});
});
diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
index 052172d0a1..660cd71cd8 100644
--- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
@@ -16,7 +16,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'API' as const;
@@ -45,4 +45,8 @@ export interface ApiEntityV1alpha1 extends Entity {
};
}
-export const apiEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema);
+export const apiEntityV1alpha1Validator = schemaValidator(
+ KIND,
+ API_VERSION,
+ schema,
+);
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts
index 57d6d47388..64f9d05978 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
ComponentEntityV1alpha1,
- componentEntityV1alpha1Policy as policy,
+ componentEntityV1alpha1Validator as validator,
} from './ComponentEntityV1alpha1';
-describe('ComponentV1alpha1Policy', () => {
+describe('ComponentV1alpha1Validator', () => {
let entity: ComponentEntityV1alpha1;
beforeEach(() => {
@@ -39,66 +39,86 @@ describe('ComponentV1alpha1Policy', () => {
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects missing lifecycle', async () => {
delete (entity as any).spec.lifecycle;
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects wrong lifecycle', async () => {
(entity as any).spec.lifecycle = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects empty lifecycle', async () => {
(entity as any).spec.lifecycle = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/);
+ await expect(validator.check(entity)).rejects.toThrow(/lifecycle/);
});
it('rejects missing owner', async () => {
delete (entity as any).spec.owner;
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects wrong owner', async () => {
(entity as any).spec.owner = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/owner/);
+ await expect(validator.check(entity)).rejects.toThrow(/owner/);
+ });
+
+ it('accepts missing implementsApis', async () => {
+ delete (entity as any).spec.implementsApis;
+ await expect(validator.check(entity)).resolves.toBe(true);
+ });
+
+ it('rejects empty implementsApis', async () => {
+ (entity as any).spec.implementsApis = [''];
+ await expect(validator.check(entity)).rejects.toThrow(/implementsApis/);
+ });
+
+ it('rejects undefined implementsApis', async () => {
+ (entity as any).spec.implementsApis = [undefined];
+ await expect(validator.check(entity)).rejects.toThrow(/implementsApis/);
+ });
+
+ it('accepts no implementsApis', async () => {
+ (entity as any).spec.implementsApis = [];
+ await expect(validator.check(entity)).resolves.toBe(true);
});
});
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index 9efc491ca9..9d46dea03f 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -16,7 +16,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Component' as const;
@@ -29,7 +29,7 @@ const schema = yup.object>({
type: yup.string().required().min(1),
lifecycle: yup.string().required().min(1),
owner: yup.string().required().min(1),
- implementsApis: yup.array(yup.string()).notRequired(),
+ implementsApis: yup.array(yup.string().required()).notRequired(),
})
.required(),
});
@@ -45,7 +45,7 @@ export interface ComponentEntityV1alpha1 extends Entity {
};
}
-export const componentEntityV1alpha1Policy = schemaPolicy(
+export const componentEntityV1alpha1Validator = schemaValidator(
KIND,
API_VERSION,
schema,
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts
index dc0ecd3fa3..8b7bef8ff7 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
GroupEntityV1alpha1,
- groupEntityV1alpha1Policy as policy,
+ groupEntityV1alpha1Validator as validator,
} from './GroupEntityV1alpha1';
-describe('GroupV1alpha1Policy', () => {
+describe('GroupV1alpha1Validator', () => {
let entity: GroupEntityV1alpha1;
beforeEach(() => {
@@ -42,91 +42,106 @@ describe('GroupV1alpha1Policy', () => {
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('accepts missing parent', async () => {
delete (entity as any).spec.parent;
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects empty parent', async () => {
(entity as any).spec.parent = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/parent/);
+ await expect(validator.check(entity)).rejects.toThrow(/parent/);
});
it('rejects missing ancestors', async () => {
delete (entity as any).spec.ancestors;
- await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/);
+ await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
});
- it('accepts empty ancestors', async () => {
+ it('rejects empty ancestors', async () => {
(entity as any).spec.ancestors = [''];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
+ });
+
+ it('rejects undefined ancestors', async () => {
+ (entity as any).spec.ancestors = [undefined];
+ await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
});
it('accepts no ancestors', async () => {
(entity as any).spec.ancestors = [];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects missing children', async () => {
delete (entity as any).spec.children;
- await expect(policy.enforce(entity)).rejects.toThrow(/children/);
+ await expect(validator.check(entity)).rejects.toThrow(/children/);
});
- it('accepts empty children', async () => {
+ it('rejects empty children', async () => {
(entity as any).spec.children = [''];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).rejects.toThrow(/children/);
+ });
+
+ it('rejects undefined children', async () => {
+ (entity as any).spec.children = [undefined];
+ await expect(validator.check(entity)).rejects.toThrow(/children/);
});
it('accepts no children', async () => {
(entity as any).spec.children = [];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects missing descendants', async () => {
delete (entity as any).spec.descendants;
- await expect(policy.enforce(entity)).rejects.toThrow(/descendants/);
+ await expect(validator.check(entity)).rejects.toThrow(/descendants/);
});
- it('accepts empty descendants', async () => {
+ it('rejects empty descendants', async () => {
(entity as any).spec.descendants = [''];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).rejects.toThrow(/descendants/);
+ });
+
+ it('rejects undefined descendants', async () => {
+ (entity as any).spec.descendants = [undefined];
+ await expect(validator.check(entity)).rejects.toThrow(/descendants/);
});
it('accepts no descendants', async () => {
(entity as any).spec.descendants = [];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
});
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index 073332d707..f6345741f7 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -16,7 +16,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Group' as const;
@@ -30,21 +30,23 @@ const schema = yup.object>({
parent: yup.string().notRequired().min(1),
// Use these manual tests because yup .required() requires at least
// one element and there is no simple workaround -_-
- ancestors: yup.array(yup.string()).test({
+ // the cast is there to convince typescript that the array itself is
+ // required without using .required()
+ ancestors: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'ancestors must be defined',
test: v => Boolean(v),
- }),
- children: yup.array(yup.string()).test({
+ }) as yup.ArraySchema,
+ children: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'children must be defined',
test: v => Boolean(v),
- }),
- descendants: yup.array(yup.string()).test({
+ }) as yup.ArraySchema,
+ descendants: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'descendants must be defined',
test: v => Boolean(v),
- }),
+ }) as yup.ArraySchema,
})
.required(),
});
@@ -61,7 +63,7 @@ export interface GroupEntityV1alpha1 extends Entity {
};
}
-export const groupEntityV1alpha1Policy = schemaPolicy(
+export const groupEntityV1alpha1Validator = schemaValidator(
KIND,
API_VERSION,
schema,
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts
index a259eaeca8..d9c1e9185f 100644
--- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
LocationEntityV1alpha1,
- locationEntityV1alpha1Policy as policy,
+ locationEntityV1alpha1Validator as validator,
} from './LocationEntityV1alpha1';
-describe('LocationV1alpha1Policy', () => {
+describe('LocationV1alpha1Validator', () => {
let entity: LocationEntityV1alpha1;
beforeEach(() => {
@@ -36,65 +36,70 @@ describe('LocationV1alpha1Policy', () => {
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects wrong type', async () => {
(entity as any).spec.type = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('accepts good target', async () => {
(entity as any).spec.target =
- 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml';
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects wrong target', async () => {
(entity as any).spec.target = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/target/);
+ await expect(validator.check(entity)).rejects.toThrow(/target/);
});
it('rejects empty target', async () => {
(entity as any).spec.target = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/target/);
+ await expect(validator.check(entity)).rejects.toThrow(/target/);
});
it('accepts good targets', async () => {
(entity as any).spec.targets = [
- 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml',
- 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/playback-order-component.yaml',
+ 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml',
+ 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/playback-order-component.yaml',
];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
+ });
+
+ it('accepts empty targets', async () => {
+ (entity as any).spec.targets = [];
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects wrong targets', async () => {
(entity as any).spec.targets = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/targets/);
+ await expect(validator.check(entity)).rejects.toThrow(/targets/);
});
});
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
index 6d1f1f5515..e536be7922 100644
--- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
@@ -16,7 +16,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Location' as const;
@@ -28,7 +28,7 @@ const schema = yup.object>({
.object({
type: yup.string().required().min(1),
target: yup.string().notRequired().min(1),
- targets: yup.array(yup.string()).notRequired(),
+ targets: yup.array(yup.string().required()).notRequired(),
})
.required(),
});
@@ -43,7 +43,7 @@ export interface LocationEntityV1alpha1 extends Entity {
};
}
-export const locationEntityV1alpha1Policy = schemaPolicy(
+export const locationEntityV1alpha1Validator = schemaValidator(
KIND,
API_VERSION,
schema,
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
index 86351b41aa..e3d29aad4f 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
TemplateEntityV1alpha1,
- templateEntityV1alpha1Policy as policy,
+ templateEntityV1alpha1Validator as validator,
} from './TemplateEntityV1alpha1';
-describe('templateEntityV1alpha1', () => {
+describe('templateEntityV1alpha1Validator', () => {
let entity: TemplateEntityV1alpha1;
beforeEach(() => {
@@ -53,41 +53,41 @@ describe('templateEntityV1alpha1', () => {
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('accepts any other type', async () => {
(entity as any).spec.type = 'hallo';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/type/);
+ await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects missing templater', async () => {
(entity as any).spec.templater = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/templater/);
+ await expect(validator.check(entity)).rejects.toThrow(/templater/);
});
});
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
index a26540fda2..b16fcdc7e4 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
@@ -17,7 +17,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { JSONSchema } from '../types';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'Template' as const;
@@ -46,7 +46,7 @@ export interface TemplateEntityV1alpha1 extends Entity {
};
}
-export const templateEntityV1alpha1Policy = schemaPolicy(
+export const templateEntityV1alpha1Validator = schemaValidator(
KIND,
API_VERSION,
schema,
diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts
index 97fe0888f1..075f97e92c 100644
--- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts
+++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts
@@ -16,10 +16,10 @@
import {
UserEntityV1alpha1,
- userEntityV1alpha1Policy as policy,
+ userEntityV1alpha1Validator as validator,
} from './UserEntityV1alpha1';
-describe('userEntityV1alpha1Policy', () => {
+describe('userEntityV1alpha1Validator', () => {
let entity: UserEntityV1alpha1;
beforeEach(() => {
@@ -41,117 +41,117 @@ describe('userEntityV1alpha1Policy', () => {
});
it('happy path: accepts valid data', async () => {
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
// root
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
- await expect(policy.enforce(entity)).resolves.toBeUndefined();
+ await expect(validator.check(entity)).resolves.toBe(false);
});
it('spec accepts unknown additional fields', async () => {
(entity as any).spec.foo = 'data';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
// profile
it('accepts missing profile', async () => {
delete (entity as any).spec.profile;
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects wrong profile', async () => {
(entity as any).spec.profile = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/profile/);
+ await expect(validator.check(entity)).rejects.toThrow(/profile/);
});
it('profile accepts missing displayName', async () => {
delete (entity as any).spec.profile.displayName;
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('profile rejects wrong displayName', async () => {
(entity as any).spec.profile.displayName = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
+ await expect(validator.check(entity)).rejects.toThrow(/displayName/);
});
it('profile rejects empty displayName', async () => {
(entity as any).spec.profile.displayName = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
+ await expect(validator.check(entity)).rejects.toThrow(/displayName/);
});
it('profile accepts missing email', async () => {
delete (entity as any).spec.profile.email;
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('profile rejects wrong email', async () => {
(entity as any).spec.profile.email = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/email/);
+ await expect(validator.check(entity)).rejects.toThrow(/email/);
});
it('profile rejects empty email', async () => {
(entity as any).spec.profile.email = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/email/);
+ await expect(validator.check(entity)).rejects.toThrow(/email/);
});
it('profile accepts missing picture', async () => {
delete (entity as any).spec.profile.picture;
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('profile rejects wrong picture', async () => {
(entity as any).spec.profile.picture = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
+ await expect(validator.check(entity)).rejects.toThrow(/picture/);
});
it('profile rejects empty picture', async () => {
(entity as any).spec.profile.picture = '';
- await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
+ await expect(validator.check(entity)).rejects.toThrow(/picture/);
});
it('profile accepts unknown additional fields', async () => {
(entity as any).spec.profile.foo = 'data';
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
// memberOf
it('rejects missing memberOf', async () => {
delete (entity as any).spec.memberOf;
- await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
+ await expect(validator.check(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf', async () => {
(entity as any).spec.memberOf = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
+ await expect(validator.check(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf item', async () => {
(entity as any).spec.memberOf[0] = 7;
- await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
+ await expect(validator.check(entity)).rejects.toThrow(/memberOf/);
});
it('accepts empty memberOf', async () => {
(entity as any).spec.memberOf = [];
- await expect(policy.enforce(entity)).resolves.toBe(entity);
+ await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects null memberOf', async () => {
(entity as any).spec.memberOf = null;
- await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
+ await expect(validator.check(entity)).rejects.toThrow(/memberOf/);
});
});
diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
index ff4edcc8a2..16a5a86e05 100644
--- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
@@ -16,7 +16,7 @@
import * as yup from 'yup';
import type { Entity } from '../entity/Entity';
-import { schemaPolicy } from './util';
+import { schemaValidator } from './util';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'User' as const;
@@ -35,11 +35,13 @@ const schema = yup.object>({
.notRequired(),
// Use this manual test because yup .required() requires at least one
// element and there is no simple workaround -_-
- memberOf: yup.array(yup.string()).test({
+ // the cast is there to convince typescript that the array itself is
+ // required without using .required()
+ memberOf: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'memberOf must be defined',
test: v => Boolean(v),
- }),
+ }) as yup.ArraySchema,
})
.required(),
});
@@ -57,4 +59,8 @@ export interface UserEntityV1alpha1 extends Entity {
};
}
-export const userEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema);
+export const userEntityV1alpha1Validator = schemaValidator(
+ KIND,
+ API_VERSION,
+ schema,
+);
diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts
index 6df4f1212a..914d7efea7 100644
--- a/packages/catalog-model/src/kinds/index.ts
+++ b/packages/catalog-model/src/kinds/index.ts
@@ -14,32 +14,34 @@
* limitations under the License.
*/
-export { apiEntityV1alpha1Policy } from './ApiEntityV1alpha1';
+export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
ApiEntityV1alpha1,
} from './ApiEntityV1alpha1';
-export { componentEntityV1alpha1Policy } from './ComponentEntityV1alpha1';
+export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1';
export type {
ComponentEntityV1alpha1 as ComponentEntity,
ComponentEntityV1alpha1,
} from './ComponentEntityV1alpha1';
-export { groupEntityV1alpha1Policy } from './GroupEntityV1alpha1';
+export { groupEntityV1alpha1Validator } from './GroupEntityV1alpha1';
export type {
GroupEntityV1alpha1 as GroupEntity,
GroupEntityV1alpha1,
} from './GroupEntityV1alpha1';
-export { locationEntityV1alpha1Policy } from './LocationEntityV1alpha1';
+export { locationEntityV1alpha1Validator } from './LocationEntityV1alpha1';
export type {
LocationEntityV1alpha1 as LocationEntity,
LocationEntityV1alpha1,
} from './LocationEntityV1alpha1';
-export { templateEntityV1alpha1Policy } from './TemplateEntityV1alpha1';
+export * from './relations';
+export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1';
export type {
TemplateEntityV1alpha1 as TemplateEntity,
TemplateEntityV1alpha1,
} from './TemplateEntityV1alpha1';
-export { userEntityV1alpha1Policy } from './UserEntityV1alpha1';
+export type { KindValidator } from './types';
+export { userEntityV1alpha1Validator } from './UserEntityV1alpha1';
export type {
UserEntityV1alpha1 as UserEntity,
UserEntityV1alpha1,
diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts
new file mode 100644
index 0000000000..846313ec8c
--- /dev/null
+++ b/packages/catalog-model/src/kinds/relations.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+Naming rules for relations in priority order:
+
+1. Use at most two words. One main verb and a specifier, e.g. "ownerOf"
+2. Reading out " " should make sense in English.
+3. Maintain symmetry between pairs, e.g. "ownedBy" and "ownerOf" rather than "owns".
+*/
+
+/**
+ * An ownership relation where the owner is usually an organizational
+ * entity (user or group), and the other entity can be anything.
+ */
+export const RELATION_OWNED_BY = 'ownedBy';
+export const RELATION_OWNER_OF = 'ownerOf';
+
+/**
+ * A relation with an API entity, typically from a component or system
+ */
+export const RELATION_CONSUMES_API = 'consumesApi';
+export const RELATION_PROVIDES_API = 'providesApi';
+
+/**
+ * A relation denoting a dependency on another entity.
+ */
+export const RELATION_DEPENDS_ON = 'dependsOn';
+export const RELATION_DEPENDENCY_OF = 'dependencyOf';
+
+/**
+ * A parent/child relation to build up a tree, used for example to describe
+ * the organizational structure between groups.
+ */
+export const RELATION_PARENT_OF = 'parentOf';
+export const RELATION_CHILD_OF = 'childOf';
+
+/**
+ * A membership relation, typically for users in a group.
+ */
+export const RELATION_MEMBER_OF = 'memberOf';
+export const RELATION_HAS_MEMBER = 'hasMember';
diff --git a/packages/catalog-model/src/kinds/types.ts b/packages/catalog-model/src/kinds/types.ts
new file mode 100644
index 0000000000..4b947680c7
--- /dev/null
+++ b/packages/catalog-model/src/kinds/types.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '../entity';
+
+/**
+ * Validates entities of a certain kind.
+ */
+export type KindValidator = {
+ /**
+ * Validates the entity as a known entity kind.
+ *
+ * @param entity The entity to validate
+ * @returns Resolves to true, if the entity was of a kind that was known and
+ * handled by this validator, and was found to be valid. Resolves to false,
+ * if the entity was not of a kind that was known by this validator.
+ * Rejects to an Error describing the problem, if the entity was of a kind
+ * that was known by this validator and was not valid.
+ */
+ check(entity: Entity): Promise;
+};
diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts
index 491b2c5e2f..f1b02cfba1 100644
--- a/packages/catalog-model/src/kinds/util.ts
+++ b/packages/catalog-model/src/kinds/util.ts
@@ -15,23 +15,23 @@
*/
import * as yup from 'yup';
-import { Entity } from '../entity';
-import { EntityPolicy } from '../types';
+import { KindValidator } from './types';
-export function schemaPolicy(
+export function schemaValidator(
kind: string,
apiVersion: readonly string[],
schema: yup.Schema,
-): EntityPolicy {
+): KindValidator {
return {
- async enforce(envelope: Entity): Promise {
+ async check(envelope) {
if (
kind !== envelope.kind ||
!apiVersion.includes(envelope.apiVersion as any)
) {
- return undefined;
+ return false;
}
- return await schema.validate(envelope, { strict: true });
+ await schema.validate(envelope, { strict: true });
+ return true;
},
};
}
diff --git a/packages/catalog-model/src/location/types.ts b/packages/catalog-model/src/location/types.ts
index 50e6e82a54..33e443e04f 100644
--- a/packages/catalog-model/src/location/types.ts
+++ b/packages/catalog-model/src/location/types.ts
@@ -17,6 +17,10 @@
export type LocationSpec = {
type: string;
target: string;
+ // When using repo importer plugin, location is being created before the component yaml file is merged to the main branch.
+ // This flag is then set to indicate that the file can be not present.
+ // default value: 'required'.
+ presence?: 'optional' | 'required';
};
export type Location = {
diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts
index 5fad47bdd0..27d74b9a82 100644
--- a/packages/catalog-model/src/location/validation.ts
+++ b/packages/catalog-model/src/location/validation.ts
@@ -21,8 +21,10 @@ export const locationSpecSchema = yup
.object({
type: yup.string().required(),
target: yup.string().required(),
+ presence: yup.string(),
})
- .noUnknown();
+ .noUnknown()
+ .required();
export const locationSchema = yup
.object({
@@ -30,4 +32,5 @@ export const locationSchema = yup
type: yup.string().required(),
target: yup.string().required(),
})
- .noUnknown();
+ .noUnknown()
+ .required();
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
index 457ef64611..129fdd2b62 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
@@ -113,7 +113,7 @@ describe('CommonValidatorFunctions', () => {
[{}, true],
[{ a: 1 }, true],
[{ a: undefined }, false],
- ] as [any, boolean][])(`isJsonSafe %p ? %p`, (value, result) => {
+ ] as [unknown, boolean][])(`isJsonSafe %p ? %p`, (value, result) => {
expect(CommonValidatorFunctions.isJsonSafe(value)).toBe(result);
});
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
index 349d6d7f72..90b4e1a611 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
@@ -31,7 +31,7 @@ export class CommonValidatorFunctions {
* @param isValidSuffix Checks that the part after the separator (or the entire value if there is no separator) is valid
*/
static isValidPrefixAndOrSuffix(
- value: any,
+ value: unknown,
separator: string,
isValidPrefix: (value: string) => boolean,
isValidSuffix: (value: string) => boolean,
@@ -55,7 +55,7 @@ export class CommonValidatorFunctions {
*
* @param value The value to check
*/
- static isJsonSafe(value: any): boolean {
+ static isJsonSafe(value: unknown): boolean {
try {
return lodash.isEqual(value, JSON.parse(JSON.stringify(value)));
} catch {
@@ -69,7 +69,7 @@ export class CommonValidatorFunctions {
* @param value The value to check
* @see https://tools.ietf.org/html/rfc1123
*/
- static isValidDnsSubdomain(value: any): boolean {
+ static isValidDnsSubdomain(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
@@ -84,7 +84,7 @@ export class CommonValidatorFunctions {
* @param value The value to check
* @see https://tools.ietf.org/html/rfc1123
*/
- static isValidDnsLabel(value: any): boolean {
+ static isValidDnsLabel(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
diff --git a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts
index fa938f5fcb..ada0aa71ff 100644
--- a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts
+++ b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts
@@ -25,7 +25,7 @@ import { CommonValidatorFunctions } from './CommonValidatorFunctions';
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
*/
export class KubernetesValidatorFunctions {
- static isValidApiVersion(value: any): boolean {
+ static isValidApiVersion(value: unknown): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
@@ -34,7 +34,7 @@ export class KubernetesValidatorFunctions {
);
}
- static isValidKind(value: any): boolean {
+ static isValidKind(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
@@ -43,7 +43,7 @@ export class KubernetesValidatorFunctions {
);
}
- static isValidObjectName(value: any): boolean {
+ static isValidObjectName(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
@@ -52,11 +52,11 @@ export class KubernetesValidatorFunctions {
);
}
- static isValidNamespace(value: any): boolean {
+ static isValidNamespace(value: unknown): boolean {
return CommonValidatorFunctions.isValidDnsLabel(value);
}
- static isValidLabelKey(value: any): boolean {
+ static isValidLabelKey(value: unknown): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
@@ -65,13 +65,13 @@ export class KubernetesValidatorFunctions {
);
}
- static isValidLabelValue(value: any): boolean {
+ static isValidLabelValue(value: unknown): boolean {
return (
value === '' || KubernetesValidatorFunctions.isValidObjectName(value)
);
}
- static isValidAnnotationKey(value: any): boolean {
+ static isValidAnnotationKey(value: unknown): boolean {
return CommonValidatorFunctions.isValidPrefixAndOrSuffix(
value,
'/',
@@ -80,7 +80,7 @@ export class KubernetesValidatorFunctions {
);
}
- static isValidAnnotationValue(value: any): boolean {
+ static isValidAnnotationValue(value: unknown): boolean {
return typeof value === 'string';
}
}
diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts
index 14706485ca..a4475c4809 100644
--- a/packages/catalog-model/src/validation/types.ts
+++ b/packages/catalog-model/src/validation/types.ts
@@ -15,13 +15,13 @@
*/
export type Validators = {
- isValidApiVersion(value: any): boolean;
- isValidKind(value: any): boolean;
- isValidEntityName(value: any): boolean;
- isValidNamespace(value: any): boolean;
- isValidLabelKey(value: any): boolean;
- isValidLabelValue(value: any): boolean;
- isValidAnnotationKey(value: any): boolean;
- isValidAnnotationValue(value: any): boolean;
- isValidTag(value: any): boolean;
+ isValidApiVersion(value: unknown): boolean;
+ isValidKind(value: unknown): boolean;
+ isValidEntityName(value: unknown): boolean;
+ isValidNamespace(value: unknown): boolean;
+ isValidLabelKey(value: unknown): boolean;
+ isValidLabelValue(value: unknown): boolean;
+ isValidAnnotationKey(value: unknown): boolean;
+ isValidAnnotationValue(value: unknown): boolean;
+ isValidTag(value: unknown): boolean;
};
diff --git a/packages/cli-common/README.md b/packages/cli-common/README.md
index 3488003a76..91b774145e 100644
--- a/packages/cli-common/README.md
+++ b/packages/cli-common/README.md
@@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json
index abb4b5f9a1..0d929e53e2 100644
--- a/packages/cli-common/package.json
+++ b/packages/cli-common/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli-common",
"description": "Common functionality used by cli, backend, and create-app",
- "version": "0.1.1-alpha.24",
+ "version": "0.1.1",
"private": false,
"main": "src/index.ts",
"types": "src/index.ts",
@@ -13,7 +13,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/cli-common"
},
"keywords": [
diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts
index 087f95af38..2278ee3581 100644
--- a/packages/cli-common/src/paths.test.ts
+++ b/packages/cli-common/src/paths.test.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+/* eslint-disable no-restricted-syntax */
import { resolve as resolvePath } from 'path';
import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths';
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
new file mode 100644
index 0000000000..a5bcc6bcfd
--- /dev/null
+++ b/packages/cli/CHANGELOG.md
@@ -0,0 +1,79 @@
+# @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
+
+- 28edd7d29: Create backend plugin through CLI
+- 1d0aec70f: Upgrade dependency `esbuild@0.7.7`
+- 72f6cda35: Adds a new `BACKSTAGE_CLI_BUILD_PARELLEL` environment variable to control
+ parallelism for some build steps.
+
+ This is useful in CI to help avoid out of memory issues when using `terser`. The
+ `BACKSTAGE_CLI_BUILD_PARELLEL` environment variable can be set to
+ `true | false | [integer]` to override the default behaviour. See
+ [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin#parallel)
+ for more details.
+
+- 8c2b76e45: **BREAKING CHANGE**
+
+ The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed.
+ Instead, the CLI and backend process now accept one or more `--config` flags to load config files.
+
+ Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded.
+ If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one.
+
+ The old behaviour of for example `APP_ENV=development` can be replicated using the following flags:
+
+ ```bash
+ --config ../../app-config.yaml --config ../../app-config.development.yaml
+ ```
+
+- 8afce088a: Use APP_ENV before NODE_ENV for determining what config to load
+
+### Patch Changes
+
+- 3472c8be7: Add codeowners processor
+
+ - Include ESNext.Promise in TypeScript compilation
+
+- a3840bed2: Upgrade dependency rollup-plugin-typescript2 to ^0.27.3
+- cba4e4d97: Including source maps with all packages
+- 9a3b3dbf1: Fixed duplicate help output, and print help on invalid command
+- 7bbeb049f: Change loadBackendConfig to return the config directly
+- Updated dependencies [8c2b76e45]
+- Updated dependencies [ce5512bc0]
+ - @backstage/config-loader@0.2.0
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 8868e699c8..9eac5cdfe4 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -30,5 +30,5 @@ To try out the command locally, you can execute the following from the parent di
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli
index a065b8ac5a..5c127af2ec 100755
--- a/packages/cli/bin/backstage-cli
+++ b/packages/cli/bin/backstage-cli
@@ -18,6 +18,7 @@
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
+/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src'));
if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
@@ -25,6 +26,7 @@ if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
} else {
require('ts-node').register({
transpileOnly: true,
+ /* eslint-disable-next-line no-restricted-syntax */
project: path.resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index 113ba55818..67f0a49b9f 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -37,6 +37,10 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
rules: {
+ // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these
+ '@typescript-eslint/no-shadow': 0,
+ '@typescript-eslint/no-redeclare': 0,
+
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
'import/newline-after-import': 'error',
@@ -67,6 +71,11 @@ module.exports = {
selector:
'ImportDeclaration[source.value="winston"] ImportDefaultSpecifier',
},
+ {
+ message:
+ "`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-common` instead.",
+ selector: 'Identifier[name="__dirname"]',
+ },
],
},
overrides: [
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 3e171d1f82..9b1880c1db 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -41,6 +41,10 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
rules: {
+ // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these
+ '@typescript-eslint/no-shadow': 0,
+ '@typescript-eslint/no-redeclare': 0,
+
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index f69e18190d..61cd83b7b0 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -77,14 +77,17 @@ async function getConfig() {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
},
+ globals: {
+ 'ts-jest': {
+ isolatedModules: true,
+ },
+ },
+
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
transform: {
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
- '\\.(js|jsx|ts|tsx)$': [
- require.resolve('ts-jest'),
- { isolatedModules: true },
- ],
+ '\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'),
'\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve(
'./jestFileTransform.js',
),
@@ -96,7 +99,7 @@ async function getConfig() {
// Default behaviour is to not apply transforms for node_modules, but we still want
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: [
- `/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`,
+ `/node_modules/${transformModulePattern}.*\\.(?:(? {
- const appConfigs = await loadConfig({
- env: process.env.NODE_ENV ?? 'production',
- rootPaths: [paths.targetRoot, paths.targetDir],
- });
await buildBundle({
entry: 'src/index',
+ parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
statsJsonEnabled: cmd.stats,
- config: ConfigReader.fromConfigs(appConfigs),
- appConfigs,
+ ...(await loadCliConfig(cmd.config)),
});
};
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index c748d12a75..c3f58774b5 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -15,21 +15,14 @@
*/
import { Command } from 'commander';
-import { loadConfig } from '@backstage/config-loader';
-import { ConfigReader } from '@backstage/config';
-import { paths } from '../../lib/paths';
import { serveBundle } from '../../lib/bundler';
+import { loadCliConfig } from '../../lib/config';
export default async (cmd: Command) => {
- const appConfigs = await loadConfig({
- env: process.env.NODE_ENV ?? 'development',
- rootPaths: [paths.targetRoot, paths.targetDir],
- });
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
- config: ConfigReader.fromConfigs(appConfigs),
- appConfigs,
+ ...(await loadCliConfig(cmd.config)),
});
await waitForExit();
diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts
index b6e9da7b39..b6ae0dd579 100644
--- a/packages/cli/src/commands/backend/buildImage.ts
+++ b/packages/cli/src/commands/backend/buildImage.ts
@@ -14,12 +14,13 @@
* limitations under the License.
*/
+import { Command } from 'commander';
import fs from 'fs-extra';
import { join as joinPath, relative as relativePath } from 'path';
import { createDistWorkspace } from '../../lib/packager';
import { paths } from '../../lib/paths';
import { run } from '../../lib/run';
-import { Command } from 'commander';
+import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
const PKG_PATH = 'package.json';
@@ -41,6 +42,7 @@ export default async (cmd: Command) => {
...appConfigs,
{ src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' },
],
+ parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
skeleton: 'skeleton.tar',
});
console.log(`Dist workspace ready at ${tempDistWorkspace}`);
diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts
index 8b2ff9285a..88c069a92b 100644
--- a/packages/cli/src/commands/backend/dev.ts
+++ b/packages/cli/src/commands/backend/dev.ts
@@ -14,24 +14,14 @@
* limitations under the License.
*/
-import { ConfigReader } from '@backstage/config';
-import { loadConfig } from '@backstage/config-loader';
import { Command } from 'commander';
-import { paths } from '../../lib/paths';
import { serveBackend } from '../../lib/bundler/backend';
export default async (cmd: Command) => {
- const appConfigs = await loadConfig({
- env: process.env.NODE_ENV ?? 'development',
- rootPaths: [paths.targetRoot, paths.targetDir],
- });
-
const waitForExit = await serveBackend({
entry: 'src/index',
checksEnabled: cmd.check,
inspectEnabled: cmd.inspect,
- config: ConfigReader.fromConfigs(appConfigs),
- appConfigs,
});
await waitForExit();
diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts
index bb626f520f..9649c70ef3 100644
--- a/packages/cli/src/commands/config/print.ts
+++ b/packages/cli/src/commands/config/print.ts
@@ -15,23 +15,53 @@
*/
import { Command } from 'commander';
-import { loadConfig } from '@backstage/config-loader';
-import { ConfigReader } from '@backstage/config';
-import { paths } from '../../lib/paths';
import { stringify as stringifyYaml } from 'yaml';
+import { AppConfig, ConfigReader } from '@backstage/config';
+import { loadCliConfig } from '../../lib/config';
+import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader';
export default async (cmd: Command) => {
- const appConfigs = await loadConfig({
- env: cmd.env ?? process.env.NODE_ENV ?? 'development',
- shouldReadSecrets: cmd.withSecrets ?? false,
- rootPaths: [paths.targetRoot, paths.targetDir],
- });
-
- const flatConfig = ConfigReader.fromConfigs(appConfigs).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/config/validate.ts b/packages/cli/src/commands/config/validate.ts
new file mode 100644
index 0000000000..229f9e07e9
--- /dev/null
+++ b/packages/cli/src/commands/config/validate.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Command } from 'commander';
+import { loadCliConfig } from '../../lib/config';
+
+export default async (cmd: Command) => {
+ await loadCliConfig(cmd.config);
+};
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts
index 72b95b072c..660caec189 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts
@@ -16,54 +16,27 @@
import fs from 'fs-extra';
import path from 'path';
-import os from 'os';
-import del from 'del';
-import { createTemporaryPluginFolder, movePlugin } from './createPlugin';
+import mockFs from 'mock-fs';
+import { movePlugin } from './createPlugin';
+
+const id = 'testPluginMock';
describe('createPlugin', () => {
- describe('createPluginFolder', () => {
- it('should create a temporary plugin directory in the correct place', async () => {
- const id = 'testPlugin';
- const tempDir = path.join(os.tmpdir(), id);
- try {
- await createTemporaryPluginFolder(tempDir);
- await expect(fs.pathExists(tempDir)).resolves.toBe(true);
- expect(tempDir).toMatch(id);
- } finally {
- await del(tempDir, { force: true });
- }
- });
-
- it('should not create a temporary plugin directory if it already exists', async () => {
- const id = 'testPlugin';
- const tempDir = path.join(os.tmpdir(), id);
- try {
- await createTemporaryPluginFolder(tempDir);
- await expect(fs.pathExists(tempDir)).resolves.toBe(true);
- await expect(createTemporaryPluginFolder(tempDir)).rejects.toThrow(
- /Failed to create temporary plugin directory/,
- );
- } finally {
- await del(tempDir, { force: true });
- }
- });
+ afterAll(() => {
+ mockFs.restore();
});
describe('movePlugin', () => {
it('should move the temporary plugin directory to its final place', async () => {
- const id = 'testPlugin';
- const tempDir = path.join(os.tmpdir(), id);
- const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
- const pluginDir = path.join(rootDir, 'plugins', id);
- try {
- await createTemporaryPluginFolder(tempDir);
- await movePlugin(tempDir, pluginDir, id);
- await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
- expect(pluginDir).toMatch(path.join('', 'plugins', id));
- } finally {
- await del(tempDir, { force: true });
- await del(rootDir, { force: true });
- }
+ mockFs({
+ [id]: {},
+ });
+ const tempDir = id;
+ const pluginDir = path.join('test-temp', 'plugins', id);
+
+ await movePlugin(tempDir, pluginDir, id);
+ await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
+ expect(pluginDir).toMatch(path.join('', 'plugins', id));
});
});
});
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index cbaf956bdb..a2005c5580 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -19,7 +19,7 @@ import { promisify } from 'util';
import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
-import { resolve as resolvePath } from 'path';
+import { resolve as resolvePath, join as joinPath } from 'path';
import os from 'os';
import { Command } from 'commander';
import {
@@ -28,8 +28,8 @@ import {
getCodeownersFilePath,
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
+import { packageVersions } from '../../lib/version';
import { Task, templatingTask } from '../../lib/tasks';
-import { version as backstageVersion } from '../../lib/version';
const exec = promisify(execCb);
@@ -46,18 +46,6 @@ async function checkExists(destination: string) {
});
}
-export async function createTemporaryPluginFolder(tempDir: string) {
- await Task.forItem('creating', 'temporary directory', async () => {
- try {
- await fs.mkdir(tempDir);
- } catch (error) {
- throw new Error(
- `Failed to create temporary plugin directory: ${error.message}`,
- );
- }
- });
-}
-
const sortObjectByKeys = (obj: { [name in string]: string }) => {
return Object.keys(obj)
.sort()
@@ -152,15 +140,21 @@ async function buildPlugin(pluginFolder: string) {
'yarn build',
];
for (const command of commands) {
- await Task.forItem('executing', command, async () => {
- process.chdir(pluginFolder);
-
- await exec(command).catch(error => {
+ try {
+ await Task.forItem('executing', command, async () => {
+ process.chdir(pluginFolder);
+ await exec(command);
+ }).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
- throw new Error(`Could not execute command ${chalk.cyan(command)}`);
+ throw new Error(
+ `Warning: Could not execute command ${chalk.cyan(command)}`,
+ );
});
- });
+ } catch (error) {
+ Task.error(error.message);
+ break;
+ }
}
}
@@ -224,9 +218,11 @@ export default async (cmd: Command) => {
}
const answers: Answers = await inquirer.prompt(questions);
+ const pluginId = cmd.backend ? `${answers.id}-backend` : answers.id;
+
const name = cmd.scope
- ? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}`
- : `plugin-${answers.id}`;
+ ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}`
+ : `plugin-${pluginId}`;
const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
const privatePackage = cmd.private === false ? false : true;
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
@@ -236,54 +232,59 @@ export default async (cmd: Command) => {
? 'templates/default-backend-plugin'
: 'templates/default-plugin',
);
- const tempDir = resolvePath(os.tmpdir(), answers.id);
const pluginDir = isMonoRepo
- ? paths.resolveTargetRoot('plugins', answers.id)
- : paths.resolveTargetRoot(answers.id);
+ ? paths.resolveTargetRoot('plugins', pluginId)
+ : paths.resolveTargetRoot(pluginId);
const ownerIds = parseOwnerIds(answers.owner);
- const { version } = isMonoRepo
+ const { version: pluginVersion } = isMonoRepo
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
: { version: '0.1.0' };
Task.log();
Task.log('Creating the plugin...');
+ Task.section('Checking if the plugin ID is available');
+ await checkExists(pluginDir);
+
+ Task.section('Creating a temporary plugin directory');
+ const tempDir = await fs.mkdtemp(
+ joinPath(os.tmpdir(), `backstage-plugin-${pluginId}`),
+ );
+
try {
- Task.section('Checking if the plugin ID is available');
- await checkExists(pluginDir);
-
- Task.section('Creating a temporary plugin directory');
- await createTemporaryPluginFolder(tempDir);
-
Task.section('Preparing files');
- await templatingTask(templateDir, tempDir, {
- ...answers,
- version,
- backstageVersion,
- name,
- privatePackage,
- npmRegistry,
- });
+ await templatingTask(
+ templateDir,
+ tempDir,
+ {
+ ...answers,
+ pluginVersion,
+ name,
+ privatePackage,
+ npmRegistry,
+ },
+ packageVersions,
+ );
Task.section('Moving to final location');
- await movePlugin(tempDir, pluginDir, answers.id);
+ await movePlugin(tempDir, pluginDir, pluginId);
Task.section('Building the plugin');
await buildPlugin(pluginDir);
if ((await fs.pathExists(appPackage)) && !cmd.backend) {
Task.section('Adding plugin as dependency in app');
- await addPluginDependencyToApp(paths.targetRoot, name, version);
+ await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
Task.section('Import plugin in app');
- await addPluginToApp(paths.targetRoot, answers.id, name);
+ await addPluginToApp(paths.targetRoot, pluginId, name);
}
if (ownerIds && ownerIds.length) {
await addCodeownersEntry(
codeownersPath!,
- `/plugins/${answers.id}`,
+ `/plugins/${pluginId}`,
ownerIds,
);
}
diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts
index fea250cd55..1ebe518cac 100644
--- a/packages/cli/src/commands/index.ts
+++ b/packages/cli/src/commands/index.ts
@@ -18,16 +18,25 @@ import { CommanderStatic } from 'commander';
import { exitWithError } from '../lib/errors';
export function registerCommands(program: CommanderStatic) {
+ const configOption = [
+ '--config ',
+ 'Config files to load instead of app-config.yaml',
+ (opt: string, opts: string[]) => [...opts, opt],
+ Array(),
+ ] as const;
+
program
.command('app:build')
.description('Build an app for a production release')
.option('--stats', 'Write bundle stats to output directory')
+ .option(...configOption)
.action(lazy(() => import('./app/build').then(m => m.default)));
program
.command('app:serve')
.description('Serve an app for local development')
.option('--check', 'Enable type checking and linting')
+ .option(...configOption)
.action(lazy(() => import('./app/serve').then(m => m.default)));
program
@@ -50,6 +59,8 @@ export function registerCommands(program: CommanderStatic) {
.description('Start local development server with HMR for the backend')
.option('--check', 'Enable type checking and linting')
.option('--inspect', 'Enable debugger')
+ // We don't actually use the config in the CLI, just pass them on to the NodeJS process
+ .option(...configOption)
.action(lazy(() => import('./backend/dev').then(m => m.default)));
program
@@ -89,14 +100,9 @@ export function registerCommands(program: CommanderStatic) {
.command('plugin:serve')
.description('Serves the dev/ folder of a plugin')
.option('--check', 'Enable type checking and linting')
+ .option(...configOption)
.action(lazy(() => import('./plugin/serve').then(m => m.default)));
- program
- .command('plugin:export')
- .description('Exports the dev/ folder of a plugin')
- .option('--stats', 'Write bundle stats to output directory')
- .action(lazy(() => import('./plugin/export').then(m => m.default)));
-
program
.command('plugin:diff')
.option('--check', 'Fail if changes are required')
@@ -130,18 +136,24 @@ 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(
- '--env ',
- 'The environment to print configuration for [NODE_ENV or development]',
- )
.option(
'--format ',
'Format to print the configuration in, either json or yaml [yaml]',
)
+ .option(...configOption)
.description('Print the app configuration for the current package')
.action(lazy(() => import('./config/print').then(m => m.default)));
+ 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('prepack')
.description('Prepares a package for packaging before publishing')
diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts
index f5dfa670b7..416d67869b 100644
--- a/packages/cli/src/commands/plugin/diff.ts
+++ b/packages/cli/src/commands/plugin/diff.ts
@@ -25,13 +25,12 @@ import {
yesPromptFunc,
} from '../../lib/diff';
import { paths } from '../../lib/paths';
-import { version as backstageVersion } from '../../lib/version';
export type PluginData = {
id: string;
name: string;
privatePackage: string;
- version: string;
+ pluginVersion: string;
npmRegistry: string;
};
@@ -40,17 +39,13 @@ const fileHandlers = [
patterns: ['package.json'],
handler: handlers.packageJson,
},
- {
- patterns: ['tsconfig.json'],
- handler: handlers.exactMatch,
- },
{
// make sure files in 1st level of src/ and dev/ exist
patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/],
handler: handlers.exists,
},
{
- patterns: ['README.md', /^src\//],
+ patterns: ['README.md', 'tsconfig.json', /^src\//],
handler: handlers.skip,
},
];
@@ -66,10 +61,7 @@ export default async (cmd: Command) => {
}
const data = await readPluginData();
- const templateFiles = await diffTemplateFiles('default-plugin', {
- backstageVersion,
- ...data,
- });
+ const templateFiles = await diffTemplateFiles('default-plugin', data);
await handleAllFiles(fileHandlers, templateFiles, promptFunc);
await finalize();
};
@@ -78,13 +70,13 @@ export default async (cmd: Command) => {
async function readPluginData(): Promise {
let name: string;
let privatePackage: string;
- let version: string;
+ let pluginVersion: string;
let npmRegistry: string;
try {
const pkg = require(paths.resolveTarget('package.json'));
name = pkg.name;
privatePackage = pkg.private;
- version = pkg.version;
+ pluginVersion = pkg.version;
const scope = name.split('/')[0];
if (`${scope}:registry` in pkg.publishConfig) {
const registryURL = pkg.publishConfig[`${scope}:registry`];
@@ -106,5 +98,5 @@ async function readPluginData(): Promise {
const id = pluginIdMatch[1];
- return { id, name, privatePackage, version, npmRegistry };
+ return { id, name, privatePackage, pluginVersion, npmRegistry };
}
diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts
index 8a04df8837..cb9def3158 100644
--- a/packages/cli/src/commands/plugin/serve.ts
+++ b/packages/cli/src/commands/plugin/serve.ts
@@ -15,21 +15,14 @@
*/
import { Command } from 'commander';
-import { loadConfig } from '@backstage/config-loader';
-import { ConfigReader } from '@backstage/config';
-import { paths } from '../../lib/paths';
import { serveBundle } from '../../lib/bundler';
+import { loadCliConfig } from '../../lib/config';
export default async (cmd: Command) => {
- const appConfigs = await loadConfig({
- env: process.env.NODE_ENV ?? 'development',
- rootPaths: [paths.targetRoot, paths.targetDir],
- });
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
- config: ConfigReader.fromConfigs(appConfigs),
- appConfigs,
+ ...(await loadCliConfig(cmd.config)),
});
await waitForExit();
diff --git a/packages/cli/src/commands/remove-plugin/file-mocks.ts b/packages/cli/src/commands/remove-plugin/file-mocks.ts
new file mode 100644
index 0000000000..5768f5c398
--- /dev/null
+++ b/packages/cli/src/commands/remove-plugin/file-mocks.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const pluginsFileContent = `
+export { plugin as WelcomePlugin } from '@backstage/plugin-welcome';
+export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';`;
+
+export const codeownersFileContent = `
+* @spotify/backstage-core
+/docs/features/techdocs @spotify/techdocs-core
+/plugins/cost-insights @spotify/silver-lining
+`;
+
+export const packageFileContent = {
+ name: 'example-app',
+ version: '0.1.1',
+ dependencies: {},
+ devDependencies: {},
+ scripts: {},
+};
diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts
index 3ffa8d63cf..56cde2ca9e 100644
--- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts
+++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts
@@ -16,13 +16,9 @@
import fse from 'fs-extra';
import path from 'path';
-import os from 'os';
+import mockFs from 'mock-fs';
import { paths } from '../../lib/paths';
-import {
- addExportStatement,
- capitalize,
- createTemporaryPluginFolder,
-} from '../create-plugin/createPlugin';
+import { addExportStatement, capitalize } from '../create-plugin/createPlugin';
import { addCodeownersEntry } from '../../lib/codeowners';
import {
removeReferencesFromAppPackage,
@@ -31,160 +27,209 @@ import {
removeSymLink,
removePluginFromCodeOwners,
} from './removePlugin';
+import {
+ codeownersFileContent,
+ packageFileContent,
+ pluginsFileContent,
+} from './file-mocks';
-// Some constant variables
const BACKSTAGE = `@backstage`;
const testPluginName = 'yarn-test-package';
const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`;
-const tempDir = path.join(os.tmpdir(), 'remove-plugin-test');
+const tempDir = '/remove-plugin-test';
const removeEmptyLines = (file: string): string =>
file.split(/\r?\n/).filter(Boolean).join('\n');
-const createTestPackageFile = async (
- testFilePath: string,
- packageFile: string,
-) => {
- // Copy contents of package file for test
- const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8'));
+const createTestPackageFile = async (testFilePath: string) => {
+ const testFileContent = {
+ ...packageFileContent,
+ dependencies: {
+ ...packageFileContent.dependencies,
+ [testPluginPackage]: '0.1.0',
+ },
+ };
- packageFileContent.dependencies[testPluginPackage] = '0.1.0';
- fse.createFileSync(testFilePath);
- fse.writeFileSync(
- testFilePath,
- `${JSON.stringify(packageFileContent, null, 2)}\n`,
- 'utf8',
- );
+ mockFs({
+ packages: {
+ app: {
+ 'package.json': `${JSON.stringify(packageFileContent, null, 2)}\n`,
+ },
+ },
+ [tempDir]: {
+ [testFilePath]: `${JSON.stringify(testFileContent, null, 2)}\n`,
+ },
+ });
return;
};
const createTestPluginFile = async (
- testFilePath: string,
- pluginsFilePath: string,
+ testFileName: string,
+ pluginsFileName: string,
) => {
- // Copy contents of package file for test
- fse.copyFileSync(pluginsFilePath, testFilePath);
+ mockFs({
+ [tempDir]: {
+ [testFileName]: `${pluginsFileContent}\n`,
+ [pluginsFileName]: `${pluginsFileContent}\n`,
+ },
+ packages: {
+ app: {
+ src: {
+ 'plugin.ts': `${pluginsFileContent}\n`,
+ },
+ },
+ },
+ });
+
const pluginNameCapitalized = testPluginName
.split('-')
.map(name => capitalize(name))
.join('');
- const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`;
- addExportStatement(testFilePath, exportStatement);
+ const exportStatement = `export { plugin as ${pluginNameCapitalized}} from ${testPluginPackage}`;
+ await addExportStatement(path.join(tempDir, testFileName), exportStatement);
};
const mkTestPluginDir = (testDirPath: string) => {
- fse.mkdirSync(testDirPath);
- for (let i = 0; i < 50; i++)
- fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`));
+ const pluginFiles: { [index: string]: string } = {};
+ for (let i = 0; i < 50; i++) {
+ pluginFiles[`testFile${i}.ts`] = '';
+ }
+
+ mockFs({
+ [testDirPath]: pluginFiles,
+ });
};
-beforeAll(() => {
- // Create temporary directory for all tests
- createTemporaryPluginFolder(tempDir);
-});
-
describe('removePlugin', () => {
- describe('Remove Plugin Dependencies', () => {
- const appPath = paths.resolveTargetRoot('packages', 'app');
- const githubDir = paths.resolveTargetRoot('.github');
- it('removes plugin references from /packages/app/package.json', async () => {
- // Set up test
- const packageFilePath = path.join(appPath, 'package.json');
- const testFilePath = path.join(tempDir, 'test.json');
- createTestPackageFile(testFilePath, packageFilePath);
- try {
- await removeReferencesFromAppPackage(testFilePath, testPluginName);
- const testFileContent = removeEmptyLines(
- fse.readFileSync(testFilePath, 'utf8'),
- );
- const packageFileContent = removeEmptyLines(
- fse.readFileSync(packageFilePath, 'utf8'),
- );
- expect(testFileContent).toBe(packageFileContent);
- } finally {
- fse.removeSync(testFilePath);
- }
- });
- it('removes plugin exports from /packages/app/src/packacge.json', async () => {
- const testFilePath = path.join(tempDir, 'test.ts');
- const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts');
- createTestPluginFile(testFilePath, pluginsFilePaths);
- try {
- await removeReferencesFromPluginsFile(testFilePath, testPluginName);
- const testFileContent = removeEmptyLines(
- fse.readFileSync(testFilePath, 'utf8'),
- );
- const pluginsFileContent = removeEmptyLines(
- fse.readFileSync(pluginsFilePaths, 'utf8'),
- );
- expect(testFileContent).toBe(pluginsFileContent);
- } finally {
- fse.removeSync(testFilePath);
- }
- });
- it('removes codeOwners references', async () => {
- const testFilePath = path.join(tempDir, 'test');
- const codeownersPath = path.join(githubDir, 'CODEOWNERS');
- try {
- fse.copySync(codeownersPath, testFilePath);
- const testFileContent = removeEmptyLines(
- fse.readFileSync(testFilePath, 'utf8'),
- );
- const codeOwnersFileContent = removeEmptyLines(
- fse.readFileSync(codeownersPath, 'utf8'),
- );
- await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [
- '@thisIsAtestTeam',
- 'test@gmail.com',
- ]);
- await removePluginFromCodeOwners(testFilePath, testPluginName);
- expect(testFileContent).toBe(codeOwnersFileContent);
- } finally {
- if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath);
- }
+ beforeAll(() => {
+ // Create temporary directory for all tests
+ mockFs({
+ [tempDir]: {
+ 'package.json': packageFileContent,
+ src: {
+ 'plugin.ts': pluginsFileContent,
+ },
+ },
});
});
+
+ afterAll(() => {
+ mockFs.restore();
+ });
+
+ describe('Remove Plugin Dependencies', () => {
+ it('removes plugin references from /packages/app/package.json', async () => {
+ // Set up test
+ const testFilePath = 'test.json';
+ createTestPackageFile(testFilePath);
+ await removeReferencesFromAppPackage(
+ path.join(tempDir, testFilePath),
+ testPluginName,
+ );
+ const testFileContent = removeEmptyLines(
+ fse.readFileSync(path.join(tempDir, testFilePath), 'utf8'),
+ );
+
+ const mockedPackageFileContent = removeEmptyLines(
+ fse.readFileSync(path.join('packages', 'app', 'package.json'), 'utf8'),
+ );
+ expect(testFileContent).toBe(mockedPackageFileContent);
+ });
+ it('removes plugin exports from /packages/app/src/package.json', async () => {
+ const testFileName = 'test.ts';
+ const pluginsFileName = 'plugin.ts';
+ createTestPluginFile(testFileName, pluginsFileName);
+ await removeReferencesFromPluginsFile(
+ path.join(tempDir, testFileName),
+ testPluginName,
+ );
+ const testFileContent = removeEmptyLines(
+ fse.readFileSync(path.join(tempDir, testFileName), 'utf8'),
+ );
+ const mockedPluginsFileContent = removeEmptyLines(
+ fse.readFileSync(
+ path.join('packages', 'app', 'src', pluginsFileName),
+ 'utf8',
+ ),
+ );
+ expect(testFileContent).toBe(mockedPluginsFileContent);
+ });
+
+ it('removes codeOwners references', async () => {
+ const testFileName = 'test';
+ const testFilePath = path.join(tempDir, testFileName);
+
+ const mockedCodeownersPath = path.join('.github', 'CODEOWNERS');
+
+ mockFs({
+ [tempDir]: {
+ [testFileName]: '',
+ },
+ '.github': {
+ CODEOWNERS: codeownersFileContent,
+ },
+ });
+ fse.copySync(mockedCodeownersPath, testFilePath);
+ const testFileContent = removeEmptyLines(
+ fse.readFileSync(testFilePath, 'utf8'),
+ );
+ const codeOwnersFileContent = removeEmptyLines(
+ fse.readFileSync(mockedCodeownersPath, 'utf8'),
+ );
+ await addCodeownersEntry(
+ testFilePath!,
+ path.join('plugins', testPluginName),
+ ['@thisIsAtestTeam', 'test@gmail.com'],
+ );
+ await removePluginFromCodeOwners(testFilePath, testPluginName);
+ expect(testFileContent).toBe(codeOwnersFileContent);
+ });
+ });
+
describe('Remove files', () => {
const testDirPath = path.join(
paths.resolveTargetRoot(),
'plugins',
testPluginName,
);
+
describe('Removes Plugin Directory', () => {
it('removes plugin directory from /plugins', async () => {
- try {
- mkTestPluginDir(testDirPath);
- expect(fse.existsSync(testDirPath)).toBeTruthy();
- await removePluginDirectory(testDirPath);
- expect(fse.existsSync(testDirPath)).toBeFalsy();
- } finally {
- if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath);
- }
+ mkTestPluginDir(testDirPath);
+ expect(fse.existsSync(testDirPath)).toBeTruthy();
+ await removePluginDirectory(testDirPath);
+ expect(fse.existsSync(testDirPath)).toBeFalsy();
});
});
+
describe('Removes System Link', () => {
it('removes system link from @backstage', async () => {
- const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage');
+ const symLink = `plugin-${testPluginName}`;
const testSymLinkPath = path.join(
- scopedDir,
- `plugin-${testPluginName}`,
+ '/',
+ 'node_modules',
+ '@backstage',
+ symLink,
);
- try {
- mkTestPluginDir(testDirPath);
- fse.ensureSymlinkSync(testSymLinkPath, testDirPath);
+ const mockedTestDirPath = path.join('/', 'plugins', testPluginName);
- await removeSymLink(testSymLinkPath);
- expect(fse.existsSync(testSymLinkPath)).toBeFalsy();
- } finally {
- if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath);
- if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath);
- }
+ mockFs({
+ '/plugins': {
+ [testPluginName]: {},
+ },
+ '/node_modules': {
+ '@backstage': {
+ [symLink]: mockFs.symlink({
+ path: mockedTestDirPath,
+ }),
+ },
+ },
+ });
+
+ expect(fse.existsSync(testSymLinkPath)).toBeTruthy();
+ await removeSymLink(testSymLinkPath);
+ expect(fse.existsSync(testSymLinkPath)).toBeFalsy();
});
});
});
});
-
-afterAll(() => {
- // Remove temporary directory
- fse.removeSync(tempDir);
-});
diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts
index 1395384022..81d8322ffa 100644
--- a/packages/cli/src/commands/remove-plugin/removePlugin.ts
+++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts
@@ -20,7 +20,6 @@ import inquirer, { Answers, Question } from 'inquirer';
import { getCodeownersFilePath } from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { Task } from '../../lib/tasks';
-// import os from 'os';
const BACKSTAGE = '@backstage';
@@ -63,7 +62,7 @@ export const removePluginDirectory = async (destination: string) => {
export const removeSymLink = async (destination: string) => {
await Task.forItem('removing', 'symbolic link', async () => {
- const symLinkExists = fse.pathExists(destination);
+ const symLinkExists = await fse.pathExists(destination);
if (symLinkExists) {
try {
await fse.remove(destination);
@@ -190,7 +189,7 @@ export default async () => {
return chalk.red('Please enter an ID for the plugin');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
- 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.',
+ 'Plugin IDs must be kebab-cased and contain only letters, digits and dashes.',
);
}
return true;
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index e278164f4c..28e46bbd1a 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -27,18 +27,12 @@ const main = (argv: string[]) => {
program.on('command:*', () => {
console.log();
- console.log(
- chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`),
- );
- console.log(chalk.red('See --help for a list of available commands.'));
+ console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`));
console.log();
+ program.outputHelp();
process.exit(1);
});
- if (!process.argv.slice(2).length) {
- program.outputHelp(chalk.yellow);
- }
-
program.parse(argv);
};
diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts
index 2566460494..2e7847fdb1 100644
--- a/packages/cli/src/lib/builder/config.ts
+++ b/packages/cli/src/lib/builder/config.ts
@@ -64,6 +64,7 @@ export const makeConfigs = async (
entryFileNames: 'index.cjs.js',
chunkFileNames: 'cjs/[name]-[hash].js',
format: 'commonjs',
+ sourcemap: true,
});
}
if (options.outputs.has(Output.esm)) {
@@ -72,6 +73,7 @@ export const makeConfigs = async (
entryFileNames: 'index.esm.js',
chunkFileNames: 'esm/[name]-[hash].js',
format: 'module',
+ sourcemap: true,
});
// Assume we're building for the browser if ESM output is included
mainFields.unshift('browser');
diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts
index e3cc8af8d1..9633c1b963 100644
--- a/packages/cli/src/lib/bundler/backend.ts
+++ b/packages/cli/src/lib/bundler/backend.ts
@@ -17,13 +17,9 @@
import webpack from 'webpack';
import { createBackendConfig } from './config';
import { resolveBundlingPaths } from './paths';
-import { ServeOptions } from './types';
+import { BackendServeOptions } from './types';
-export async function serveBackend(
- options: ServeOptions & {
- inspectEnabled: boolean;
- },
-) {
+export async function serveBackend(options: BackendServeOptions) {
const paths = resolveBundlingPaths(options);
const config = await createBackendConfig(paths, {
...options,
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 118cb1b40c..9e150e0ccb 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -74,11 +74,11 @@ export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
): Promise {
- const { checksEnabled, isDev } = options;
+ const { checksEnabled, isDev, frontendConfig } = options;
const { plugins, loaders } = transforms(options);
- const baseUrl = options.config.getString('app.baseUrl');
+ const baseUrl = frontendConfig.getString('app.baseUrl');
const validBaseUrl = new URL(baseUrl);
if (checksEnabled) {
@@ -99,7 +99,7 @@ export async function createConfig(
plugins.push(
new webpack.EnvironmentPlugin({
- APP_CONFIG: options.appConfigs,
+ APP_CONFIG: options.frontendAppConfigs,
}),
);
@@ -109,8 +109,11 @@ 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: frontendConfig.getOptionalString(
+ 'app.googleAnalyticsTrackingId',
+ ),
},
},
}),
@@ -209,6 +212,7 @@ export async function createBackendConfig(
],
target: 'node' as const,
node: {
+ /* eslint-disable-next-line no-restricted-syntax */
__dirname: true,
__filename: true,
global: true,
diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts
index acd6a937d9..665eb10b21 100644
--- a/packages/cli/src/lib/bundler/optimization.ts
+++ b/packages/cli/src/lib/bundler/optimization.ts
@@ -15,7 +15,9 @@
*/
import { Options } from 'webpack';
+import TerserPlugin from 'terser-webpack-plugin';
import { BundlingOptions } from './types';
+import { isParallelDefault } from '../parallel';
export const optimization = (
options: BundlingOptions,
@@ -24,6 +26,16 @@ export const optimization = (
return {
minimize: !isDev,
+ // Only configure when parallel is explicitly overriden from the default
+ ...(!isParallelDefault(options.parallel)
+ ? {
+ minimizer: [
+ new TerserPlugin({
+ parallel: options.parallel,
+ }),
+ ],
+ }
+ : {}),
runtimeChunk: 'single',
splitChunks: {
automaticNameDelimiter: '-',
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/types.ts b/packages/cli/src/lib/bundler/types.ts
index 03d68d9bb8..71343a1761 100644
--- a/packages/cli/src/lib/bundler/types.ts
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -16,27 +16,40 @@
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;
-};
-
-export type BackendBundlingOptions = Omit & {
- inspectEnabled: boolean;
+ parallel?: ParallelOption;
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
- config: Config;
- appConfigs: AppConfig[];
+ frontendConfig: Config;
+ frontendAppConfigs: AppConfig[];
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
- config: Config;
- appConfigs: AppConfig[];
+ parallel?: ParallelOption;
+ schema?: ConfigSchema;
+ frontendConfig: Config;
+ frontendAppConfigs: AppConfig[];
+};
+
+export type BackendBundlingOptions = {
+ checksEnabled: boolean;
+ isDev: boolean;
+ parallel?: ParallelOption;
+ inspectEnabled: boolean;
+};
+
+export type BackendServeOptions = BundlingPathsOptions & {
+ checksEnabled: boolean;
+ inspectEnabled: boolean;
};
diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts
index 91b0e19782..aa24f91aac 100644
--- a/packages/cli/src/lib/codeowners/codeowners.ts
+++ b/packages/cli/src/lib/codeowners/codeowners.ts
@@ -20,7 +20,7 @@ import path from 'path';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
const EMAIL_RE = /^[^@]+@[-.\w]+\.[-\w]+$/i;
-const DEFAULT_OWNER = '@spotify/backstage-core';
+const DEFAULT_OWNER = '@backstage/maintainers';
type CodeownersEntry = {
ownedPath: string;
diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts
new file mode 100644
index 0000000000..08a8193818
--- /dev/null
+++ b/packages/cli/src/lib/config.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { loadConfig, loadConfigSchema } from '@backstage/config-loader';
+import { ConfigReader } from '@backstage/config';
+import { paths } from './paths';
+
+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({
+ env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
+ configRoot: paths.targetRoot,
+ configPaths,
+ });
+
+ console.log(
+ `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
+ );
+
+ 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/diff/read.ts b/packages/cli/src/lib/diff/read.ts
index 75e1b86d24..d37dfe4b03 100644
--- a/packages/cli/src/lib/diff/read.ts
+++ b/packages/cli/src/lib/diff/read.ts
@@ -24,6 +24,7 @@ import handlebars from 'handlebars';
import recursiveReadDir from 'recursive-readdir';
import { paths } from '../paths';
import { FileDiff } from './types';
+import { packageVersions } from '../../lib/version';
export type TemplatedFile = {
path: string;
@@ -40,7 +41,16 @@ async function readTemplateFile(
return contents;
}
- return handlebars.compile(contents)(templateVars);
+ return handlebars.compile(contents)(templateVars, {
+ helpers: {
+ version(name: keyof typeof packageVersions) {
+ if (name in packageVersions) {
+ return packageVersions[name];
+ }
+ throw new Error(`No version available for package ${name}`);
+ },
+ },
+ });
}
async function readTemplate(
diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts
index 564317e46a..8653e66d37 100644
--- a/packages/cli/src/lib/packager/index.ts
+++ b/packages/cli/src/lib/packager/index.ts
@@ -20,10 +20,19 @@ import {
resolve as resolvePath,
relative as relativePath,
} from 'path';
+import { tmpdir } from 'os';
+import tar, { CreateOptions } from 'tar';
import { paths } from '../paths';
import { run } from '../run';
-import tar, { CreateOptions } from 'tar';
-import { tmpdir } from 'os';
+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;
@@ -58,6 +67,11 @@ type Options = {
*/
buildDependencies?: boolean;
+ /**
+ * Enable (true/false) or control amount of (number) parallelism in some build steps.
+ */
+ parallel?: ParallelOption;
+
/**
* If set, creates a skeleton tarball that contains all package.json files
* with the same structure as the workspace dir.
@@ -85,7 +99,12 @@ export async function createDistWorkspace(
if (options.buildDependencies) {
const scopeArgs = targets.flatMap(target => ['--scope', target.name]);
- await run('yarn', ['lerna', 'run', ...scopeArgs, 'build'], {
+ const lernaArgs =
+ options.parallel && Number.isInteger(options.parallel)
+ ? ['--concurrency', options.parallel.toString()]
+ : [];
+
+ await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], {
cwd: paths.targetRoot,
});
}
@@ -124,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/parallel.test.ts b/packages/cli/src/lib/parallel.test.ts
new file mode 100644
index 0000000000..8047774a6e
--- /dev/null
+++ b/packages/cli/src/lib/parallel.test.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { isParallelDefault, parseParallel } from './parallel';
+
+describe('parallel', () => {
+ describe('parseParallel', () => {
+ it('coerces "false" string to boolean', () => {
+ expect(parseParallel('false')).toBeFalsy();
+ });
+
+ it('coerces "true" to boolean', () => {
+ expect(parseParallel('true')).toBeTruthy();
+ });
+
+ it('coerces number string to number', () => {
+ expect(parseParallel('2')).toBe(2);
+ });
+ it.each([[true], [false], [2]])('returns itself for %p', value => {
+ expect(parseParallel(value as any)).toEqual(value);
+ });
+
+ it.each([[undefined], [null]])('returns true for %p', value => {
+ expect(parseParallel(value as any)).toBe(true);
+ });
+
+ it.each([['on'], [2.5], ['2.5']])('throws error for %p', value => {
+ expect(() => parseParallel(value as any)).toThrowError(
+ `Parallel option value '${value}' is not a boolean or integer`,
+ );
+ });
+ });
+
+ describe('isParallelDefault', () => {
+ it('returns true if default value', () => {
+ expect(isParallelDefault(undefined)).toBeTruthy();
+ expect(isParallelDefault(true)).toBeTruthy();
+ });
+
+ it('returns false if not default value', () => {
+ expect(isParallelDefault(false)).toBeFalsy();
+ expect(isParallelDefault(2)).toBeFalsy();
+ expect(isParallelDefault('true' as any)).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/cli/src/lib/parallel.ts b/packages/cli/src/lib/parallel.ts
new file mode 100644
index 0000000000..b6926115aa
--- /dev/null
+++ b/packages/cli/src/lib/parallel.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const PARALLEL_ENV_VAR = 'BACKSTAGE_CLI_BUILD_PARALLEL';
+
+export type ParallelOption = boolean | number | undefined;
+
+export function isParallelDefault(parallel: ParallelOption) {
+ return parallel === undefined || parallel === true;
+}
+
+export function parseParallel(
+ parallel: boolean | string | number | undefined,
+): ParallelOption {
+ if (parallel === undefined || parallel === null) {
+ return true;
+ } else if (typeof parallel === 'boolean') {
+ return parallel;
+ } else if (typeof parallel === 'number' && Number.isInteger(parallel)) {
+ return parallel;
+ } else if (typeof parallel === 'string') {
+ if (parallel === 'true') {
+ return true;
+ } else if (parallel === 'false') {
+ return false;
+ } else if (Number.isInteger(parseFloat(parallel.toString()))) {
+ return Number(parallel);
+ }
+ }
+
+ throw Error(
+ `Parallel option value '${parallel}' is not a boolean or integer`,
+ );
+}
diff --git a/packages/cli/src/lib/paths.ts b/packages/cli/src/lib/paths.ts
index 06c8f2ab38..a17034344d 100644
--- a/packages/cli/src/lib/paths.ts
+++ b/packages/cli/src/lib/paths.ts
@@ -16,4 +16,5 @@
import { findPaths } from '@backstage/cli-common';
+/* eslint-disable-next-line no-restricted-syntax */
export const paths = findPaths(__dirname);
diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts
index 871d5a4d37..88d8050290 100644
--- a/packages/cli/src/lib/tasks.test.ts
+++ b/packages/cli/src/lib/tasks.test.ts
@@ -15,39 +15,51 @@
*/
import fs from 'fs-extra';
+import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
-import os from 'os';
-import del from 'del';
import { templatingTask } from './tasks';
describe('templatingTask', () => {
+ afterEach(() => {
+ mockFs.restore();
+ });
+
it('should template a directory with mix of regular files and templates', async () => {
- // Set up a testing template directory
- const tmplDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
- await fs.ensureDir(resolvePath(tmplDir, 'sub'));
- await fs.writeFile(resolvePath(tmplDir, 'test.txt'), 'testing');
- await fs.writeFile(
- resolvePath(tmplDir, 'sub/version.txt.hbs'),
- 'version: {{version}}',
+ // Testing template directory
+ const tmplDir = 'test-tmpl';
+
+ // Temporary dest dir to write the template to
+ const destDir = 'test-dest';
+
+ // Files content
+ const testFileContent = 'testing';
+ const testVersionFileContent =
+ "version: {{pluginVersion}} {{version 'mock-pkg'}}";
+
+ mockFs({
+ [tmplDir]: {
+ sub: {
+ 'version.txt.hbs': testVersionFileContent,
+ },
+ 'test.txt': testFileContent,
+ },
+ [destDir]: {},
+ });
+
+ await templatingTask(
+ tmplDir,
+ destDir,
+ {
+ pluginVersion: '0.0.0',
+ },
+ { 'mock-pkg': '0.1.2' },
);
- // Set up a temporary dest dir to write the template to
- const destDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
-
- try {
- await templatingTask(tmplDir, destDir, {
- version: '0.0.0',
- });
-
- await expect(
- fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'),
- ).resolves.toBe('testing');
- await expect(
- fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'),
- ).resolves.toBe('version: 0.0.0');
- } finally {
- await del(tmplDir, { force: true });
- await del(destDir, { force: true });
- }
+ await expect(
+ fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'),
+ ).resolves.toBe(testFileContent);
+ await expect(
+ fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'),
+ ).resolves.toBe('version: 0.0.0 0.1.2');
});
});
diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts
index 0753301b78..4b92c6b9cb 100644
--- a/packages/cli/src/lib/tasks.ts
+++ b/packages/cli/src/lib/tasks.ts
@@ -20,6 +20,7 @@ import handlebars from 'handlebars';
import ora from 'ora';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
+import { paths } from './paths';
const TASK_NAME_MAX_LENGTH = 14;
@@ -68,10 +69,12 @@ export async function templatingTask(
templateDir: string,
destinationDir: string,
context: any,
+ versions: { [name: string]: string },
) {
const files = await recursive(templateDir).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
+ const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
for (const file of files) {
const destinationFile = file.replace(templateDir, destinationDir);
@@ -83,7 +86,19 @@ export async function templatingTask(
const template = await fs.readFile(file);
const compiled = handlebars.compile(template.toString());
- const contents = compiled({ name: basename(destination), ...context });
+ const contents = compiled(
+ { name: basename(destination), ...context },
+ {
+ helpers: {
+ version(name: string) {
+ if (versions[name]) {
+ return versions[name];
+ }
+ throw new Error(`No version available for package ${name}`);
+ },
+ },
+ },
+ );
await fs.writeFile(destination, contents).catch(error => {
throw new Error(
@@ -92,6 +107,10 @@ export async function templatingTask(
});
});
} else {
+ if (isMonoRepo && file.match('tsconfig.json')) {
+ continue;
+ }
+
await Task.forItem('copying', basename(file), async () => {
await fs.copyFile(file, destinationFile).catch(error => {
const destination = destinationFile;
diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts
index 24734b87cc..b2b793717d 100644
--- a/packages/cli/src/lib/version.ts
+++ b/packages/cli/src/lib/version.ts
@@ -17,6 +17,38 @@
import fs from 'fs-extra';
import { paths } from './paths';
+/* eslint-disable import/no-extraneous-dependencies,monorepo/no-internal-import */
+/*
+This is a list of all packages used by the templates. If dependencies are added or removed,
+this list should be updated as well.
+
+The list, and the accompanying devDependencies entries, are here to ensure correct versioning
+and bumping of this package. Without this list the version would not be bumped unless we
+manually trigger a release.
+
+This does not create an actual dependency on these packages and does not bring in any code.
+Rollup will extract the value of the version field in each package at build time without
+leaving any imports in place.
+*/
+
+import { version as backendCommon } from '@backstage/backend-common/package.json';
+import { version as cli } from '@backstage/cli/package.json';
+import { version as config } from '@backstage/config/package.json';
+import { version as core } from '@backstage/core/package.json';
+import { version as devUtils } from '@backstage/dev-utils/package.json';
+import { version as testUtils } from '@backstage/test-utils/package.json';
+import { version as theme } from '@backstage/theme/package.json';
+
+export const packageVersions = {
+ '@backstage/backend-common': backendCommon,
+ '@backstage/cli': cli,
+ '@backstage/config': config,
+ '@backstage/core': core,
+ '@backstage/dev-utils': devUtils,
+ '@backstage/test-utils': testUtils,
+ '@backstage/theme': theme,
+};
+
export function findVersion() {
const pkgContent = fs.readFileSync(paths.resolveOwn('package.json'), 'utf8');
return JSON.parse(pkgContent).version;
diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts
index 94136f1dbc..828819ad17 100644
--- a/packages/cli/src/types.d.ts
+++ b/packages/cli/src/types.d.ts
@@ -29,3 +29,5 @@ declare module '@svgr/rollup' {
}
declare module '@rollup/plugin-yaml';
+
+declare module 'terser-webpack-plugin';
diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs
index 40e612609a..b762bb9645 100644
--- a/packages/cli/templates/default-backend-plugin/package.json.hbs
+++ b/packages/cli/templates/default-backend-plugin/package.json.hbs
@@ -1,6 +1,6 @@
{
"name": "{{name}}",
- "version": "{{version}}",
+ "version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -23,20 +23,20 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/backend-common": "^{{version}}",
- "@backstage/config": "^{{version}}",
+ "@backstage/backend-common": "^{{version '@backstage/backend-common'}}",
+ "@backstage/config": "^{{version '@backstage/config'}}",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"winston": "^3.2.1",
- "node-fetch": "^2.6.1",
+ "cross-fetch": "^3.0.6",
"yn": "^4.0.0"
},
"devDependencies": {
- "@backstage/cli": "^{{version}}",
+ "@backstage/cli": "^{{version '@backstage/cli'}}",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
- "msw": "^0.20.5"
+ "msw": "^0.21.2"
},
"files": [
"dist"
diff --git a/packages/cli/templates/default-backend-plugin/src/setupTests.ts b/packages/cli/templates/default-backend-plugin/src/setupTests.ts
index a5907fd52f..ba33cf996b 100644
--- a/packages/cli/templates/default-backend-plugin/src/setupTests.ts
+++ b/packages/cli/templates/default-backend-plugin/src/setupTests.ts
@@ -15,4 +15,3 @@
*/
export {};
-global.fetch = require('node-fetch');
diff --git a/packages/cli/templates/default-backend-plugin/tsconfig.json b/packages/cli/templates/default-backend-plugin/tsconfig.json
new file mode 100644
index 0000000000..d77f0fe3b4
--- /dev/null
+++ b/packages/cli/templates/default-backend-plugin/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@backstage/cli/config/tsconfig.json",
+ "include": [
+ "src",
+ "dev",
+ "migrations"
+ ],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "outDir": "dist-types",
+ "rootDir": "."
+ }
+}
diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs
index e024884547..a3f924408a 100644
--- a/packages/cli/templates/default-plugin/package.json.hbs
+++ b/packages/cli/templates/default-plugin/package.json.hbs
@@ -1,6 +1,6 @@
{
"name": "{{name}}",
- "version": "{{version}}",
+ "version": "{{pluginVersion}}",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -24,8 +24,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/core": "^{{backstageVersion}}",
- "@backstage/theme": "^{{backstageVersion}}",
+ "@backstage/core": "^{{version '@backstage/core'}}",
+ "@backstage/theme": "^{{version '@backstage/theme'}}",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -34,15 +34,16 @@
"react-use": "^15.3.3"
},
"devDependencies": {
- "@backstage/cli": "^{{backstageVersion}}",
- "@backstage/dev-utils": "^{{backstageVersion}}",
+ "@backstage/cli": "^{{version '@backstage/cli'}}",
+ "@backstage/dev-utils": "^{{version '@backstage/dev-utils'}}",
+ "@backstage/test-utils": "^{{version '@backstage/test-utils'}}",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
- "msw": "^0.20.5",
- "node-fetch": "^2.6.1"
+ "msw": "^0.21.2",
+ "cross-fetch": "^3.0.6"
},
"files": [
"dist"
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
index fdb39444d8..e805900f36 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
@@ -5,18 +5,13 @@ import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
+import { msw } from '@backstage/test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
- // Enable API mocking before tests.
- beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
-
- // Reset any runtime request handlers we may add during the tests.
- afterEach(() => server.resetHandlers())
-
- // Disable API mocking after the tests are done.
- afterAll(() => server.close())
+ // Enable sane handlers for network requests
+ msw.setupDefaultHandlers(server);
// setup mock response
beforeEach(() => {
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
index 79adaefaa8..e08f1650d5 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
@@ -4,7 +4,6 @@ import {
InfoCard,
Header,
Page,
- pageTheme,
Content,
ContentHeader,
HeaderLabel,
@@ -13,7 +12,7 @@ import {
import ExampleFetchComponent from '../ExampleFetchComponent';
const ExampleComponent: FC<{}> = () => (
-
+
@@ -37,5 +36,5 @@ const ExampleComponent: FC<{}> = () => (
);
-
+
export default ExampleComponent;
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
index ca1990b4bc..81e1b4be09 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs
@@ -3,18 +3,13 @@ import { render } from '@testing-library/react';
import ExampleFetchComponent from './ExampleFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
+import { msw } from '@backstage/test-utils';
describe('ExampleFetchComponent', () => {
const server = setupServer();
- // Enable API mocking before tests.
- beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
-
- // Reset any runtime request handlers we may add during the tests.
- afterEach(() => server.resetHandlers())
-
- // Disable API mocking after the tests are done.
- afterAll(() => server.close())
-
+ // Enable sane handlers for network requests
+ msw.setupDefaultHandlers(server);
+
// setup mock response
beforeEach(() => {
server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({}))))
diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts
index cc559f672e..292b0cc471 100644
--- a/packages/cli/templates/default-plugin/src/setupTests.ts
+++ b/packages/cli/templates/default-plugin/src/setupTests.ts
@@ -1,2 +1,2 @@
import '@testing-library/jest-dom';
-global.fetch = require('node-fetch');
+import 'cross-fetch/polyfill'
diff --git a/packages/cli/templates/default-plugin/tsconfig.json b/packages/cli/templates/default-plugin/tsconfig.json
new file mode 100644
index 0000000000..b61e496175
--- /dev/null
+++ b/packages/cli/templates/default-plugin/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "@backstage/cli/config/tsconfig.json",
+ "include": [
+ "src",
+ "dev"
+ ],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "outDir": "dist-types",
+ "rootDir": "."
+ }
+}
diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md
new file mode 100644
index 0000000000..729dc81206
--- /dev/null
+++ b/packages/config-loader/CHANGELOG.md
@@ -0,0 +1,47 @@
+# @backstage/config-loader
+
+## 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;
+ };
+ }
+ ```
+
+## 0.2.0
+
+### Minor Changes
+
+- 8c2b76e45: **BREAKING CHANGE**
+
+ The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed.
+ Instead, the CLI and backend process now accept one or more `--config` flags to load config files.
+
+ Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded.
+ If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one.
+
+ The old behaviour of for example `APP_ENV=development` can be replicated using the following flags:
+
+ ```bash
+ --config ../../app-config.yaml --config ../../app-config.development.yaml
+ ```
+
+- ce5512bc0: Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc.
diff --git a/packages/config-loader/README.md b/packages/config-loader/README.md
index 595241e0d5..261394a6a5 100644
--- a/packages/config-loader/README.md
+++ b/packages/config-loader/README.md
@@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 6a17eba76c..1584a61df5 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
- "version": "0.1.1-alpha.24",
+ "version": "0.3.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -12,7 +12,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/config-loader"
},
"keywords": [
@@ -30,16 +30,23 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/config": "^0.1.1-alpha.24",
+ "@backstage/cli-common": "^0.1.1",
+ "@backstage/config": "^0.1.1",
+ "ajv": "^6.12.5",
"fs-extra": "^9.0.0",
+ "json-schema": "^0.2.5",
+ "json-schema-merge-allof": "^0.7.0",
+ "typescript-json-schema": "^0.43.0",
"yaml": "^1.9.2",
- "yup": "^0.29.1"
+ "yup": "^0.29.3"
},
"devDependencies": {
"@types/jest": "^26.0.7",
+ "@types/json-schema": "^7.0.6",
+ "@types/json-schema-merge-allof": "^0.6.0",
"@types/mock-fs": "^4.10.0",
"@types/node": "^12.0.0",
- "@types/yup": "^0.28.2",
+ "@types/yup": "^0.29.8",
"mock-fs": "^4.13.0"
},
"files": [
diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts
index 02db134fe5..9ad54c5f18 100644
--- a/packages/config-loader/src/index.ts
+++ b/packages/config-loader/src/index.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
-export { readEnvConfig } from './lib';
+export { readEnvConfig, loadConfigSchema } from './lib';
+export type { ConfigSchema, ConfigVisibility } from './lib';
export { loadConfig } from './loader';
export type { LoadConfigOptions } from './loader';
diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts
index 40252b9cec..ceb7c34222 100644
--- a/packages/config-loader/src/lib/index.ts
+++ b/packages/config-loader/src/lib/index.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-export { resolveStaticConfig } from './resolver';
export { readConfigFile } from './reader';
export { readEnvConfig } from './env';
export { readSecret } from './secrets';
+export * from './schema';
diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts
index 6ccceef60d..a0a8495714 100644
--- a/packages/config-loader/src/lib/reader.test.ts
+++ b/packages/config-loader/src/lib/reader.test.ts
@@ -28,7 +28,6 @@ function memoryFiles(files: { [path: string]: string }) {
const mockContext: ReaderContext = {
env: {},
- skip: () => false,
readFile: jest.fn(),
readSecret: jest.fn(),
};
@@ -179,22 +178,4 @@ describe('readConfigFile', () => {
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
});
-
- it('should omit skipped values', async () => {
- const readFile = memoryFiles({
- './app-config.yaml': 'app: { title: skip, name: include }',
- });
-
- const config = readConfigFile('./app-config.yaml', {
- ...mockContext,
- readFile,
- skip: (path: string) => path === '.app.title',
- readSecret: jest.fn() as ReadSecretFunc,
- });
-
- await expect(config).resolves.toEqual({
- context: 'app-config.yaml',
- data: { app: { name: 'include' } },
- });
- });
});
diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts
index 8eadae0fe0..9eba58be97 100644
--- a/packages/config-loader/src/lib/reader.ts
+++ b/packages/config-loader/src/lib/reader.ts
@@ -37,10 +37,6 @@ export async function readConfigFile(
obj: JsonValue,
path: string,
): Promise {
- if (ctx.skip(path)) {
- return undefined;
- }
-
if (typeof obj !== 'object') {
return obj;
} else if (obj === null) {
diff --git a/packages/config-loader/src/lib/resolver.test.ts b/packages/config-loader/src/lib/resolver.test.ts
deleted file mode 100644
index 9fd71f2486..0000000000
--- a/packages/config-loader/src/lib/resolver.test.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import mockFs from 'mock-fs';
-import { resolveStaticConfig } from './resolver';
-
-function normalizePaths(paths: string[]) {
- return paths.map(p =>
- p
- .replace(/^[a-z]:/i, '')
- .split('\\')
- .join('/'),
- );
-}
-
-describe('resolveStaticConfig', () => {
- afterEach(() => {
- mockFs.restore();
- });
-
- it('should resolve no files for empty roots', async () => {
- mockFs({});
- const resolved = await resolveStaticConfig({
- env: 'development',
- rootPaths: [],
- });
-
- expect(normalizePaths(resolved)).toEqual([]);
- });
-
- it('should resolve a single app-config', async () => {
- mockFs({ '/repo/app-config.yaml': '' });
- const resolved = await resolveStaticConfig({
- env: 'development',
- rootPaths: ['/repo'],
- });
-
- expect(normalizePaths(resolved)).toEqual(['/repo/app-config.yaml']);
- });
-
- it('should resolve a app-configs in different directories', async () => {
- mockFs({
- '/repo/app-config.yaml': '',
- '/repo/packages/a/app-config.yaml': '',
- });
- const resolved = await resolveStaticConfig({
- env: 'development',
- rootPaths: [
- '/repo',
- '/other-repo',
- '/repo/packages/a',
- '/repo/packages/b',
- ],
- });
-
- expect(normalizePaths(resolved)).toEqual([
- '/repo/app-config.yaml',
- '/repo/packages/a/app-config.yaml',
- ]);
- });
-
- it('should resolve env and local configs', async () => {
- mockFs({
- '/repo/app-config.yaml': '',
- '/repo/app-config.local.yaml': '',
- '/repo/app-config.production.yaml': '',
- '/repo/app-config.production.local.yaml': '',
- '/repo/app-config.development.local.yaml': '',
- '/repo/packages/a/app-config.development.yaml': '',
- '/repo/packages/a/app-config.local.yaml': '',
- });
- const resolved = await resolveStaticConfig({
- env: 'development',
- rootPaths: ['/repo', '/repo/packages/a'],
- });
-
- expect(normalizePaths(resolved)).toEqual([
- '/repo/app-config.yaml',
- '/repo/app-config.local.yaml',
- '/repo/app-config.development.local.yaml',
- '/repo/packages/a/app-config.local.yaml',
- '/repo/packages/a/app-config.development.yaml',
- ]);
- });
-
- it('resolves suffixed configs in the correct order', async () => {
- mockFs({
- '/repo/app-config.yaml': '',
- '/repo/app-config.local.yaml': '',
- '/repo/app-config.production.yaml': '',
- '/repo/app-config.production.local.yaml': '',
- });
-
- const resolved = await resolveStaticConfig({
- env: 'production',
- rootPaths: ['/repo'],
- });
-
- expect(normalizePaths(resolved)).toEqual([
- '/repo/app-config.yaml',
- '/repo/app-config.local.yaml',
- '/repo/app-config.production.yaml',
- '/repo/app-config.production.local.yaml',
- ]);
- });
-});
diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts
deleted file mode 100644
index 44f0f6ea9e..0000000000
--- a/packages/config-loader/src/lib/resolver.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { resolve as resolvePath } from 'path';
-import { pathExists } from 'fs-extra';
-
-type ResolveOptions = {
- // Root paths to search for config files. Config from earlier paths has lower priority.
- rootPaths: string[];
- // The environment that we're loading config for, e.g. 'development', 'production'.
- env: string;
-};
-
-/**
- * Resolves all configuration files that should be loaded in the given environment.
- *
- * For each root directory, search for the default app-config.yaml, along with suffixed
- * NODE_ENV and local variants, e.g. app-config.production.yaml or app-config.development.local.yaml
- *
- * The priority order of config loaded through suffixes is `env > local > none`, meaning that
- * for example app-config.development.yaml has higher priority than `app-config.local.yaml`.
- *
- */
-export async function resolveStaticConfig(
- options: ResolveOptions,
-): Promise {
- const filePaths = [
- `app-config.yaml`,
- `app-config.local.yaml`,
- `app-config.${options.env}.yaml`,
- `app-config.${options.env}.local.yaml`,
- ];
-
- const resolvedPaths = [];
-
- for (const rootPath of options.rootPaths) {
- for (const filePath of filePaths) {
- const path = resolvePath(rootPath, filePath);
- if (await pathExists(path)) {
- resolvedPaths.push(path);
- }
- }
- }
-
- return resolvedPaths;
-}
diff --git a/packages/config-loader/src/lib/schema/collect.test.ts b/packages/config-loader/src/lib/schema/collect.test.ts
new file mode 100644
index 0000000000..7aca4f7c0c
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/collect.test.ts
@@ -0,0 +1,229 @@
+/*
+ * 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 { collectConfigSchemas } from './collect';
+
+const mockSchema = {
+ type: 'object',
+ properties: {
+ key: {
+ type: 'string',
+ visibility: 'frontend',
+ },
+ },
+};
+
+describe('collectConfigSchemas', () => {
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should not find any schemas without packages', async () => {
+ mockFs({
+ 'lerna.json': JSON.stringify({
+ packages: ['packages/*'],
+ }),
+ });
+
+ await expect(collectConfigSchemas([])).resolves.toEqual([]);
+ });
+
+ it('should find schema in a local package', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ configSchema: mockSchema,
+ }),
+ },
+ },
+ });
+
+ await expect(collectConfigSchemas(['a'])).resolves.toEqual([
+ {
+ path: 'node_modules/a/package.json',
+ value: mockSchema,
+ },
+ ]);
+ });
+
+ it('should find schema in transitive dependencies', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ dependencies: { b: '0.0.0', '@backstage/mock': '0.0.0' },
+ }),
+ },
+ b: {
+ 'package.json': JSON.stringify({
+ name: 'b',
+ dependencies: {
+ c1: '0.0.0',
+ c2: '0.0.0',
+ '@backstage/mock': '0.0.0',
+ },
+ configSchema: { ...mockSchema, title: 'b' },
+ }),
+ },
+ c1: {
+ 'package.json': JSON.stringify({
+ name: 'c1',
+ dependencies: { d1: '0.0.0' },
+ configSchema: { ...mockSchema, title: 'c1' },
+ }),
+ },
+ c2: {
+ 'package.json': JSON.stringify({
+ name: 'c2',
+ dependencies: { d2: '0.0.0' },
+ }),
+ },
+ d1: {
+ 'package.json': JSON.stringify({
+ name: 'd1',
+ dependencies: {},
+ configSchema: { ...mockSchema, title: 'd1' },
+ }),
+ },
+ d2: {
+ 'package.json': JSON.stringify({
+ name: 'd2',
+ dependencies: {},
+ configSchema: { ...mockSchema, title: 'd2' },
+ }),
+ },
+ },
+ });
+
+ await expect(collectConfigSchemas(['a'])).resolves.toEqual([
+ {
+ path: 'node_modules/b/package.json',
+ value: { ...mockSchema, title: 'b' },
+ },
+ {
+ path: 'node_modules/c1/package.json',
+ value: { ...mockSchema, title: 'c1' },
+ },
+ {
+ path: 'node_modules/d1/package.json',
+ value: { ...mockSchema, title: 'd1' },
+ },
+ ]);
+ });
+
+ it('should schema of different types', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ configSchema: { ...mockSchema, title: 'inline' },
+ }),
+ },
+ b: {
+ 'package.json': JSON.stringify({
+ name: 'b',
+ configSchema: 'schema.json',
+ }),
+ 'schema.json': JSON.stringify({ ...mockSchema, title: 'external' }),
+ },
+ c: {
+ 'package.json': JSON.stringify({
+ name: 'c',
+ configSchema: 'schema.d.ts',
+ }),
+ 'schema.d.ts': `export interface Config {
+ /** @visibility secret */
+ tsKey: string
+ }`,
+ },
+ },
+ // TypeScript compilation needs to load some real files inside the typescript dir
+ '../../node_modules/typescript': (mockFs as any).load(
+ '../../node_modules/typescript',
+ ),
+ });
+
+ await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([
+ {
+ path: 'node_modules/a/package.json',
+ value: { ...mockSchema, title: 'inline' },
+ },
+ {
+ path: 'node_modules/b/schema.json',
+ value: { ...mockSchema, title: 'external' },
+ },
+ {
+ path: 'node_modules/c/schema.d.ts',
+ value: {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ type: 'object',
+ properties: {
+ tsKey: {
+ type: 'string',
+ visibility: 'secret',
+ },
+ },
+ required: ['tsKey'],
+ },
+ },
+ ]);
+ });
+
+ it('should not allow unknown schema file types', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ configSchema: 'schema.yaml',
+ }),
+ 'schema.yaml': mockSchema,
+ },
+ },
+ });
+
+ await expect(collectConfigSchemas(['a'])).rejects.toThrow(
+ 'Config schema files must be .json or .d.ts, got schema.yaml',
+ );
+ });
+
+ it('should reject typescript config declaration without a Config type', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ configSchema: 'schema.d.ts',
+ }),
+ 'schema.d.ts': `export interface NotConfig {}`,
+ },
+ },
+ // TypeScript compilation needs to load some real files inside the typescript dir
+ '../../node_modules/typescript': (mockFs as any).load(
+ '../../node_modules/typescript',
+ ),
+ });
+
+ await expect(collectConfigSchemas(['a'])).rejects.toThrow(
+ 'Invalid schema in node_modules/a/schema.d.ts, missing Config export',
+ );
+ });
+});
diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts
new file mode 100644
index 0000000000..0e53562875
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/collect.ts
@@ -0,0 +1,180 @@
+/*
+ * 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 {
+ resolve as resolvePath,
+ relative as relativePath,
+ dirname,
+ sep,
+} from 'path';
+import { ConfigSchemaPackageEntry } from './types';
+import { getProgramFromFiles, generateSchema } from 'typescript-json-schema';
+import { JsonObject } from '@backstage/config';
+
+type Item = {
+ name: string;
+ parentPath?: string;
+};
+
+const req =
+ typeof __non_webpack_require__ === 'undefined'
+ ? require
+ : __non_webpack_require__;
+
+/**
+ * This collects all known config schemas across all dependencies of the app.
+ */
+export async function collectConfigSchemas(
+ packageNames: string[],
+): Promise {
+ const visitedPackages = new Set();
+ const schemas = Array();
+ const tsSchemaPaths = Array();
+ const currentDir = await fs.realpath(process.cwd());
+
+ async function processItem({ name, parentPath }: Item) {
+ // Ensures that we only process each package once. We don't bother with
+ // loading in schemas from duplicates of different versions, as that's not
+ // supported by Backstage right now anyway. We may want to change that in
+ // the future though, if it for example becomes possible to load in two
+ // different versions of e.g. @backstage/core at once.
+ if (visitedPackages.has(name)) {
+ return;
+ }
+ visitedPackages.add(name);
+
+ let pkgPath: string;
+ try {
+ pkgPath = req.resolve(
+ `${name}/package.json`,
+ parentPath && {
+ paths: [parentPath],
+ },
+ );
+ } catch {
+ // We can somewhat safely ignore packages that don't export package.json,
+ // as they are likely not part of the Backstage ecosystem anyway.
+ return;
+ }
+
+ const pkg = await fs.readJson(pkgPath);
+ const depNames = [
+ ...Object.keys(pkg.dependencies ?? {}),
+ ...Object.keys(pkg.peerDependencies ?? {}),
+ ];
+
+ // TODO(Rugvip): Trying this out to avoid having to traverse the full dependency graph,
+ // since that's pretty slow. We probably need a better way to determine when
+ // we've left the Backstage ecosystem, but this will do for now.
+ const hasSchema = 'configSchema' in pkg;
+ const hasBackstageDep = depNames.some(_ => _.startsWith('@backstage/'));
+ if (!hasSchema && !hasBackstageDep) {
+ return;
+ }
+ if (hasSchema) {
+ if (typeof pkg.configSchema === 'string') {
+ const isJson = pkg.configSchema.endsWith('.json');
+ const isDts = pkg.configSchema.endsWith('.d.ts');
+ if (!isJson && !isDts) {
+ throw new Error(
+ `Config schema files must be .json or .d.ts, got ${pkg.configSchema}`,
+ );
+ }
+ if (isDts) {
+ tsSchemaPaths.push(
+ relativePath(
+ currentDir,
+ resolvePath(dirname(pkgPath), pkg.configSchema),
+ ),
+ );
+ } else {
+ const path = resolvePath(dirname(pkgPath), pkg.configSchema);
+ const value = await fs.readJson(path);
+ schemas.push({
+ value,
+ path: relativePath(currentDir, path),
+ });
+ }
+ } else {
+ schemas.push({
+ value: pkg.configSchema,
+ path: relativePath(currentDir, pkgPath),
+ });
+ }
+ }
+
+ await Promise.all(
+ depNames.map(name => processItem({ name, parentPath: pkgPath })),
+ );
+ }
+
+ await Promise.all(packageNames.map(name => processItem({ name })));
+
+ const tsSchemas = compileTsSchemas(tsSchemaPaths);
+
+ return schemas.concat(tsSchemas);
+}
+
+// This handles the support of TypeScript .d.ts config schema declarations.
+// We collect all typescript schema definition and compile them all in one go.
+// This is much faster than compiling them separately.
+function compileTsSchemas(paths: string[]) {
+ if (paths.length === 0) {
+ return [];
+ }
+
+ const program = getProgramFromFiles(paths, {
+ incremental: false,
+ isolatedModules: true,
+ lib: ['ES5'], // Skipping most libs speeds processing up a lot, we just need the primitive types anyway
+ noEmit: true,
+ noResolve: true,
+ skipLibCheck: true, // Skipping lib checks speeds things up
+ skipDefaultLibCheck: true,
+ strict: true,
+ typeRoots: [], // Do not include any additional types
+ types: [],
+ });
+
+ const tsSchemas = paths.map(path => {
+ let value;
+ try {
+ value = generateSchema(
+ program,
+ // All schemas should export a `Config` symbol
+ 'Config',
+ // This enables usage of @visibility is doc comments
+ {
+ required: true,
+ validationKeywords: ['visibility'],
+ },
+ [path.split(sep).join('/')], // Unix paths are expected for all OSes here
+ ) as JsonObject | null;
+ } catch (error) {
+ if (error.message !== 'type Config not found') {
+ throw error;
+ }
+ }
+
+ if (!value) {
+ throw new Error(`Invalid schema in ${path}, missing Config export`);
+ }
+ return { path, value };
+ });
+
+ return tsSchemas;
+}
diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts
new file mode 100644
index 0000000000..91e7aa687e
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/compile.test.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 { compileConfigSchemas } from './compile';
+
+describe('compileConfigSchemas', () => {
+ it('should merge schemas', () => {
+ const validate = compileConfigSchemas([
+ {
+ path: 'a',
+ value: { type: 'object', properties: { a: { type: 'string' } } },
+ },
+ {
+ path: 'b',
+ value: { type: 'object', properties: { b: { type: 'number' } } },
+ },
+ ]);
+ expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({
+ errors: ['Config should be string { type=string } at .a'],
+ visibilityByPath: new Map(),
+ });
+ expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
+ errors: ['Config should be number { type=number } at .b'],
+ visibilityByPath: new Map(),
+ });
+ });
+
+ it('should discover visibilities', () => {
+ const validate = compileConfigSchemas([
+ {
+ path: 'a1',
+ value: {
+ type: 'object',
+ properties: {
+ a: { type: 'string', visibility: 'frontend' },
+ b: { type: 'string', visibility: 'backend' },
+ c: { type: 'string' },
+ d: {
+ type: 'array',
+ visibility: 'secret',
+ items: { type: 'string', visibility: 'frontend' },
+ },
+ },
+ },
+ },
+ {
+ path: 'a2',
+ value: {
+ type: 'object',
+ properties: {
+ a: { type: 'string' },
+ b: { type: 'string', visibility: 'secret' },
+ c: { type: 'string', visibility: 'backend' },
+ d: {
+ type: 'array',
+ visibility: 'secret',
+ items: { type: 'string' },
+ },
+ },
+ },
+ },
+ ]);
+ expect(
+ validate([
+ { data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' },
+ ]),
+ ).toEqual({
+ visibilityByPath: new Map(
+ Object.entries({
+ '.a': 'frontend',
+ '.b': 'secret',
+ '.d': 'secret',
+ '.d.0': 'frontend',
+ }),
+ ),
+ });
+ });
+
+ it('should reject visiblity conflicts', () => {
+ expect(() =>
+ compileConfigSchemas([
+ {
+ path: 'a1',
+ value: {
+ type: 'object',
+ properties: { a: { type: 'string', visibility: 'frontend' } },
+ },
+ },
+ {
+ path: 'a2',
+ value: {
+ type: 'object',
+ properties: { a: { type: 'string', visibility: 'secret' } },
+ },
+ },
+ ]),
+ ).toThrow(
+ "Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility",
+ );
+ });
+});
diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts
new file mode 100644
index 0000000000..f01a4f640a
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/compile.ts
@@ -0,0 +1,126 @@
+/*
+ * 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 Ajv from 'ajv';
+import { JSONSchema7 as JSONSchema } from 'json-schema';
+import mergeAllOf, { Resolvers } from 'json-schema-merge-allof';
+import { ConfigReader } from '@backstage/config';
+import {
+ ConfigSchemaPackageEntry,
+ ValidationFunc,
+ CONFIG_VISIBILITIES,
+ ConfigVisibility,
+} from './types';
+
+/**
+ * This takes a collection of Backstage configuration schemas from various
+ * sources and compiles them down into a single schema validation function.
+ *
+ * It also handles the implementation of the custom "visibility" keyword used
+ * to specify the scope of different config paths.
+ */
+export function compileConfigSchemas(
+ schemas: ConfigSchemaPackageEntry[],
+): ValidationFunc {
+ // The ajv instance below is stateful and doesn't really allow for additional
+ // output during validation. We work around this by having this extra piece
+ // of state that we reset before each validation.
+ const visibilityByPath = new Map();
+
+ const ajv = new Ajv({
+ allErrors: true,
+ schemas: {
+ 'https://backstage.io/schema/config-v1': true,
+ },
+ }).addKeyword('visibility', {
+ metaSchema: {
+ type: 'string',
+ enum: CONFIG_VISIBILITIES,
+ },
+ compile(visibility: ConfigVisibility) {
+ return (_data, dataPath) => {
+ if (!dataPath) {
+ return false;
+ }
+ if (visibility && visibility !== 'backend') {
+ const normalizedPath = dataPath.replace(
+ /\['?(.*?)'?\]/g,
+ (_, segment) => `.${segment}`,
+ );
+ visibilityByPath.set(normalizedPath, visibility);
+ }
+ return true;
+ };
+ },
+ });
+
+ const merged = mergeAllOf(
+ { allOf: schemas.map(_ => _.value) },
+ {
+ // JSONSchema is typically subtractive, as in it always reduces the set of allowed
+ // inputs through constraints. This changes the object property merging to be additive
+ // rather than subtractive.
+ ignoreAdditionalProperties: true,
+ resolvers: {
+ // This ensures that the visibilities across different schemas are sound, and
+ // selects the most specific visibility for each path.
+ visibility(values: string[], path: string[]) {
+ const hasFrontend = values.some(_ => _ === 'frontend');
+ const hasSecret = values.some(_ => _ === 'secret');
+ if (hasFrontend && hasSecret) {
+ throw new Error(
+ `Config schema visibility is both 'frontend' and 'secret' for ${path.join(
+ '/',
+ )}`,
+ );
+ } else if (hasFrontend) {
+ return 'frontend';
+ } else if (hasSecret) {
+ return 'secret';
+ }
+
+ return 'backend';
+ },
+ } as Partial>,
+ },
+ );
+
+ const validate = ajv.compile(merged);
+
+ return configs => {
+ const config = ConfigReader.fromConfigs(configs).get();
+
+ visibilityByPath.clear();
+
+ const valid = validate(config);
+ if (!valid) {
+ const errors = validate.errors ?? [];
+ return {
+ errors: errors.map(({ dataPath, message, params }) => {
+ const paramStr = Object.entries(params)
+ .map(([name, value]) => `${name}=${value}`)
+ .join(' ');
+ return `Config ${message || ''} { ${paramStr} } at ${dataPath}`;
+ }),
+ visibilityByPath: new Map(),
+ };
+ }
+
+ return {
+ visibilityByPath: new Map(visibilityByPath),
+ };
+ };
+}
diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts
new file mode 100644
index 0000000000..bfad2ca95a
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/filtering.test.ts
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { JsonObject } from '@backstage/config';
+import { ConfigVisibility } from './types';
+import { filterByVisibility } from './filtering';
+
+const data = {
+ arr: ['f', 'b', 's'],
+ objArr: [
+ { f: 1, b: 2, s: 3 },
+ { f: 4, b: 5, s: 6 },
+ ],
+ obj: {
+ f: 'a',
+ b: {
+ s: true,
+ },
+ },
+ arrF: [{ never: 'here' }],
+ arrB: [{ never: 'here' }],
+ arrS: [{ never: 'here' }],
+ objF: { never: 'here' },
+ objB: { never: 'here' },
+ objS: { never: 'here' },
+};
+
+const visiblity = new Map(
+ Object.entries({
+ '.arr.0': 'frontend',
+ '.arr.1': 'backend',
+ '.arr.2': 'secret',
+ '.obj.f': 'frontend',
+ '.obj.b': 'backend',
+ '.obj.b.s': 'secret',
+ '.objArr.0.f': 'frontend',
+ '.objArr.0.b': 'backend',
+ '.objArr.0.s': 'secret',
+ '.objArr.1.f': 'frontend',
+ '.objArr.1.b': 'backend',
+ '.objArr.1.s': 'secret',
+ '.arrF': 'frontend',
+ '.arrB': 'backend',
+ '.arrS': 'secret',
+ '.objF': 'frontend',
+ '.objB': 'backend',
+ '.objS': 'secret',
+ }),
+);
+
+describe('filterByVisibility', () => {
+ test.each<[ConfigVisibility[], JsonObject]>([
+ [[], {}],
+ [
+ ['frontend'],
+ {
+ arr: ['f'],
+ objArr: [{ f: 1 }, { f: 4 }],
+ obj: { f: 'a' },
+ arrF: [],
+ objF: {},
+ },
+ ],
+ [
+ ['backend'],
+ {
+ arr: ['b'],
+ objArr: [{ b: 2 }, { b: 5 }],
+ obj: { b: {} },
+ arrF: [{ never: 'here' }],
+ arrB: [{ never: 'here' }],
+ arrS: [{ never: 'here' }],
+ objF: { never: 'here' },
+ objB: { never: 'here' },
+ objS: { never: 'here' },
+ },
+ ],
+ [
+ ['secret'],
+ {
+ arr: ['s'],
+ objArr: [{ s: 3 }, { s: 6 }],
+ obj: { b: { s: true } },
+ arrS: [],
+ objS: {},
+ },
+ ],
+ [['frontend', 'backend', 'secret'], data],
+ ])('should filter correctly with %p', (filter, expected) => {
+ expect(filterByVisibility(data, filter, visiblity)).toEqual(expected);
+ });
+});
diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts
new file mode 100644
index 0000000000..10a97f9a7f
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/filtering.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { JsonObject, JsonValue } from '@backstage/config';
+import {
+ ConfigVisibility,
+ DEFAULT_CONFIG_VISIBILITY,
+ TransformFunc,
+} from './types';
+
+/**
+ * This filters data by visibility by discovering the visibility of each
+ * value, and then only keeping the ones that are specified in `includeVisibilities`.
+ */
+export function filterByVisibility(
+ data: JsonObject,
+ includeVisibilities: ConfigVisibility[],
+ visibilityByPath: Map,
+ transformFunc?: TransformFunc,
+): JsonObject {
+ function transform(jsonVal: JsonValue, path: string): JsonValue | undefined {
+ const visibility = visibilityByPath.get(path) ?? DEFAULT_CONFIG_VISIBILITY;
+ const isVisible = includeVisibilities.includes(visibility);
+
+ if (typeof jsonVal !== 'object') {
+ if (isVisible) {
+ if (transformFunc) {
+ return transformFunc(jsonVal, { visibility });
+ }
+ return jsonVal;
+ }
+ return undefined;
+ } else if (jsonVal === null) {
+ return undefined;
+ } else if (Array.isArray(jsonVal)) {
+ const arr = new Array();
+
+ for (const [index, value] of jsonVal.entries()) {
+ const out = transform(value, `${path}.${index}`);
+ if (out !== undefined) {
+ arr.push(out);
+ }
+ }
+
+ if (arr.length > 0 || isVisible) {
+ return arr;
+ }
+ return undefined;
+ }
+
+ const outObj: JsonObject = {};
+ let hasOutput = false;
+
+ for (const [key, value] of Object.entries(jsonVal)) {
+ if (value === undefined) {
+ continue;
+ }
+ const out = transform(value, `${path}.${key}`);
+ if (out !== undefined) {
+ outObj[key] = out;
+ hasOutput = true;
+ }
+ }
+
+ if (hasOutput || isVisible) {
+ return outObj;
+ }
+ return undefined;
+ }
+
+ return (transform(data, '') as JsonObject) ?? {};
+}
diff --git a/packages/config-loader/src/lib/schema/index.ts b/packages/config-loader/src/lib/schema/index.ts
new file mode 100644
index 0000000000..8cefb93b3c
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { loadConfigSchema } from './load';
+export type { ConfigSchema, ConfigVisibility } from './types';
diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts
new file mode 100644
index 0000000000..a13f36be20
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/load.test.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { loadConfigSchema } from './load';
+
+describe('loadConfigSchema', () => {
+ afterEach(() => {
+ mockFs.restore();
+ });
+
+ it('should load schema from packages or data', async () => {
+ mockFs({
+ node_modules: {
+ a: {
+ 'package.json': JSON.stringify({
+ name: 'a',
+ configSchema: {
+ type: 'object',
+ properties: {
+ key1: { type: 'string', visibility: 'frontend' },
+ },
+ },
+ }),
+ },
+ b: {
+ 'package.json': JSON.stringify({
+ name: 'b',
+ configSchema: 'schema.json',
+ }),
+ 'schema.json': JSON.stringify({
+ name: 'a',
+ configSchema: {
+ type: 'object',
+ properties: {
+ key2: { type: 'number' },
+ },
+ },
+ }),
+ },
+ },
+ });
+
+ const schema = await loadConfigSchema({
+ dependencies: ['a'],
+ });
+
+ const configs = [{ data: { key1: 'a', key2: 2 }, context: 'test' }];
+
+ expect(schema.process(configs)).toEqual(configs);
+ expect(schema.process(configs, { visiblity: ['frontend'] })).toEqual([
+ { data: { key1: 'a' }, context: 'test' },
+ ]);
+ expect(
+ schema.process(configs, {
+ visiblity: ['frontend'],
+ valueTransform: () => 'X',
+ }),
+ ).toEqual([{ data: { key1: 'X' }, context: 'test' }]);
+ expect(
+ schema.process(configs, {
+ valueTransform: () => 'X',
+ }),
+ ).toEqual([{ data: { key1: 'X', key2: 'X' }, context: 'test' }]);
+
+ const serialized = schema.serialize();
+
+ const schema2 = await loadConfigSchema({ serialized });
+ expect(schema2.process(configs, { visiblity: ['frontend'] })).toEqual([
+ { data: { key1: 'a' }, context: 'test' },
+ ]);
+ expect(() =>
+ schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
+ ).toThrow(
+ 'Config validation failed, Config should be string { type=string } at .key1',
+ );
+
+ await expect(
+ loadConfigSchema({
+ serialized: { ...serialized, backstageConfigSchemaVersion: 2 },
+ }),
+ ).rejects.toThrow(
+ 'Serialized configuration schema is invalid or has an invalid version number',
+ );
+ });
+});
diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts
new file mode 100644
index 0000000000..01a9499983
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/load.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppConfig, JsonObject } from '@backstage/config';
+import { compileConfigSchemas } from './compile';
+import { collectConfigSchemas } from './collect';
+import { filterByVisibility } from './filtering';
+import {
+ ConfigSchema,
+ ConfigSchemaPackageEntry,
+ CONFIG_VISIBILITIES,
+} from './types';
+
+type Options =
+ | {
+ dependencies: string[];
+ }
+ | {
+ serialized: JsonObject;
+ };
+
+/**
+ * Loads config schema for a Backstage instance.
+ */
+export async function loadConfigSchema(
+ options: Options,
+): Promise {
+ let schemas: ConfigSchemaPackageEntry[];
+
+ if ('dependencies' in options) {
+ schemas = await collectConfigSchemas(options.dependencies);
+ } else {
+ const { serialized } = options;
+ if (serialized?.backstageConfigSchemaVersion !== 1) {
+ throw new Error(
+ 'Serialized configuration schema is invalid or has an invalid version number',
+ );
+ }
+ schemas = serialized.schemas as ConfigSchemaPackageEntry[];
+ }
+
+ const validate = compileConfigSchemas(schemas);
+
+ return {
+ process(
+ configs: AppConfig[],
+ { visiblity, valueTransform } = {},
+ ): AppConfig[] {
+ const result = validate(configs);
+ if (result.errors) {
+ const error = new Error(
+ `Config validation failed, ${result.errors.join('; ')}`,
+ );
+ (error as any).messages = result.errors;
+ throw error;
+ }
+
+ let processedConfigs = configs;
+
+ if (visiblity) {
+ processedConfigs = processedConfigs.map(({ data, context }) => ({
+ context,
+ data: filterByVisibility(
+ data,
+ visiblity,
+ result.visibilityByPath,
+ valueTransform,
+ ),
+ }));
+ } else if (valueTransform) {
+ processedConfigs = processedConfigs.map(({ data, context }) => ({
+ context,
+ data: filterByVisibility(
+ data,
+ Array.from(CONFIG_VISIBILITIES),
+ result.visibilityByPath,
+ valueTransform,
+ ),
+ }));
+ }
+
+ return processedConfigs;
+ },
+ serialize(): JsonObject {
+ return {
+ schemas,
+ backstageConfigSchemaVersion: 1,
+ };
+ },
+ };
+}
diff --git a/packages/config-loader/src/lib/schema/types.ts b/packages/config-loader/src/lib/schema/types.ts
new file mode 100644
index 0000000000..7705242b31
--- /dev/null
+++ b/packages/config-loader/src/lib/schema/types.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 { AppConfig, JsonObject } from '@backstage/config';
+
+/**
+ * An sub-set of configuration schema.
+ */
+export type ConfigSchemaPackageEntry = {
+ /**
+ * The configuration schema itself.
+ */
+ value: JsonObject;
+ /**
+ * The relative path that the configuration schema was discovered at.
+ */
+ path: string;
+};
+
+/**
+ * A list of all possible configuration value visibilities.
+ */
+export const CONFIG_VISIBILITIES = ['frontend', 'backend', 'secret'] as const;
+
+/**
+ * A type representing the possible configuration value visibilities
+ */
+export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number];
+
+/**
+ * The default configuration visibility if no other values is given.
+ */
+export const DEFAULT_CONFIG_VISIBILITY: ConfigVisibility = 'backend';
+
+/**
+ * An explanation of a configuration validation error.
+ */
+type ValidationError = string;
+
+/**
+ * The result of validating configuration data using a schema.
+ */
+type ValidationResult = {
+ /**
+ * Errors that where emitted during validation, if any.
+ */
+ errors?: ValidationError[];
+ /**
+ * The configuration visibilities that were discovered during validation.
+ *
+ * The path in the key uses the form `////`
+ */
+ visibilityByPath: Map;
+};
+
+/**
+ * A function used validate configuration data.
+ */
+export type ValidationFunc = (configs: AppConfig[]) => ValidationResult;
+
+/**
+ * A function used to transform primitive configuration values.
+ */
+export type TransformFunc = (
+ value: T,
+ context: { visibility: ConfigVisibility },
+) => T | undefined;
+
+/**
+ * Options used to process configuration data with a schema.
+ */
+type ConfigProcessingOptions = {
+ /**
+ * The visibilities that should be included in the output data.
+ * If omitted, the data will not be filtered by visibility.
+ */
+ visiblity?: ConfigVisibility[];
+
+ /**
+ * A transform function that can be used to transform primitive configuration values
+ * during validation. The value returned from the transform function will be used
+ * instead of the original value. If the transform returns `undefined`, the value
+ * will be omitted.
+ */
+ valueTransform?: TransformFunc;
+};
+
+/**
+ * A loaded configuration schema that is ready to process configuration data.
+ */
+export type ConfigSchema = {
+ process(
+ appConfigs: AppConfig[],
+ options?: ConfigProcessingOptions,
+ ): AppConfig[];
+
+ serialize(): JsonObject;
+};
diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts
index cfc4150d68..d80ada193b 100644
--- a/packages/config-loader/src/lib/secrets.test.ts
+++ b/packages/config-loader/src/lib/secrets.test.ts
@@ -21,7 +21,6 @@ const ctx: ReaderContext = {
env: {
SECRET: 'my-secret',
},
- skip: () => false,
readSecret: jest.fn(),
async readFile(path) {
const content = ({
diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts
index 4c3812d527..97f0de2940 100644
--- a/packages/config-loader/src/lib/secrets.ts
+++ b/packages/config-loader/src/lib/secrets.ts
@@ -58,7 +58,7 @@ const secretLoaderSchemas = {
};
// The top-level secret schema, which figures out what type of secret it is.
-const secretSchema = yup.lazy(value => {
+const secretSchema = yup.lazy(value => {
if (typeof value !== 'object' || value === null) {
return yup.object().required().label('secret');
}
diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts
index 02b8a9d053..e189aef20d 100644
--- a/packages/config-loader/src/lib/types.ts
+++ b/packages/config-loader/src/lib/types.ts
@@ -28,7 +28,6 @@ export type SkipFunc = (path: string) => boolean;
*/
export type ReaderContext = {
env: { [name in string]?: string };
- skip: SkipFunc;
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts
index 087e2309c0..a9857a784f 100644
--- a/packages/config-loader/src/loader.test.ts
+++ b/packages/config-loader/src/loader.test.ts
@@ -38,31 +38,12 @@ describe('loadConfig', () => {
mockFs.restore();
});
- it('loads config without secrets', async () => {
+ it('load config from default path', async () => {
await expect(
loadConfig({
- rootPaths: ['/root'],
+ configRoot: '/root',
+ configPaths: [],
env: 'production',
- shouldReadSecrets: false,
- }),
- ).resolves.toEqual([
- {
- context: 'app-config.yaml',
- data: {
- app: {
- title: 'Example App',
- },
- },
- },
- ]);
- });
-
- it('loads config with secrets', async () => {
- await expect(
- loadConfig({
- rootPaths: ['/root'],
- env: 'production',
- shouldReadSecrets: true,
}),
).resolves.toEqual([
{
@@ -77,12 +58,12 @@ describe('loadConfig', () => {
]);
});
- it('loads development config without secrets', async () => {
+ it('loads config with secrets', async () => {
await expect(
loadConfig({
- rootPaths: ['/root'],
- env: 'development',
- shouldReadSecrets: false,
+ configRoot: '/root',
+ configPaths: ['/root/app-config.yaml'],
+ env: 'production',
}),
).resolves.toEqual([
{
@@ -90,24 +71,22 @@ describe('loadConfig', () => {
data: {
app: {
title: 'Example App',
+ sessionKey: 'abc123',
},
},
},
- {
- context: 'app-config.development.yaml',
- data: {
- app: {},
- },
- },
]);
});
it('loads development config with secrets', async () => {
await expect(
loadConfig({
- rootPaths: ['/root'],
+ configRoot: '/root',
+ configPaths: [
+ '/root/app-config.yaml',
+ '/root/app-config.development.yaml',
+ ],
env: 'development',
- shouldReadSecrets: true,
}),
).resolves.toEqual([
{
diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts
index cf5edd3ce3..a647367469 100644
--- a/packages/config-loader/src/loader.ts
+++ b/packages/config-loader/src/loader.ts
@@ -15,33 +15,26 @@
*/
import fs from 'fs-extra';
-import { resolve as resolvePath, dirname } from 'path';
+import { resolve as resolvePath, dirname, isAbsolute } from 'path';
import { AppConfig, JsonObject } from '@backstage/config';
-import {
- resolveStaticConfig,
- readConfigFile,
- readEnvConfig,
- readSecret,
-} from './lib';
+import { readConfigFile, readEnvConfig, readSecret } from './lib';
export type LoadConfigOptions = {
- // Root paths to search for config files. Config from earlier paths has lower priority.
- rootPaths: string[];
+ // The root directory of the config loading context. Used to find default configs.
+ configRoot: string;
- // The environment that we're loading config for, e.g. 'development', 'production'.
+ // Absolute paths to load config files from. Configs from earlier paths have lower priority.
+ configPaths: string[];
+
+ // TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes.
env: string;
-
- // Whether to read secrets or omit them, defaults to false.
- shouldReadSecrets?: boolean;
};
class Context {
constructor(
private readonly options: {
- secretPaths: Set;
env: { [name in string]?: string };
rootPath: string;
- shouldReadSecrets: boolean;
},
) {}
@@ -49,26 +42,14 @@ class Context {
return this.options.env;
}
- skip(path: string): boolean {
- if (this.options.shouldReadSecrets) {
- return false;
- }
- return this.options.secretPaths.has(path);
- }
-
async readFile(path: string): Promise {
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
}
async readSecret(
- path: string,
+ _path: string,
desc: JsonObject,
): Promise {
- this.options.secretPaths.add(path);
- if (!this.options.shouldReadSecrets) {
- return undefined;
- }
-
return readSecret(desc, this);
}
}
@@ -77,20 +58,40 @@ export async function loadConfig(
options: LoadConfigOptions,
): Promise {
const configs = [];
+ const { configRoot } = options;
+ const configPaths = options.configPaths.slice();
- const configPaths = await resolveStaticConfig(options);
+ // If no paths are provided, we default to reading
+ // `app-config.yaml` and, if it exists, `app-config.local.yaml`
+ if (configPaths.length === 0) {
+ configPaths.push(resolvePath(configRoot, 'app-config.yaml'));
+
+ const localConfig = resolvePath(configRoot, 'app-config.local.yaml');
+ if (await fs.pathExists(localConfig)) {
+ configPaths.push(localConfig);
+ }
+
+ const envFile = `app-config.${options.env}.yaml`;
+ if (await fs.pathExists(resolvePath(configRoot, envFile))) {
+ console.error(
+ `Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`,
+ );
+ console.error(
+ `To load the config file, use --config , listing every config file that you want to load`,
+ );
+ }
+ }
try {
- const secretPaths = new Set();
-
for (const configPath of configPaths) {
+ if (!isAbsolute(configPath)) {
+ throw new Error(`Config load path is not absolute: '${configPath}'`);
+ }
const config = await readConfigFile(
configPath,
new Context({
- secretPaths,
env: process.env,
rootPath: dirname(configPath),
- shouldReadSecrets: Boolean(options.shouldReadSecrets),
}),
);
diff --git a/packages/config/README.md b/packages/config/README.md
index f532c682be..9866b1bce9 100644
--- a/packages/config/README.md
+++ b/packages/config/README.md
@@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/config/package.json b/packages/config/package.json
index d3695ebf33..f1ae07ef64 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/config",
"description": "Config API used by Backstage core, backend, and CLI",
- "version": "0.1.1-alpha.24",
+ "version": "0.1.1",
"private": false,
"publishConfig": {
"access": "public",
@@ -12,7 +12,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/config"
},
"keywords": [
diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md
new file mode 100644
index 0000000000..b936715c70
--- /dev/null
+++ b/packages/core-api/CHANGELOG.md
@@ -0,0 +1,123 @@
+# @backstage/core-api
+
+## 0.2.1
+
+### Patch Changes
+
+- c5bab94ab: 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 };
+ };
+ ```
+
+- Updated dependencies [4577e377b]
+ - @backstage/theme@0.2.1
+
+## 0.2.0
+
+### Minor Changes
+
+- 819a70229: Add SAML login to backstage
+
+ 
+
+ 
+
+- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github 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'],
+ }),
+ ```
+
+- cbab5bbf8: Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save().
+
+### Patch Changes
+
+- cbbd271c4: Add initial RouteRefRegistry
+
+ Starting out some work to bring routing back and working as part of the work towards finalizing #1536
+
+ This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this:
+
+ ```jsx
+ const App = () => (
+
+
+
+ {' '}
+ // catalogRouteRef
+
+
+
+
+
+
+ // statusRouteRef
+
+
+
+
+
+
+
+
+
+ // sentryRouteRef
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ ```
+
+ As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router.
+
+ The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs.
+
+ The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point.
+
+ We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks.
+
+- 26e69ab1a: Remove cost insights example client from demo app and export from plugin
+ Create cost insights dev plugin using example client
+ Make PluginConfig and dependent types public
+- Updated dependencies [ae5983387]
+- Updated dependencies [0d4459c08]
+ - @backstage/theme@0.2.0
+ - @backstage/test-utils@0.1.2
diff --git a/packages/core-api/README.md b/packages/core-api/README.md
index 5732b5ef23..f7b8b6c337 100644
--- a/packages/core-api/README.md
+++ b/packages/core-api/README.md
@@ -18,5 +18,5 @@ $ yarn add @backstage/core
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
index c925471695..212d4c9046 100644
--- a/packages/core-api/package.json
+++ b/packages/core-api/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
- "version": "0.1.1-alpha.24",
+ "version": "0.2.1",
"private": false,
"publishConfig": {
"access": "public",
@@ -11,7 +11,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/core-api"
},
"keywords": [
@@ -29,8 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/config": "^0.1.1-alpha.24",
- "@backstage/theme": "^0.1.1-alpha.24",
+ "@backstage/config": "^0.1.1",
+ "@backstage/test-utils": "^0.1.3",
+ "@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
@@ -41,15 +42,16 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.24",
- "@backstage/test-utils-core": "^0.1.1-alpha.24",
+ "@backstage/cli": "^0.3.0",
+ "@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
- "jest-fetch-mock": "^3.0.3"
+ "cross-fetch": "^3.0.6",
+ "msw": "^0.21.3"
},
"files": [
"dist"
diff --git a/packages/core-api/src/apis/definitions/AlertApi.ts b/packages/core-api/src/apis/definitions/AlertApi.ts
index 123cd47651..91641ac35b 100644
--- a/packages/core-api/src/apis/definitions/AlertApi.ts
+++ b/packages/core-api/src/apis/definitions/AlertApi.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { createApiRef, ApiRef } from '../system';
import { Observable } from '../../types';
export type AlertMessage = {
@@ -38,7 +38,7 @@ export type AlertApi = {
alert$(): Observable;
};
-export const alertApiRef = createApiRef({
+export const alertApiRef: ApiRef = createApiRef({
id: 'core.alert',
description: 'Used to report alerts and forward them to the app',
});
diff --git a/packages/core-api/src/apis/definitions/AppThemeApi.ts b/packages/core-api/src/apis/definitions/AppThemeApi.ts
index de70adabd9..515e8df082 100644
--- a/packages/core-api/src/apis/definitions/AppThemeApi.ts
+++ b/packages/core-api/src/apis/definitions/AppThemeApi.ts
@@ -14,9 +14,10 @@
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
import { BackstageTheme } from '@backstage/theme';
import { Observable } from '../../types';
+import { SvgIconProps } from '@material-ui/core';
/**
* Describes a theme provided by the app.
@@ -41,6 +42,11 @@ export type AppTheme = {
* The specialized MaterialUI theme instance.
*/
theme: BackstageTheme;
+
+ /**
+ * An Icon for the theme mode setting.
+ */
+ icon?: React.ReactElement;
};
/**
@@ -71,7 +77,7 @@ export type AppThemeApi = {
setActiveThemeId(themeId?: string): void;
};
-export const appThemeApiRef = createApiRef({
+export const appThemeApiRef: ApiRef = createApiRef({
id: 'core.apptheme',
description: 'API Used to configure the app theme, and enumerate options',
});
diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts
index 1fa6e70d1f..2ce972af0e 100644
--- a/packages/core-api/src/apis/definitions/ConfigApi.ts
+++ b/packages/core-api/src/apis/definitions/ConfigApi.ts
@@ -13,13 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
import { Config } from '@backstage/config';
// Using interface to make the ConfigApi name show up in docs
export type ConfigApi = Config;
-export const configApiRef = createApiRef({
+export const configApiRef: ApiRef = createApiRef({
id: 'core.config',
description: 'Used to access runtime configuration',
});
diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts
index b0773086c7..9777835ec8 100644
--- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts
+++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
/**
* The discovery API is used to provide a mechanism for plugins to
@@ -41,7 +41,7 @@ export type DiscoveryApi = {
getBaseUrl(pluginId: string): Promise;
};
-export const discoveryApiRef = createApiRef({
+export const discoveryApiRef: ApiRef = createApiRef({
id: 'core.discovery',
description: 'Provides service discovery of backend plugins',
});
diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts
index 7f9676ea5a..edd6966968 100644
--- a/packages/core-api/src/apis/definitions/ErrorApi.ts
+++ b/packages/core-api/src/apis/definitions/ErrorApi.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
/**
@@ -62,7 +62,7 @@ export type ErrorApi = {
error$(): Observable<{ error: Error; context?: ErrorContext }>;
};
-export const errorApiRef = createApiRef({
+export const errorApiRef: ApiRef = createApiRef({
id: 'core.error',
description: 'Used to report errors and forward them to the app',
});
diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
index b6aac75620..243af562b1 100644
--- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
+++ b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
@@ -14,12 +14,7 @@
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
-import {
- UserFlags,
- FeatureFlagsRegistry,
- FeatureFlagsRegistryItem,
-} from '../../app/FeatureFlags';
+import { ApiRef, createApiRef } from '../system';
/**
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
@@ -33,29 +28,59 @@ import {
* to enable and disable feature flags, this API acts as another way to enable/disable.
*/
+export type FeatureFlag = {
+ name: string;
+ pluginId: string;
+};
+
export enum FeatureFlagState {
- Off = 0,
- On = 1,
+ None = 0,
+ Active = 1,
}
+/**
+ * Options to use when saving feature flags.
+ */
+export type FeatureFlagsSaveOptions = {
+ /**
+ * The new feature flag states to save.
+ */
+ states: Record;
+
+ /**
+ * Whether the saves states should be merged into the existing ones, or replace them.
+ *
+ * Defaults to false.
+ */
+ merge?: boolean;
+};
+
+export type UserFlags = {};
+
export interface FeatureFlagsApi {
/**
- * Store a list of registered feature flags.
+ * Registers a new feature flag. Once a feature flag has been registered it
+ * can be toggled by users, and read back to enable or disable features.
*/
- registeredFeatureFlags: FeatureFlagsRegistryItem[];
-
- /**
- * Get a list of all feature flags from the current user.
- */
- getFlags(): UserFlags;
+ registerFlag(flag: FeatureFlag): void;
/**
* Get a list of all registered flags.
*/
- getRegisteredFlags(): FeatureFlagsRegistry;
+ getRegisteredFlags(): FeatureFlag[];
+
+ /**
+ * Whether the feature flag with the given name is currently activated for the user.
+ */
+ isActive(name: string): boolean;
+
+ /**
+ * Save the user's choice of feature flag states.
+ */
+ save(options: FeatureFlagsSaveOptions): void;
}
-export const featureFlagsApiRef = createApiRef({
+export const featureFlagsApiRef: ApiRef = createApiRef({
id: 'core.featureflags',
description: 'Used to toggle functionality in features across Backstage',
});
diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts
index 2684422b1e..5cfc4723e0 100644
--- a/packages/core-api/src/apis/definitions/IdentityApi.ts
+++ b/packages/core-api/src/apis/definitions/IdentityApi.ts
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
/**
@@ -51,7 +51,7 @@ export type IdentityApi = {
signOut(): Promise;
};
-export const identityApiRef = createApiRef({
+export const identityApiRef: ApiRef = createApiRef({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
index 6fefbd1b9c..b9776ed037 100644
--- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
+++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts
@@ -16,7 +16,7 @@
import { IconComponent } from '../../icons';
import { Observable } from '../../types';
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
/**
* Information about the auth provider that we're requesting a login towards.
@@ -99,7 +99,7 @@ export type PendingAuthRequest = {
export type OAuthRequestApi = {
/**
* A utility for showing login popups or similar things, and merging together multiple requests for
- * different scopes into one request that inclues all scopes.
+ * different scopes into one request that includes all scopes.
*
* The passed in options provide information about the login provider, and how to handle auth requests.
*
@@ -114,7 +114,7 @@ export type OAuthRequestApi = {
): AuthRequester;
/**
- * Observers panding auth requests. The returned observable will emit all
+ * Observers pending auth requests. The returned observable will emit all
* current active auth request, at most one for each created auth requester.
*
* Each request has its own info about the login provider, forwarded from the auth requester options.
@@ -127,7 +127,7 @@ export type OAuthRequestApi = {
authRequest$(): Observable;
};
-export const oauthRequestApiRef = createApiRef({
+export const oauthRequestApiRef: ApiRef = createApiRef({
id: 'core.oauthrequest',
description: 'An API for implementing unified OAuth flows in Backstage',
});
diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts
index 920ee56811..2482af6d63 100644
--- a/packages/core-api/src/apis/definitions/StorageApi.ts
+++ b/packages/core-api/src/apis/definitions/StorageApi.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
+import { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
import { ErrorApi } from './ErrorApi';
@@ -52,7 +52,7 @@ export interface StorageApi {
remove(key: string): Promise;
/**
- * Save persistant data, and emit messages to anyone that is using observe$ for this key
+ * Save persistent data, and emit messages to anyone that is using observe$ for this key
*
* @param {String} key Unique key associated with the data.
*/
@@ -65,7 +65,7 @@ export interface StorageApi {
observe$(key: string): Observable>;
}
-export const storageApiRef = createApiRef({
+export const storageApiRef: ApiRef = createApiRef({
id: 'core.storage',
description: 'Provides the ability to store data which is unique to the user',
});
diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts
index 109f15b26e..c538065f3a 100644
--- a/packages/core-api/src/apis/definitions/auth.ts
+++ b/packages/core-api/src/apis/definitions/auth.ts
@@ -14,8 +14,8 @@
* limitations under the License.
*/
-import { createApiRef } from '../ApiRef';
-import { Observable } from '../..';
+import { ApiRef, createApiRef } from '../system';
+import { Observable } from '../../types';
/**
* This file contains declarations for common interfaces of auth-related APIs.
@@ -212,13 +212,13 @@ export type SessionApi = {
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
-export const googleAuthApiRef = createApiRef<
+export const googleAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
->({
+> = createApiRef({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
});
@@ -229,9 +229,9 @@ export const googleAuthApiRef = createApiRef<
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
-export const githubAuthApiRef = createApiRef<
+export const githubAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
->({
+> = createApiRef({
id: 'core.auth.github',
description: 'Provides authentication towards GitHub APIs',
});
@@ -242,13 +242,13 @@ export const githubAuthApiRef = createApiRef<
* See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/
* for a full list of supported scopes.
*/
-export const oktaAuthApiRef = createApiRef<
+export const oktaAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
->({
+> = createApiRef({
id: 'core.auth.okta',
description: 'Provides authentication towards Okta APIs',
});
@@ -259,9 +259,9 @@ export const oktaAuthApiRef = createApiRef<
* See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token
* for a full list of supported scopes.
*/
-export const gitlabAuthApiRef = createApiRef<
+export const gitlabAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
->({
+> = createApiRef({
id: 'core.auth.gitlab',
description: 'Provides authentication towards GitLab APIs',
});
@@ -272,9 +272,9 @@ export const gitlabAuthApiRef = createApiRef<
* See https://auth0.com/docs/scopes/current/oidc-scopes
* for a full list of supported scopes.
*/
-export const auth0AuthApiRef = createApiRef<
+export const auth0AuthApiRef: ApiRef<
OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
->({
+> = createApiRef({
id: 'core.auth.auth0',
description: 'Provides authentication towards Auth0 APIs',
});
@@ -286,13 +286,13 @@ export const auth0AuthApiRef = createApiRef<
* - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
* - https://docs.microsoft.com/en-us/graph/permissions-reference
*/
-export const microsoftAuthApiRef = createApiRef<
+export const microsoftAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
->({
+> = createApiRef({
id: 'core.auth.microsoft',
description: 'Provides authentication towards Microsoft APIs and identities',
});
@@ -300,13 +300,13 @@ export const microsoftAuthApiRef = createApiRef<
/**
* Provides authentication for custom identity providers.
*/
-export const oauth2ApiRef = createApiRef<
+export const oauth2ApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
->({
+> = createApiRef({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
@@ -314,9 +314,20 @@ export const oauth2ApiRef = createApiRef<
/**
* Provides authentication for saml based identity providers
*/
-export const samlAuthApiRef = createApiRef<
+export const samlAuthApiRef: ApiRef<
ProfileInfoApi & BackstageIdentityApi & SessionApi
->({
+> = createApiRef({
id: 'core.auth.saml',
description: 'Example of how to use SAML custom provider',
});
+
+export const oneloginAuthApiRef: ApiRef<
+ OAuthApi &
+ OpenIdConnectApi &
+ ProfileInfoApi &
+ BackstageIdentityApi &
+ SessionApi
+> = createApiRef({
+ id: 'core.auth.onelogin',
+ description: 'Provides authentication towards OneLogin APIs and identities',
+});
diff --git a/packages/core-api/src/app/FeatureFlags.test.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
similarity index 59%
rename from packages/core-api/src/app/FeatureFlags.test.tsx
rename to packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
index 99974f9b1d..2902e399c9 100644
--- a/packages/core-api/src/app/FeatureFlags.test.tsx
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
@@ -14,70 +14,52 @@
* limitations under the License.
*/
-import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
-import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
+import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
+import { FeatureFlagState, FeatureFlagsApi } from '../../definitions';
describe('FeatureFlags', () => {
beforeEach(() => {
window.localStorage.clear();
});
- describe('#getFlags', () => {
+ describe('getFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
- featureFlags = new FeatureFlagsImpl();
+ featureFlags = new LocalStorageFeatureFlags();
});
it('returns no flags', () => {
- expect(featureFlags.getFlags().toObject()).toMatchObject({});
+ expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
- it('returns the correct flags', () => {
+ it('loads flags from local storage', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
+ 'feature-flag-four': 2,
+ 'feature-flag-five': 'not-valid',
}),
);
- featureFlags = new FeatureFlagsImpl();
- expect(featureFlags.getFlags().toObject()).toMatchObject({
- 'feature-flag-one': FeatureFlagState.On,
- 'feature-flag-two': FeatureFlagState.On,
- 'feature-flag-three': FeatureFlagState.Off,
- });
- });
-
- it('gets the correct values', () => {
- window.localStorage.setItem(
- 'featureFlags',
- JSON.stringify({
- 'feature-flag-one': 1,
- 'feature-flag-two': 0,
- }),
- );
-
- featureFlags = new FeatureFlagsImpl();
-
- expect(featureFlags.getFlags().get('feature-flag-one')).toEqual(
- FeatureFlagState.On,
- );
- expect(featureFlags.getFlags().get('feature-flag-two')).toEqual(
- FeatureFlagState.Off,
- );
- expect(featureFlags.getFlags().get('feature-flag-three')).toEqual(
- FeatureFlagState.Off,
- );
+ expect(featureFlags.isActive('feature-flag-one')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-four')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-five')).toBe(false);
});
it('sets the correct values', () => {
- const flags = featureFlags.getFlags();
- flags.set('feature-flag-zero', FeatureFlagState.On);
+ featureFlags.save({
+ states: {
+ 'feature-flag-zero': FeatureFlagState.Active,
+ },
+ });
- expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On);
+ expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-zero":1}',
);
@@ -89,16 +71,20 @@ describe('FeatureFlags', () => {
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 0,
+ 'feature-flag-tree': 1,
+ 'feature-flag-four': 0,
}),
);
- featureFlags = new FeatureFlagsImpl();
- const flags = featureFlags.getFlags();
- flags.delete('feature-flag-one');
+ featureFlags.save({
+ states: {
+ 'feature-flag-one': FeatureFlagState.None,
+ 'feature-flag-two': FeatureFlagState.Active,
+ },
+ });
- expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off);
expect(window.localStorage.getItem('featureFlags')).toEqual(
- '{"feature-flag-two":0}',
+ '{"feature-flag-two":1}',
);
});
@@ -112,33 +98,65 @@ describe('FeatureFlags', () => {
}),
);
- const flags = featureFlags.getFlags();
- flags.clear();
+ expect(featureFlags.isActive('feature-flag-one')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(true);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
+
+ featureFlags.save({ states: {} });
+
+ expect(featureFlags.isActive('feature-flag-one')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-two')).toBe(false);
+ expect(featureFlags.isActive('feature-flag-three')).toBe(false);
- expect(flags.toObject()).toEqual({});
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
});
});
- describe('#getRegisteredFlags', () => {
+ describe('getRegisteredFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
- featureFlags = new FeatureFlagsImpl();
- featureFlags.registeredFeatureFlags = [
- { name: 'registered-flag-1', pluginId: 'plugin-one' },
- { name: 'registered-flag-2', pluginId: 'plugin-one' },
- { name: 'registered-flag-3', pluginId: 'plugin-two' },
- ];
+ featureFlags = new LocalStorageFeatureFlags();
+ featureFlags.registerFlag({
+ name: 'registered-flag-1',
+ pluginId: 'plugin-one',
+ });
+ featureFlags.registerFlag({
+ name: 'registered-flag-2',
+ pluginId: 'plugin-one',
+ });
+ featureFlags.registerFlag({
+ name: 'registered-flag-3',
+ pluginId: 'plugin-two',
+ });
});
it('should return an empty list', () => {
- featureFlags.registeredFeatureFlags = [];
- expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]);
+ featureFlags = new LocalStorageFeatureFlags();
+ expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('should return an valid list', () => {
- expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([
+ expect(featureFlags.getRegisteredFlags()).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ { name: 'registered-flag-3', pluginId: 'plugin-two' },
+ ]);
+ });
+
+ it('should provide a copy of the list of flags', () => {
+ const flags = featureFlags.getRegisteredFlags();
+ expect(flags).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ { name: 'registered-flag-3', pluginId: 'plugin-two' },
+ ]);
+ flags.splice(2, 1);
+ expect(flags).toEqual([
+ { name: 'registered-flag-1', pluginId: 'plugin-one' },
+ { name: 'registered-flag-2', pluginId: 'plugin-one' },
+ ]);
+ expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
@@ -164,48 +182,9 @@ describe('FeatureFlags', () => {
});
});
- it('should append the correct value', () => {
- const flags = featureFlags.getRegisteredFlags();
-
- flags.push({
- name: 'registered-flag-4',
- pluginId: 'plugin-three',
- });
-
- expect(flags.toObject()).toMatchObject([
- { name: 'registered-flag-1', pluginId: 'plugin-one' },
- { name: 'registered-flag-2', pluginId: 'plugin-one' },
- { name: 'registered-flag-3', pluginId: 'plugin-two' },
- { name: 'registered-flag-4', pluginId: 'plugin-three' },
- ]);
- });
-
- it('should concat the correct values', () => {
- const flags = featureFlags.getRegisteredFlags();
- const concatValues = flags.concat([
- {
- name: 'registered-flag-4',
- pluginId: 'plugin-three',
- },
- {
- name: 'registered-flag-5',
- pluginId: 'plugin-four',
- },
- ]);
-
- expect(concatValues).toMatchObject([
- { name: 'registered-flag-1', pluginId: 'plugin-one' },
- { name: 'registered-flag-2', pluginId: 'plugin-one' },
- { name: 'registered-flag-3', pluginId: 'plugin-two' },
- { name: 'registered-flag-4', pluginId: 'plugin-three' },
- { name: 'registered-flag-5', pluginId: 'plugin-four' },
- ]);
- });
-
it('throws an error if length is less than three characters', () => {
- const flags = featureFlags.getRegisteredFlags();
expect(() =>
- flags.push({
+ featureFlags.registerFlag({
name: 'ab',
pluginId: 'plugin-three',
}),
@@ -213,9 +192,8 @@ describe('FeatureFlags', () => {
});
it('throws an error if length is greater than 150 characters', () => {
- const flags = featureFlags.getRegisteredFlags();
expect(() =>
- flags.push({
+ featureFlags.registerFlag({
name:
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
pluginId: 'plugin-three',
@@ -224,9 +202,8 @@ describe('FeatureFlags', () => {
});
it('throws an error if name does not start with a lowercase letter', () => {
- const flags = featureFlags.getRegisteredFlags();
expect(() =>
- flags.push({
+ featureFlags.registerFlag({
name: '123456789',
pluginId: 'plugin-three',
}),
@@ -234,9 +211,8 @@ describe('FeatureFlags', () => {
});
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
- const flags = featureFlags.getRegisteredFlags();
expect(() =>
- flags.push({
+ featureFlags.registerFlag({
name: 'Invalid_Feature_Flag',
pluginId: 'plugin-three',
}),
diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
new file mode 100644
index 0000000000..00b3eff906
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
@@ -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 {
+ FeatureFlagState,
+ FeatureFlagsApi,
+ FeatureFlag,
+ FeatureFlagsSaveOptions,
+} from '../../definitions';
+
+export function validateFlagName(name: string): void {
+ if (name.length < 3) {
+ throw new Error(
+ `The '${name}' feature flag must have a minimum length of three characters.`,
+ );
+ }
+
+ if (name.length > 150) {
+ throw new Error(
+ `The '${name}' feature flag must not exceed 150 characters.`,
+ );
+ }
+
+ if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
+ throw new Error(
+ `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
+ 'Examples: feature-flag-one, alpha, release-2020',
+ );
+ }
+}
+
+/**
+ * Create the FeatureFlags implementation based on the API.
+ */
+export class LocalStorageFeatureFlags implements FeatureFlagsApi {
+ private registeredFeatureFlags: FeatureFlag[] = [];
+ private flags?: Map;
+
+ registerFlag(flag: FeatureFlag) {
+ validateFlagName(flag.name);
+ this.registeredFeatureFlags.push(flag);
+ }
+
+ getRegisteredFlags(): FeatureFlag[] {
+ return this.registeredFeatureFlags.slice();
+ }
+
+ isActive(name: string): boolean {
+ if (!this.flags) {
+ this.flags = this.load();
+ }
+ return this.flags.get(name) === FeatureFlagState.Active;
+ }
+
+ save(options: FeatureFlagsSaveOptions): void {
+ if (!this.flags) {
+ this.flags = this.load();
+ }
+ if (!options.merge) {
+ this.flags.clear();
+ }
+ for (const [name, state] of Object.entries(options.states)) {
+ this.flags.set(name, state);
+ }
+
+ const enabled = Array.from(this.flags.entries()).filter(
+ ([, state]) => state === FeatureFlagState.Active,
+ );
+ window.localStorage.setItem(
+ 'featureFlags',
+ JSON.stringify(Object.fromEntries(enabled)),
+ );
+ }
+
+ private load(): Map {
+ try {
+ const jsonStr = window.localStorage.getItem('featureFlags');
+ if (!jsonStr) {
+ return new Map();
+ }
+ const json = JSON.parse(jsonStr) as unknown;
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
+ return new Map();
+ }
+
+ const entries = Object.entries(json).filter(([name, value]) => {
+ validateFlagName(name);
+ return value === FeatureFlagState.Active;
+ });
+
+ return new Map(entries);
+ } catch {
+ return new Map();
+ }
+ }
+}
diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts
new file mode 100644
index 0000000000..33990584f3
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
index 40e537169c..b41e705726 100644
--- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
+++ b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts
@@ -16,20 +16,8 @@
import Auth0Icon from '@material-ui/icons/AcUnit';
import { auth0AuthApiRef } from '../../../definitions/auth';
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { OAuth2 } from '../oauth2';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'auth0',
@@ -43,13 +31,14 @@ class Auth0Auth {
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
- }: CreateOptions): typeof auth0AuthApiRef.T {
+ defaultScopes = ['openid', `email`, `profile`],
+ }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
- defaultScopes: ['openid', `email`, `profile`],
+ defaultScopes,
});
}
}
diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts
index 1d87ff3720..532bff3480 100644
--- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts
@@ -25,25 +25,13 @@ import {
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
export type GithubAuthResponse = {
providerInfo: {
@@ -67,7 +55,8 @@ class GithubAuth implements OAuthApi, SessionApi {
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
- }: CreateOptions) {
+ defaultScopes = ['read:user'],
+ }: OAuthApiCreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
@@ -89,7 +78,7 @@ class GithubAuth implements OAuthApi, SessionApi {
const sessionManager = new StaticAuthSessionManager({
connector,
- defaultScopes: new Set(['read:user']),
+ defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
index 9e2acd4537..8669dff022 100644
--- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts
@@ -16,20 +16,8 @@
import GitlabIcon from '@material-ui/icons/AcUnit';
import { gitlabAuthApiRef } from '../../../definitions/auth';
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { OAuth2 } from '../oauth2';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
@@ -43,13 +31,14 @@ class GitlabAuth {
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
- }: CreateOptions): typeof gitlabAuthApiRef.T {
+ defaultScopes = ['read_user'],
+ }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
- defaultScopes: ['read_user'],
+ defaultScopes,
});
}
}
diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts
index 7e84226508..fbc902cdd8 100644
--- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts
@@ -16,20 +16,8 @@
import GoogleIcon from '@material-ui/icons/AcUnit';
import { googleAuthApiRef } from '../../../definitions/auth';
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { OAuth2 } from '../oauth2';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'google',
@@ -37,25 +25,26 @@ const DEFAULT_PROVIDER = {
icon: GoogleIcon,
};
+const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
+
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
- }: CreateOptions): typeof googleAuthApiRef.T {
- const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
-
+ defaultScopes = [
+ 'openid',
+ `${SCOPE_PREFIX}userinfo.email`,
+ `${SCOPE_PREFIX}userinfo.profile`,
+ ],
+ }: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
- defaultScopes: [
- 'openid',
- `${SCOPE_PREFIX}userinfo.email`,
- `${SCOPE_PREFIX}userinfo.profile`,
- ],
+ defaultScopes,
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts
index 39223e8e15..7ef78d19cf 100644
--- a/packages/core-api/src/apis/implementations/auth/index.ts
+++ b/packages/core-api/src/apis/implementations/auth/index.ts
@@ -22,3 +22,4 @@ export * from './okta';
export * from './saml';
export * from './auth0';
export * from './microsoft';
+export * from './onelogin';
diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
index 241ac7b802..3e2711db3b 100644
--- a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts
@@ -16,21 +16,8 @@
import MicrosoftIcon from '@material-ui/icons/AcUnit';
import { microsoftAuthApiRef } from '../../../definitions/auth';
-
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { OAuth2 } from '../oauth2';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'microsoft',
@@ -44,19 +31,20 @@ class MicrosoftAuth {
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
- }: CreateOptions): typeof microsoftAuthApiRef.T {
+ defaultScopes = [
+ 'openid',
+ 'offline_access',
+ 'profile',
+ 'email',
+ 'User.Read',
+ ],
+ }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
- defaultScopes: [
- 'openid',
- 'offline_access',
- 'profile',
- 'email',
- 'User.Read',
- ],
+ defaultScopes,
});
}
}
diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts
index d088ca9798..73ad3dcf14 100644
--- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts
+++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts
@@ -19,11 +19,6 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
-import {
- AuthProvider,
- OAuthRequestApi,
- DiscoveryApi,
-} from '../../../definitions';
import {
AuthRequestOptions,
BackstageIdentity,
@@ -36,19 +31,14 @@ import {
BackstageIdentityApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
+import { OAuthApiCreateOptions } from '../types';
type Options = {
sessionManager: SessionManager;
scopeTransform: (scopes: string[]) => string[];
};
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
- defaultScopes?: string[];
+type CreateOptions = OAuthApiCreateOptions & {
scopeTransform?: (scopes: string[]) => string[];
};
diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts
index 7e9ff77678..2df908e9fb 100644
--- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts
@@ -16,20 +16,8 @@
import OktaIcon from '@material-ui/icons/AcUnit';
import { oktaAuthApiRef } from '../../../definitions/auth';
-import {
- OAuthRequestApi,
- AuthProvider,
- DiscoveryApi,
-} from '../../../definitions';
import { OAuth2 } from '../oauth2';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- oauthRequestApi: OAuthRequestApi;
-
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'okta',
@@ -55,13 +43,14 @@ class OktaAuth {
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
- }: CreateOptions): typeof oktaAuthApiRef.T {
+ defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
+ }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
- defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
+ defaultScopes,
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts
new file mode 100644
index 0000000000..ff5c7c1990
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 OneLoginIcon from '@material-ui/icons/AcUnit';
+import { oneloginAuthApiRef } from '../../../definitions/auth';
+import {
+ OAuthRequestApi,
+ AuthProvider,
+ DiscoveryApi,
+} from '../../../definitions';
+import { OAuth2 } from '../oauth2';
+
+type CreateOptions = {
+ discoveryApi: DiscoveryApi;
+ oauthRequestApi: OAuthRequestApi;
+
+ environment?: string;
+ provider?: AuthProvider & { id: string };
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'onelogin',
+ title: 'onelogin',
+ icon: OneLoginIcon,
+};
+
+const OIDC_SCOPES: Set = new Set([
+ 'openid',
+ 'profile',
+ 'email',
+ 'phone',
+ 'address',
+ 'groups',
+ 'offline_access',
+]);
+
+const SCOPE_PREFIX: string = 'onelogin.';
+
+class OneLoginAuth {
+ static create({
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ }: CreateOptions): typeof oneloginAuthApiRef.T {
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
+ scopeTransform(scopes) {
+ return scopes.map(scope => {
+ if (OIDC_SCOPES.has(scope)) {
+ return scope;
+ }
+
+ if (scope.startsWith(SCOPE_PREFIX)) {
+ return scope;
+ }
+
+ return `${SCOPE_PREFIX}${scope}`;
+ });
+ },
+ });
+ }
+}
+
+export default OneLoginAuth;
diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts
similarity index 91%
rename from plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts
rename to packages/core-api/src/apis/implementations/auth/onelogin/index.ts
index 7071799805..1d163207db 100644
--- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts
+++ b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { default } from './ResourceGrowthBarChartLegend';
+export { default as OneLoginAuth } from './OneLoginAuth';
diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts
index 973b402756..da5f082c9b 100644
--- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts
+++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts
@@ -27,18 +27,12 @@ import {
BackstageIdentityApi,
SessionApi,
} from '../../../definitions/auth';
-import { AuthProvider, DiscoveryApi } from '../../../definitions';
import { SamlSession } from './types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
-
-type CreateOptions = {
- discoveryApi: DiscoveryApi;
- environment?: string;
- provider?: AuthProvider & { id: string };
-};
+import { AuthApiCreateOptions } from '../types';
export type SamlAuthResponse = {
profile: ProfileInfo;
@@ -56,7 +50,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
- }: CreateOptions) {
+ }: AuthApiCreateOptions) {
const connector = new DirectAuthConnector({
discoveryApi,
environment,
diff --git a/packages/core-api/src/apis/implementations/auth/types.ts b/packages/core-api/src/apis/implementations/auth/types.ts
new file mode 100644
index 0000000000..db3b718d25
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/types.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions';
+
+export type OAuthApiCreateOptions = AuthApiCreateOptions & {
+ oauthRequestApi: OAuthRequestApi;
+ defaultScopes?: string[];
+};
+
+export type AuthApiCreateOptions = {
+ discoveryApi: DiscoveryApi;
+ environment?: string;
+ provider?: AuthProvider & { id: string };
+};
diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts
index 30aeb81d44..d0df2760ab 100644
--- a/packages/core-api/src/apis/implementations/index.ts
+++ b/packages/core-api/src/apis/implementations/index.ts
@@ -23,7 +23,8 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
-export * from './ErrorApi';
export * from './DiscoveryApi';
+export * from './ErrorApi';
+export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts
index c856a13668..03569f9570 100644
--- a/packages/core-api/src/apis/index.ts
+++ b/packages/core-api/src/apis/index.ts
@@ -14,10 +14,6 @@
* limitations under the License.
*/
-export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
-export { ApiRegistry } from './ApiRegistry';
-export * from './ApiRef';
-export * from './types';
-export * from './helpers';
+export * from './system';
export * from './definitions';
export * from './implementations';
diff --git a/packages/core-api/src/apis/ApiAggregator.test.ts b/packages/core-api/src/apis/system/ApiAggregator.test.ts
similarity index 100%
rename from packages/core-api/src/apis/ApiAggregator.test.ts
rename to packages/core-api/src/apis/system/ApiAggregator.test.ts
diff --git a/packages/core-api/src/apis/ApiAggregator.ts b/packages/core-api/src/apis/system/ApiAggregator.ts
similarity index 93%
rename from packages/core-api/src/apis/ApiAggregator.ts
rename to packages/core-api/src/apis/system/ApiAggregator.ts
index da49ee6488..1587a1d10b 100644
--- a/packages/core-api/src/apis/ApiAggregator.ts
+++ b/packages/core-api/src/apis/system/ApiAggregator.ts
@@ -14,8 +14,7 @@
* limitations under the License.
*/
-import { ApiRef } from './ApiRef';
-import { ApiHolder } from './types';
+import { ApiRef, ApiHolder } from './types';
/**
* An ApiHolder that queries multiple other holders from for
diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts
similarity index 100%
rename from packages/core-api/src/apis/ApiFactoryRegistry.test.ts
rename to packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts
diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts
similarity index 98%
rename from packages/core-api/src/apis/ApiFactoryRegistry.ts
rename to packages/core-api/src/apis/system/ApiFactoryRegistry.ts
index 0d36e6c550..556f63589c 100644
--- a/packages/core-api/src/apis/ApiFactoryRegistry.ts
+++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts
@@ -15,12 +15,12 @@
*/
import {
+ ApiRef,
ApiFactoryHolder,
ApiFactory,
AnyApiRef,
AnyApiFactory,
} from './types';
-import { ApiRef } from './ApiRef';
type ApiFactoryScope =
| 'default' // Default factories registered by core and plugins
diff --git a/packages/core-api/src/apis/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx
similarity index 100%
rename from packages/core-api/src/apis/ApiProvider.test.tsx
rename to packages/core-api/src/apis/system/ApiProvider.test.tsx
diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx
similarity index 96%
rename from packages/core-api/src/apis/ApiProvider.tsx
rename to packages/core-api/src/apis/system/ApiProvider.tsx
index 24610a1372..f2aa70244e 100644
--- a/packages/core-api/src/apis/ApiProvider.tsx
+++ b/packages/core-api/src/apis/system/ApiProvider.tsx
@@ -16,8 +16,7 @@
import React, { FC, createContext, useContext, ReactNode } from 'react';
import PropTypes from 'prop-types';
-import { ApiRef } from './ApiRef';
-import { ApiHolder, TypesToApiRefs } from './types';
+import { ApiRef, ApiHolder, TypesToApiRefs } from './types';
import { ApiAggregator } from './ApiAggregator';
type ApiProviderProps = {
diff --git a/packages/core-api/src/apis/ApiRef.test.ts b/packages/core-api/src/apis/system/ApiRef.test.ts
similarity index 100%
rename from packages/core-api/src/apis/ApiRef.test.ts
rename to packages/core-api/src/apis/system/ApiRef.test.ts
diff --git a/packages/core-api/src/apis/ApiRef.ts b/packages/core-api/src/apis/system/ApiRef.ts
similarity index 95%
rename from packages/core-api/src/apis/ApiRef.ts
rename to packages/core-api/src/apis/system/ApiRef.ts
index 793c1e5386..0e1eb177c8 100644
--- a/packages/core-api/src/apis/ApiRef.ts
+++ b/packages/core-api/src/apis/system/ApiRef.ts
@@ -14,17 +14,13 @@
* limitations under the License.
*/
+import type { ApiRef } from './types';
+
export type ApiRefConfig = {
id: string;
description: string;
};
-export type ApiRef = {
- id: string;
- description: string;
- T: T;
-};
-
class ApiRefImpl implements ApiRef {
constructor(private readonly config: ApiRefConfig) {
const valid = config.id
diff --git a/packages/core-api/src/apis/ApiRegistry.test.ts b/packages/core-api/src/apis/system/ApiRegistry.test.ts
similarity index 100%
rename from packages/core-api/src/apis/ApiRegistry.test.ts
rename to packages/core-api/src/apis/system/ApiRegistry.test.ts
diff --git a/packages/core-api/src/apis/ApiRegistry.ts b/packages/core-api/src/apis/system/ApiRegistry.ts
similarity index 93%
rename from packages/core-api/src/apis/ApiRegistry.ts
rename to packages/core-api/src/apis/system/ApiRegistry.ts
index 64d97ba234..b105633c51 100644
--- a/packages/core-api/src/apis/ApiRegistry.ts
+++ b/packages/core-api/src/apis/system/ApiRegistry.ts
@@ -14,8 +14,7 @@
* limitations under the License.
*/
-import { ApiRef } from './ApiRef';
-import { ApiHolder } from './types';
+import { ApiRef, ApiHolder } from './types';
type ApiImpl = readonly [ApiRef, T];
@@ -28,7 +27,7 @@ class ApiRegistryBuilder {
}
build(): ApiRegistry {
- // eslint-disable-next-line no-use-before-define
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
return new ApiRegistry(new Map(this.apis));
}
}
diff --git a/packages/core-api/src/apis/ApiResolver.test.ts b/packages/core-api/src/apis/system/ApiResolver.test.ts
similarity index 100%
rename from packages/core-api/src/apis/ApiResolver.test.ts
rename to packages/core-api/src/apis/system/ApiResolver.test.ts
diff --git a/packages/core-api/src/apis/ApiResolver.ts b/packages/core-api/src/apis/system/ApiResolver.ts
similarity index 98%
rename from packages/core-api/src/apis/ApiResolver.ts
rename to packages/core-api/src/apis/system/ApiResolver.ts
index cb65186a37..0dfeaeedd0 100644
--- a/packages/core-api/src/apis/ApiResolver.ts
+++ b/packages/core-api/src/apis/system/ApiResolver.ts
@@ -14,8 +14,8 @@
* limitations under the License.
*/
-import { ApiRef } from './ApiRef';
import {
+ ApiRef,
ApiHolder,
ApiFactoryHolder,
AnyApiRef,
diff --git a/packages/core-api/src/apis/helpers.ts b/packages/core-api/src/apis/system/helpers.ts
similarity index 93%
rename from packages/core-api/src/apis/helpers.ts
rename to packages/core-api/src/apis/system/helpers.ts
index 7d616acd01..0ccd0cdb80 100644
--- a/packages/core-api/src/apis/helpers.ts
+++ b/packages/core-api/src/apis/system/helpers.ts
@@ -14,8 +14,7 @@
* limitations under the License.
*/
-import { ApiFactory, TypesToApiRefs } from './types';
-import { ApiRef } from './ApiRef';
+import { ApiRef, ApiFactory, TypesToApiRefs } from './types';
/**
* Used to infer types for a standalone ApiFactory that isn't immediately passed
diff --git a/packages/core-api/src/apis/system/index.ts b/packages/core-api/src/apis/system/index.ts
new file mode 100644
index 0000000000..10b2e0f084
--- /dev/null
+++ b/packages/core-api/src/apis/system/index.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { ApiProvider, useApi, useApiHolder } from './ApiProvider';
+export { ApiRegistry } from './ApiRegistry';
+export { ApiResolver } from './ApiResolver';
+export { ApiFactoryRegistry } from './ApiFactoryRegistry';
+export { createApiRef } from './ApiRef';
+export * from './types';
+export * from './helpers';
diff --git a/packages/core-api/src/apis/types.ts b/packages/core-api/src/apis/system/types.ts
similarity index 94%
rename from packages/core-api/src/apis/types.ts
rename to packages/core-api/src/apis/system/types.ts
index 61c229b18e..b0c19e551c 100644
--- a/packages/core-api/src/apis/types.ts
+++ b/packages/core-api/src/apis/system/types.ts
@@ -14,7 +14,11 @@
* limitations under the License.
*/
-import { ApiRef } from './ApiRef';
+export type ApiRef = {
+ id: string;
+ description: string;
+ T: T;
+};
export type AnyApiRef = ApiRef;
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 9dd2fb138f..9c38c20b44 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -30,7 +30,6 @@ import {
SignInPageProps,
} from './types';
import { BackstagePlugin } from '../plugin';
-import { FeatureFlagsRegistryItem } from './FeatureFlags';
import {
featureFlagsApiRef,
AppThemeApi,
@@ -51,11 +50,11 @@ import {
useApi,
AnyApiFactory,
ApiHolder,
+ LocalStorageFeatureFlags,
} from '../apis';
import { useAsync } from 'react-use';
import { AppIdentity } from './AppIdentity';
-import { ApiFactoryRegistry } from '../apis/ApiFactoryRegistry';
-import { ApiResolver } from '../apis/ApiResolver';
+import { ApiResolver, ApiFactoryRegistry } from '../apis/system';
type FullAppOptions = {
apis: Iterable;
@@ -136,7 +135,8 @@ export class PrivateAppImpl implements BackstageApp {
getRoutes(): JSX.Element[] {
const routes = new Array();
- const registeredFeatureFlags = new Array();
+
+ const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!;
const { NotFoundErrorPage } = this.components;
@@ -172,9 +172,9 @@ export class PrivateAppImpl implements BackstageApp {
break;
}
case 'feature-flag': {
- registeredFeatureFlags.push({
- pluginId: plugin.getId(),
+ featureFlagsApi.registerFlag({
name: output.name,
+ pluginId: plugin.getId(),
});
break;
}
@@ -184,11 +184,6 @@ export class PrivateAppImpl implements BackstageApp {
}
}
- const featureFlags = this.getApiHolder().get(featureFlagsApiRef);
- if (featureFlags) {
- featureFlags.registeredFeatureFlags = registeredFeatureFlags;
- }
-
routes.push( } />);
return routes;
@@ -320,6 +315,13 @@ export class PrivateAppImpl implements BackstageApp {
factory: () => this.identityApi,
});
+ // It's possible to replace the feature flag API, but since we must have at least
+ // one implementation we add it here directly instead of through the defaultApis.
+ registry.register('default', {
+ api: featureFlagsApiRef,
+ deps: {},
+ factory: () => new LocalStorageFeatureFlags(),
+ });
for (const factory of this.defaultApis) {
registry.register('default', factory);
}
diff --git a/packages/core-api/src/app/FeatureFlags.tsx b/packages/core-api/src/app/FeatureFlags.tsx
deleted file mode 100644
index 3db2a18a02..0000000000
--- a/packages/core-api/src/app/FeatureFlags.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { FeatureFlagName } from '../plugin/types';
-import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
-
-/**
- * Helper method for validating compatibility and flag name.
- */
-export function validateBrowserCompat(): void {
- if (!('localStorage' in window)) {
- throw new Error(
- 'Feature Flags are not supported on browsers without the Local Storage API',
- );
- }
-}
-
-export function validateFlagName(name: FeatureFlagName): void {
- if (name.length < 3) {
- throw new Error(
- `The '${name}' feature flag must have a minimum length of three characters.`,
- );
- }
-
- if (name.length > 150) {
- throw new Error(
- `The '${name}' feature flag must not exceed 150 characters.`,
- );
- }
-
- if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
- throw new Error(
- `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
- 'Examples: feature-flag-one, alpha, release-2020',
- );
- }
-}
-
-/**
- * The UserFlags class.
- *
- * This acts as a data structure for the user's feature flags. You
- * can use this to retrieve, add, edit, delete, clear and save the user's
- * feature flags to the local browser for persisted storage.
- */
-export class UserFlags extends Map {
- static load(): UserFlags {
- validateBrowserCompat();
-
- try {
- const jsonString = window.localStorage.getItem('featureFlags') as string;
- const json = JSON.parse(jsonString);
- return new this(Object.entries(json));
- } catch (err) {
- return new this([]);
- }
- }
-
- get(name: FeatureFlagName): FeatureFlagState {
- return super.get(name) || FeatureFlagState.Off;
- }
-
- set(name: FeatureFlagName, state: FeatureFlagState): this {
- validateFlagName(name);
- const output = super.set(name, state);
- this.save();
- return output;
- }
-
- toggle(name: FeatureFlagName): FeatureFlagState {
- if (super.get(name) === FeatureFlagState.On) {
- super.set(name, FeatureFlagState.Off);
- } else {
- super.set(name, FeatureFlagState.On);
- }
- return super.get(name) || FeatureFlagState.Off;
- }
-
- delete(name: FeatureFlagName): boolean {
- const output = super.delete(name);
- this.save();
- return output;
- }
-
- clear(): void {
- super.clear();
- this.save();
- }
-
- save(): void {
- window.localStorage.setItem(
- 'featureFlags',
- JSON.stringify(this.toObject()),
- );
- }
-
- toObject() {
- return Array.from(this.entries()).reduce(
- (obj, [key, value]) => ({ ...obj, [key]: value }),
- {},
- );
- }
-
- toJSON() {
- return JSON.stringify(this.toObject());
- }
-
- toString() {
- return this.toJSON();
- }
-}
-
-/**
- * The FeatureFlagsRegistry class.
- *
- * This acts as a holding data structure for feature flags
- * that plugins wish to register for use in Backstage.
- */
-export interface FeatureFlagsRegistryItem {
- pluginId: string;
- name: FeatureFlagName;
-}
-
-export class FeatureFlagsRegistry extends Array {
- static from(entries: FeatureFlagsRegistryItem[]) {
- Array.from(entries).forEach(entry => validateFlagName(entry.name));
- return new FeatureFlagsRegistry(...entries);
- }
-
- push(...entries: FeatureFlagsRegistryItem[]): number {
- Array.from(entries).forEach(entry => validateFlagName(entry.name));
- return super.push(...entries);
- }
-
- concat(
- ...entries: (
- | FeatureFlagsRegistryItem
- | ConcatArray
- )[]
- ): FeatureFlagsRegistryItem[] {
- const _concat = super.concat(...entries);
- Array.from(_concat).forEach(entry => validateFlagName(entry.name));
- return _concat;
- }
-
- toObject() {
- return [...this.values()];
- }
-
- toJSON() {
- return JSON.stringify(this.toObject());
- }
-
- toString() {
- return this.toJSON();
- }
-}
-
-/**
- * Create the FeatureFlags implementation based on the API.
- */
-export class FeatureFlags implements FeatureFlagsApi {
- public registeredFeatureFlags: FeatureFlagsRegistryItem[] = [];
- private userFlags: UserFlags | undefined;
-
- getFlags(): UserFlags {
- if (!this.userFlags) this.userFlags = UserFlags.load();
- return this.userFlags;
- }
-
- getRegisteredFlags(): FeatureFlagsRegistry {
- return FeatureFlagsRegistry.from(this.registeredFeatureFlags);
- }
-}
diff --git a/packages/core-api/src/app/index.ts b/packages/core-api/src/app/index.ts
index 003b71e353..17610ea3ee 100644
--- a/packages/core-api/src/app/index.ts
+++ b/packages/core-api/src/app/index.ts
@@ -14,6 +14,5 @@
* limitations under the License.
*/
-export { FeatureFlags } from './FeatureFlags';
export { useApp } from './AppContext';
export * from './types';
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index 882ecd3d8b..8a1eb92e8e 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -112,11 +112,13 @@ export type AppOptions = {
* title: 'Light Theme',
* variant: 'light',
* theme: lightTheme,
+ * icon: ,
* }, {
* id: 'dark',
* title: 'Dark Theme',
* variant: 'dark',
* theme: darkTheme,
+ * icon: ,
* }]
* ```
*/
diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts
index 5781130799..391b86952d 100644
--- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts
+++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts
@@ -19,8 +19,9 @@ import { DefaultAuthConnector } from './DefaultAuthConnector';
import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi';
import * as loginPopup from '../loginPopup';
import { UrlPatternDiscovery } from '../../apis';
-
-const anyFetch = fetch as any;
+import { msw } from '@backstage/test-utils';
+import { setupServer } from 'msw/node';
+import { rest } from 'msw';
const defaultOptions = {
discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'),
@@ -39,19 +40,25 @@ const defaultOptions = {
};
describe('DefaultAuthConnector', () => {
+ const server = setupServer();
+ msw.setupDefaultHandlers(server);
+
afterEach(() => {
jest.resetAllMocks();
- anyFetch.resetMocks();
});
it('should refresh a session', async () => {
- anyFetch.mockResponseOnce(
- JSON.stringify({
- idToken: 'mock-id-token',
- accessToken: 'mock-access-token',
- scopes: 'a b c',
- expiresInSeconds: '60',
- }),
+ server.use(
+ rest.get('*', (_req, res, ctx) =>
+ res(
+ ctx.json({
+ idToken: 'mock-id-token',
+ accessToken: 'mock-access-token',
+ scopes: 'a b c',
+ expiresInSeconds: '60',
+ }),
+ ),
+ ),
);
const helper = new DefaultAuthConnector(defaultOptions);
@@ -64,7 +71,11 @@ describe('DefaultAuthConnector', () => {
});
it('should handle failure to refresh session', async () => {
- anyFetch.mockRejectOnce(new Error('Network NOPE'));
+ server.use(
+ rest.get('*', (_req, res, ctx) =>
+ res(ctx.status(500, 'Error: Network NOPE')),
+ ),
+ );
const helper = new DefaultAuthConnector(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
@@ -73,7 +84,7 @@ describe('DefaultAuthConnector', () => {
});
it('should handle failure response when refreshing session', async () => {
- anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' });
+ server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE'))));
const helper = new DefaultAuthConnector(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx
index 1835ec03ad..d69bb7e721 100644
--- a/packages/core-api/src/plugin/Plugin.tsx
+++ b/packages/core-api/src/plugin/Plugin.tsx
@@ -14,50 +14,9 @@
* limitations under the License.
*/
-import { ComponentType } from 'react';
-import {
- PluginOutput,
- RoutePath,
- RouteOptions,
- FeatureFlagName,
- BackstagePlugin,
-} from './types';
-import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
-import { RouteRef } from '../routing';
+import { PluginConfig, PluginOutput, BackstagePlugin } from './types';
import { AnyApiFactory } from '../apis';
-export type PluginConfig = {
- id: string;
- apis?: Iterable;
- register?(hooks: PluginHooks): void;
-};
-
-export type PluginHooks = {
- router: RouterHooks;
- featureFlags: FeatureFlagsHooks;
-};
-
-export type RouterHooks = {
- addRoute(
- target: RouteRef,
- Component: ComponentType,
- options?: RouteOptions,
- ): void;
-
- /**
- * @deprecated See the `addRoute` method
- */
- registerRoute(
- path: RoutePath,
- Component: ComponentType,
- options?: RouteOptions,
- ): void;
-};
-
-export type FeatureFlagsHooks = {
- register(name: FeatureFlagName): void;
-};
-
export class PluginImpl {
private storedOutput?: PluginOutput[];
@@ -97,8 +56,6 @@ export class PluginImpl {
},
featureFlags: {
register(name) {
- validateBrowserCompat();
- validateFlagName(name);
outputs.push({ type: 'feature-flag', name });
},
},
diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts
index 12992f3620..427d3d539a 100644
--- a/packages/core-api/src/plugin/types.ts
+++ b/packages/core-api/src/plugin/types.ts
@@ -16,7 +16,7 @@
import { ComponentType } from 'react';
import { RouteRef } from '../routing';
-import { AnyApiFactory } from '../apis';
+import { AnyApiFactory } from '../apis/system';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
@@ -54,11 +54,9 @@ export type LegacyRedirectRouteOutput = {
options?: RouteOptions;
};
-export type FeatureFlagName = string;
-
export type FeatureFlagOutput = {
type: 'feature-flag';
- name: FeatureFlagName;
+ name: string;
};
export type PluginOutput =
@@ -73,3 +71,35 @@ export type BackstagePlugin = {
output(): PluginOutput[];
getApis(): Iterable;
};
+
+export type PluginConfig = {
+ id: string;
+ apis?: Iterable;
+ register?(hooks: PluginHooks): void;
+};
+
+export type PluginHooks = {
+ router: RouterHooks;
+ featureFlags: FeatureFlagsHooks;
+};
+
+export type RouterHooks = {
+ addRoute(
+ target: RouteRef,
+ Component: ComponentType,
+ options?: RouteOptions,
+ ): void;
+
+ /**
+ * @deprecated See the `addRoute` method
+ */
+ registerRoute(
+ path: RoutePath,
+ Component: ComponentType,
+ options?: RouteOptions,
+ ): void;
+};
+
+export type FeatureFlagsHooks = {
+ register(name: string): void;
+};
diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts
index 8553642152..aea2220869 100644
--- a/packages/core-api/src/setupTests.ts
+++ b/packages/core-api/src/setupTests.ts
@@ -15,5 +15,4 @@
*/
import '@testing-library/jest-dom';
-
-require('jest-fetch-mock').enableMocks();
+import 'cross-fetch/polyfill';
diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts
index 09f0bc11ae..13a8a72570 100644
--- a/packages/core-api/src/types.ts
+++ b/packages/core-api/src/types.ts
@@ -15,7 +15,7 @@
*/
/**
- * This file contains non-react related core types used throught Backstage.
+ * This file contains non-react related core types used throughout Backstage.
*/
/**
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
new file mode 100644
index 0000000000..213e2b5246
--- /dev/null
+++ b/packages/core/CHANGELOG.md
@@ -0,0 +1,111 @@
+# @backstage/core
+
+## 0.3.1
+
+### Patch Changes
+
+- 1722cb53c: Added configuration schema
+
+## 0.3.0
+
+### Minor Changes
+
+- 199237d2f: New DependencyGraph component added to core package.
+
+### Patch Changes
+
+- 7b37d65fd: 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`
+
+ ```
+ ...`
+
+ Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse'
+ to pass an optional variant to the corresponding card of the plugin.
+
+ As a result the overview content of the EntityPage shows cards with full height suitable for Grid.
+
+### Patch Changes
+
+- ae5983387: Fix banner position and color
+
+ This PR closes: #2245
+
+ The "fixed" props added to control the position of the banner. When it is set to true the banner will be shown in bottom of that page and the width will be based on the content of the message.
+
+ 
+
+ 
+
+- 144c66d50: Fixed banner component position in DismissableBanner component
+- 93a3fa3ae: Add forwardRef to the SidebarItem
+- 782f3b354: add test case for Progress component
+- 2713f28f4: fix the warning of all the core components test cases
+- 406015b0d: Update ItemCard headers to pass color contrast standards.
+- 82759d3e4: rename stories folder top Chip
+- ac8d5d5c7: update the test cases of CodeSnippet component
+- ebca83d48: add test cases for Status components
+- aca79334f: update ItemCard component and it's story
+- c0d5242a0: Proper render boolean values on StructuredMetadataTable component
+- 3beb5c9fc: make ErrorPage responsive + fix the test case
+- 754e31db5: give aria-label attribute to Status Ok, Warning and Error
+- 1611c6dbc: fix the responsive of page story
+- Updated dependencies [819a70229]
+- Updated dependencies [ae5983387]
+- Updated dependencies [0d4459c08]
+- Updated dependencies [cbbd271c4]
+- Updated dependencies [b79017fd3]
+- Updated dependencies [26e69ab1a]
+- Updated dependencies [cbab5bbf8]
+ - @backstage/core-api@0.2.0
+ - @backstage/theme@0.2.0
diff --git a/packages/core/README.md b/packages/core/README.md
index a22f66ecd9..0d0063c9fd 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -18,5 +18,5 @@ $ yarn add @backstage/core
## Documentation
-- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
-- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
diff --git a/packages/core/config.d.ts b/packages/core/config.d.ts
new file mode 100644
index 0000000000..014e4ac934
--- /dev/null
+++ b/packages/core/config.d.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 {
+ /**
+ * Generic frontend configuration.
+ */
+ app: {
+ /**
+ * The public absolute root URL that the frontend.
+ * @visibility frontend
+ */
+ baseUrl: string;
+
+ /**
+ * The title of the app.
+ * @visibility frontend
+ */
+ title?: string;
+ };
+
+ /**
+ * Generic backend configuration.
+ */
+ backend: {
+ /**
+ * The public absolute root URL that the backend is reachable at.
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+
+ /**
+ * Configuration that provides information about the organization that the app is for.
+ */
+ organization?: {
+ /**
+ * The name of the organization that the app belongs to.
+ * @visibility frontend
+ */
+ name?: string;
+ };
+
+ homepage?: {
+ clocks?: {
+ /** @visibility frontend */
+ label: string;
+ /** @visibility frontend */
+ timezone: string;
+ }[];
+ };
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 3022aa29c4..70c1a9e647 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
- "version": "0.1.1-alpha.24",
+ "version": "0.3.1",
"private": false,
"publishConfig": {
"access": "public",
@@ -11,7 +11,7 @@
"homepage": "https://backstage.io",
"repository": {
"type": "git",
- "url": "https://github.com/spotify/backstage",
+ "url": "https://github.com/backstage/backstage",
"directory": "packages/core"
},
"keywords": [
@@ -29,16 +29,23 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@backstage/config": "^0.1.1-alpha.24",
- "@backstage/core-api": "^0.1.1-alpha.24",
- "@backstage/theme": "^0.1.1-alpha.24",
+ "@backstage/config": "^0.1.1",
+ "@backstage/core-api": "^0.2.1",
+ "@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+ "@types/dagre": "^0.7.44",
"@types/react": "^16.9",
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
+ "d3-selection": "^2.0.0",
+ "d3-shape": "^2.0.0",
+ "d3-zoom": "^2.0.0",
+ "dagre": "^0.8.5",
+ "qs": "^6.9.4",
+ "immer": "^7.0.9",
"lodash": "^4.17.15",
"material-table": "^1.69.1",
"prop-types": "^15.7.2",
@@ -47,27 +54,33 @@
"react-dom": "^16.12.0",
"react-helmet": "6.1.0",
"react-hook-form": "^6.6.0",
+ "react-markdown": "^5.0.2",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^13.5.1",
- "react-use": "^15.3.3"
+ "react-use": "^15.3.3",
+ "remark-gfm": "^1.0.0"
},
"devDependencies": {
- "@backstage/cli": "^0.1.1-alpha.24",
- "@backstage/test-utils": "^0.1.1-alpha.24",
+ "@backstage/cli": "^0.3.0",
+ "@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/classnames": "^2.2.9",
+ "@types/d3-selection": "^2.0.0",
+ "@types/d3-shape": "^2.0.0",
+ "@types/d3-zoom": "^2.0.0",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react-helmet": "^6.1.0",
- "@types/zen-observable": "^0.8.0",
- "jest-fetch-mock": "^3.0.3"
+ "@types/zen-observable": "^0.8.0"
},
"files": [
- "dist"
- ]
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
}
diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx
index a1655b88dd..31f584b68c 100644
--- a/packages/core/src/api-wrappers/createApp.test.tsx
+++ b/packages/core/src/api-wrappers/createApp.test.tsx
@@ -18,10 +18,12 @@ import { defaultConfigLoader } from './createApp';
(process as any).env = { NODE_ENV: 'test' };
const anyEnv = process.env as any;
+const anyWindow = window as any;
describe('defaultConfigLoader', () => {
afterEach(() => {
delete anyEnv.APP_CONFIG;
+ delete anyWindow.__APP_CONFIG__;
});
it('loads static config', async () => {
@@ -73,4 +75,20 @@ describe('defaultConfigLoader', () => {
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
+
+ it('loads config from window.__APP_CONFIG__', async () => {
+ anyEnv.APP_CONFIG = [
+ { data: { my: 'config' }, context: 'a' },
+ { data: { my: 'override-config' }, context: 'b' },
+ ];
+ const windowConfig = { app: { configKey: 'config-value' } };
+ anyWindow.__APP_CONFIG__ = windowConfig;
+
+ const configs = await defaultConfigLoader();
+
+ expect(configs).toEqual([
+ ...anyEnv.APP_CONFIG,
+ { context: 'window', data: windowConfig },
+ ]);
+ });
});
diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx
index 7fe77423e2..4a1e58db42 100644
--- a/packages/core/src/api-wrappers/createApp.tsx
+++ b/packages/core/src/api-wrappers/createApp.tsx
@@ -22,7 +22,8 @@ import privateExports, {
AppConfigLoader,
} from '@backstage/core-api';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
-
+import LightIcon from '@material-ui/icons/WbSunny';
+import DarkIcon from '@material-ui/icons/Brightness2';
import { ErrorPage } from '../layout/ErrorPage';
import { Progress } from '../components/Progress';
import { defaultApis } from './defaultApis';
@@ -60,12 +61,23 @@ export const defaultConfigLoader: AppConfigLoader = async (
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
const data = JSON.parse(runtimeConfigJson) as JsonObject;
- configs.push({ data, context: 'env' });
+ if (Array.isArray(data)) {
+ configs.push(...data);
+ } else {
+ configs.push({ data, context: 'env' });
+ }
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
+ const windowAppConfig = (window as any).__APP_CONFIG__;
+ if (windowAppConfig) {
+ configs.push({
+ context: 'window',
+ data: windowAppConfig,
+ });
+ }
return configs;
};
@@ -110,12 +122,14 @@ export function createApp(options?: AppOptions) {
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
+ icon: ,
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: darkTheme,
+ icon: ,
},
];
const configLoader = options?.configLoader ?? defaultConfigLoader;
diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts
index dedadf0456..61d4f3a7e1 100644
--- a/packages/core/src/api-wrappers/defaultApis.ts
+++ b/packages/core/src/api-wrappers/defaultApis.ts
@@ -20,8 +20,6 @@ import {
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
- featureFlagsApiRef,
- FeatureFlags,
discoveryApiRef,
GoogleAuth,
GithubAuth,
@@ -46,6 +44,8 @@ import {
UrlPatternDiscovery,
samlAuthApiRef,
SamlAuth,
+ oneloginAuthApiRef,
+ OneLoginAuth,
} from '@backstage/core-api';
export const defaultApis = [
@@ -69,7 +69,6 @@ export const defaultApis = [
deps: { errorApi: errorApiRef },
factory: ({ errorApi }) => WebStorage.create({ errorApi }),
}),
- createApiFactory(featureFlagsApiRef, new FeatureFlags()),
createApiFactory(oauthRequestApiRef, new OAuthRequestManager()),
createApiFactory({
api: googleAuthApiRef,
@@ -96,7 +95,11 @@ export const defaultApis = [
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
- GithubAuth.create({ discoveryApi, oauthRequestApi }),
+ GithubAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ defaultScopes: ['read:user'],
+ }),
}),
createApiFactory({
api: oktaAuthApiRef,
@@ -141,4 +144,13 @@ export const defaultApis = [
},
factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }),
}),
+ createApiFactory({
+ api: oneloginAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi }) =>
+ OneLoginAuth.create({ discoveryApi, oauthRequestApi }),
+ }),
];
diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx
index aaca389f82..a5005574fe 100644
--- a/packages/core/src/components/Button/Button.stories.tsx
+++ b/packages/core/src/components/Button/Button.stories.tsx
@@ -24,7 +24,7 @@ const Location = () => {
};
export default {
- title: 'Button',
+ title: 'Inputs/Button',
component: Button,
decorators: [
(storyFn: FunctionComponentFactory<{}>) => (
diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.tsx
similarity index 94%
rename from packages/core/src/components/Button/Button.test.jsx
rename to packages/core/src/components/Button/Button.test.tsx
index 115835be3f..8bae5f2767 100644
--- a/packages/core/src/components/Button/Button.test.jsx
+++ b/packages/core/src/components/Button/Button.test.tsx
@@ -34,7 +34,9 @@ describe(' ', () => {
);
expect(() => getByText(testString)).toThrow();
- await act(async () => fireEvent.click(getByText(buttonLabel)));
+ await act(async () => {
+ fireEvent.click(getByText(buttonLabel));
+ });
expect(getByText(testString)).toBeInTheDocument();
});
});
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx
new file mode 100644
index 0000000000..db590e6524
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.stories.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { useState } from 'react';
+import { CheckboxTree } from '.';
+
+const CHECKBOX_TREE_ITEMS = [
+ {
+ label: 'Genereic subcategory name 1',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+ {
+ label: 'Genereic subcategory name 2',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+ {
+ label: 'Genereic subcategory name 3',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+];
+
+export default {
+ title: 'Inputs/CheckboxTree',
+ component: CheckboxTree,
+};
+
+export const Default = () => (
+ {}}
+ label="default"
+ subCategories={CHECKBOX_TREE_ITEMS}
+ />
+);
+
+export const DynamicTree = () => {
+ function generateTree(showMore: boolean = false) {
+ const t = [
+ {
+ label: 'Show more',
+ options: [],
+ },
+ ];
+
+ if (showMore) {
+ t.push({
+ label: 'More',
+ options: [],
+ });
+ }
+
+ return t;
+ }
+
+ const [tree, setTree] = useState(generateTree());
+
+ return (
+ {
+ setTree(generateTree(state.some(c => c.category === 'Show more')));
+ }}
+ label="default"
+ subCategories={tree}
+ />
+ );
+};
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx
new file mode 100644
index 0000000000..f5eaa0e58b
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.test.tsx
@@ -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 React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+
+import { CheckboxTree } from '.';
+
+const CHECKBOX_TREE_ITEMS = [
+ {
+ label: 'Genereic subcategory name 1',
+ options: [
+ {
+ label: 'Option 1',
+ value: 1,
+ },
+ {
+ label: 'Option 2',
+ value: 2,
+ },
+ ],
+ },
+];
+
+const minProps = {
+ onChange: jest.fn(),
+ label: 'Default',
+ subCategories: CHECKBOX_TREE_ITEMS,
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText, getByTestId } = render( );
+
+ expect(getByText('Genereic subcategory name 1')).toBeInTheDocument();
+ const checkbox = await getByTestId('expandable');
+
+ // Simulate click on expandable arrow
+ fireEvent.click(checkbox);
+
+ // Simulate click on option
+ const option = getByText('Option 1');
+ expect(getByText('Option 1')).toBeInTheDocument();
+ fireEvent.click(option);
+ expect(minProps.onChange).toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx
new file mode 100644
index 0000000000..d6d3410913
--- /dev/null
+++ b/packages/core/src/components/CheckboxTree/CheckboxTree.tsx
@@ -0,0 +1,358 @@
+/*
+ * 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.
+ */
+/* eslint-disable guard-for-in */
+import {
+ Checkbox,
+ Collapse,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Typography,
+} from '@material-ui/core';
+import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
+import ExpandLess from '@material-ui/icons/ExpandLess';
+import ExpandMore from '@material-ui/icons/ExpandMore';
+import produce from 'immer';
+import { isEqual } from 'lodash';
+import React, { useEffect, useReducer } from 'react';
+import { usePrevious } from 'react-use';
+
+type IndexedObject = {
+ [key: string]: T;
+};
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ width: '100%',
+ minWidth: 10,
+ maxWidth: 360,
+ backgroundColor: 'transparent',
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ '&:active': {
+ animation: 'none',
+ transform: 'none',
+ },
+ },
+ nested: {
+ paddingLeft: theme.spacing(5),
+ height: '32px',
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ },
+ listItemIcon: {
+ minWidth: 10,
+ },
+ listItem: {
+ '&:hover': {
+ backgroundColor: 'transparent',
+ },
+ },
+ text: {
+ '& span, & svg': {
+ fontWeight: 'normal',
+ fontSize: 14,
+ },
+ },
+ }),
+);
+
+/* SUB_CATEGORY */
+
+type SubCategory = {
+ label: string;
+ isChecked?: boolean;
+ isOpen?: boolean;
+ options?: Option[];
+};
+
+type SubCategoryWithIndexedOptions = {
+ label: string;
+ isChecked?: boolean;
+ isOpen?: boolean;
+ options: IndexedObject;
+};
+
+/* OPTION */
+
+type Option = {
+ label: string;
+ value: string | number;
+ isChecked?: boolean;
+};
+
+type Selection = { category?: string; selectedChildren?: string[] }[];
+
+export type CheckboxTreeProps = {
+ subCategories: SubCategory[];
+ label: string;
+ triggerReset?: boolean;
+ selected?: Selection;
+ onChange: (arg: Selection) => any;
+};
+
+/* REDUCER */
+
+type checkOptionPayload = {
+ subCategoryLabel: string;
+ optionLabel: string;
+};
+
+type Action =
+ | { type: 'checkOption'; payload: checkOptionPayload }
+ | { type: 'checkCategory'; payload: string }
+ | { type: 'toggleCategory'; payload: string }
+ | {
+ type: 'updateCategories';
+ payload: IndexedObject;
+ }
+ | { type: 'updateSelected'; payload: Selection }
+ | { type: 'triggerReset' };
+
+const reducer = (
+ state: IndexedObject,
+ action: Action,
+) => {
+ switch (action.type) {
+ case 'checkOption': {
+ return produce(state, newState => {
+ const category = newState[action.payload.subCategoryLabel];
+ const option = category.options[action.payload.optionLabel];
+ option.isChecked = !option.isChecked;
+ category.isChecked = Object.values(category.options).every(
+ o => o.isChecked,
+ );
+ });
+ }
+ case 'checkCategory': {
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ const options = category.options;
+ category.isChecked = !category.isChecked;
+ for (const option in options) {
+ options[option].isChecked = category.isChecked;
+ }
+ });
+ }
+ case 'toggleCategory':
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ category.isOpen = !category.isOpen;
+ });
+ case 'triggerReset': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ newState[category].isChecked = false;
+ for (const option in newState[category].options) {
+ newState[category].options[option].isChecked =
+ newState[category].isChecked;
+ }
+ }
+ });
+ }
+ case 'updateCategories': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ delete newState[category];
+ }
+
+ for (const category in action.payload) {
+ newState[category] = action.payload[category];
+
+ if (state[category]) {
+ newState[category].isChecked = state[category].isChecked;
+ newState[category].isOpen = state[category].isOpen;
+ }
+ }
+ });
+ }
+ case 'updateSelected': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ const selection = action.payload.find(s => s.category === category);
+
+ if (selection) {
+ newState[category].isChecked = true;
+
+ for (const option in newState[category].options) {
+ newState[category].options[option].isChecked =
+ selection.selectedChildren?.includes(option) || false;
+ }
+ }
+ }
+ });
+ }
+ default:
+ return state;
+ }
+};
+
+const indexer = (
+ arr: SubCategory[],
+): IndexedObject =>
+ arr.reduce((accumulator, el) => {
+ if (el.options) {
+ return {
+ ...accumulator,
+ [el.label]: {
+ label: el.label,
+ isChecked: el.isChecked || false,
+ isOpen: false,
+ options: indexer(el.options),
+ },
+ };
+ }
+ return {
+ ...accumulator,
+ [el.label]: { ...el, isChecked: el.isChecked || false },
+ };
+ }, {});
+
+export const CheckboxTree = ({
+ subCategories,
+ label,
+ selected,
+ onChange,
+ triggerReset,
+}: CheckboxTreeProps) => {
+ const classes = useStyles();
+
+ const [state, dispatch] = useReducer(reducer, indexer(subCategories));
+
+ const handleOpen = (event: any, value: any) => {
+ event.stopPropagation();
+ dispatch({ type: 'toggleCategory', payload: value });
+ };
+
+ const previousSubCategories = usePrevious(subCategories);
+
+ useEffect(() => {
+ const values = Object.values(state).map(category => ({
+ category: category.isChecked ? category.label : undefined,
+ selectedChildren: Object.values(category.options)
+ .filter(option => option.isChecked)
+ .map(option => option.label),
+ }));
+ onChange(values);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state]);
+
+ useEffect(() => {
+ dispatch({ type: 'triggerReset' });
+ }, [triggerReset]);
+
+ useEffect(() => {
+ if (selected) {
+ dispatch({ type: 'updateSelected', payload: selected });
+ }
+ }, [selected]);
+
+ useEffect(() => {
+ if (!isEqual(subCategories, previousSubCategories)) {
+ dispatch({
+ type: 'updateCategories',
+ payload: indexer(subCategories),
+ });
+ }
+ }, [subCategories, previousSubCategories]);
+
+ return (
+
+
{label}
+
+ {Object.values(state).map(item => (
+
+
+ dispatch({
+ type: 'checkCategory',
+ payload: item.label,
+ })
+ }
+ >
+
+
+
+
+ {Object.values(item.options).length ? (
+ <>
+ {item.isOpen ? (
+ handleOpen(event, item.label)}
+ />
+ ) : (
+ handleOpen(event, item.label)}
+ />
+ )}
+ >
+ ) : null}
+
+
+
+ {Object.values(item.options).map(option => (
+
+ dispatch({
+ type: 'checkOption',
+ payload: {
+ subCategoryLabel: item.label,
+ optionLabel: option.label,
+ },
+ })
+ }
+ >
+