Merge remote-tracking branch 'upstream/master' into multi-cluster

This commit is contained in:
Nir Gazit
2021-01-20 17:48:54 +02:00
168 changed files with 4960 additions and 1227 deletions
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/catalog-model': patch
'@backstage/plugin-catalog-backend': patch
---
Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the
location that was registered to the catalog and which emitted this entity. It has a different
semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct
parent location that created this entity.
Consider this example: The Backstage operator adds a location of type `github-org` in the
`app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor
discovers the entities in the following branch
`Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be:
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: xyz
annotations:
# This entity was added by the 'github-org:…' location
backstage.io/managed-by-location: github-org:…
# The entity was added because the 'bootstrap:boostrap' was added to the catalog
backstage.io/managed-by-origin-location: bootstrap:bootstrap
# ...
spec:
# ...
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-github-actions': minor
---
Support GHE
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core': minor
---
Removed `InfoCard` variant `height100`, originally deprecated in [#2826](https://github.com/backstage/backstage/pull/2826).
If your component still relies on this variant, simply replace it with `gridItem`.
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/backend-common': patch
'@backstage/integration': patch
---
Add support for GitHub Apps authentication for backend plugins.
`GithubCredentialsProvider` requests and caches GitHub credentials based on a repository or organization url.
The `GithubCredentialsProvider` class should be considered stateful since tokens will be cached internally.
Consecutive calls to get credentials will return the same token, tokens older than 50 minutes will be considered expired and reissued.
`GithubCredentialsProvider` will default to the configured access token if no GitHub Apps are configured.
More information on how to create and configure a GitHub App to use with backstage can be found in the documentation.
Usage:
```javascript
const credentialsProvider = new GithubCredentialsProvider(config);
const { token, headers } = await credentialsProvider.getCredentials({
url: 'https://github.com/',
});
```
Updates `GithubUrlReader` to use the `GithubCredentialsProvider`.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Derive the list of to-delete entities in the `UnregisterEntityDialog` from the `backstage.io/managed-by-origin-location` annotation.
The dialog also rejects deleting entities that are created by the `bootstrap:bootstrap` location.
+40
View File
@@ -0,0 +1,40 @@
---
'@backstage/create-app': patch
---
Migrate to using `FlatRoutes` from `@backstage/core` for the root app routes.
This is the first step in migrating applications as mentioned here: https://backstage.io/docs/plugins/composability#porting-existing-apps.
To apply this change to an existing app, switch out the `Routes` component from `react-router` to `FlatRoutes` from `@backstage/core`.
This also allows you to remove any `/*` suffixes on the route paths. For example:
```diff
import {
OAuthRequestDialog,
SidebarPage,
createRouteRef,
+ FlatRoutes,
} from '@backstage/core';
import { AppSidebar } from './sidebar';
-import { Route, Routes, Navigate } from 'react-router';
+import { Route, Navigate } from 'react-router';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
...
<AppSidebar />
- <Routes>
+ <FlatRoutes>
...
<Route
- path="/catalog/*"
+ path="/catalog"
element={<CatalogRouter EntityPage={EntityPage} />}
/>
- <Route path="/docs/*" element={<DocsRouter />} />
+ <Route path="/docs" element={<DocsRouter />} />
...
<Route path="/settings" element={<SettingsRouter />} />
- </Routes>
+ </FlatRoutes>
</SidebarPage>
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Display the owner, system, and domain as links to the entity pages in the about card.
Only display fields in the about card that are applicable to the entity kind.
+19
View File
@@ -0,0 +1,19 @@
---
'@backstage/create-app': patch
---
fix routing and config for user-settings plugin
To make the corresponding change in your local app, add the following in your App.tsx
```
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
...
<Route path="/settings" element={<SettingsRouter />} />
```
and the following to your plugins.ts:
```
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
```
+26
View File
@@ -0,0 +1,26 @@
---
'@backstage/backend-common': patch
---
1. URL Reader's `readTree` method now returns an `etag` in the response along with the blob. The etag is an identifier of the blob and will only change if the blob is modified on the target. Usually it is set to the latest commit SHA on the target.
`readTree` also takes an optional `etag` in its options and throws a `NotModifiedError` if the etag matches with the etag of the resource.
So, the `etag` can be used in building a cache when working with URL Reader.
An example -
```ts
const response = await reader.readTree(
'https://github.com/backstage/backstage',
);
const etag = response.etag;
// Will throw a new NotModifiedError (exported from @backstage/backstage-common)
await reader.readTree('https://github.com/backstage/backstage', {
etag,
});
```
2. URL Reader's readTree method can now detect the default branch. So, `url:https://github.com/org/repo/tree/master` can be replaced with `url:https://github.com/org/repo` in places like `backstage.io/techdocs-ref`.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-fossa': patch
---
Request a sorted response list to select the project with the correct title. The FOSSA API
matches title searches with "starts with" so previously it used the response for `my-project-part`
if you searched for `my-project`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Fix GitLab API base URL and add it by default to the gitlab.com host
+55
View File
@@ -0,0 +1,55 @@
---
'@backstage/core': minor
---
Removed deprecated `router.registerRoute` method in `createPlugin`.
Deprecated `router.addRoute` method in `createPlugin`.
Replace usage of the above two components with a routable extension.
For example, given the following:
```ts
import { createPlugin } from '@backstage/core';
import { MyPage } from './components/MyPage';
import { rootRoute } from './routes';
export const plugin = createPlugin({
id: 'my-plugin',
register({ router }) {
router.addRoute(rootRoute, MyPage);
},
});
```
Migrate to
```ts
import { createPlugin, createRoutableExtension } from '@backstage/core';
import { rootRoute } from './routes';
export const plugin = createPlugin({
id: 'my-plugin',
routes: {
root: rootRoute,
},
});
export const MyPage = plugin.provide(
createRoutableExtension({
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRoute,
}),
);
```
And then use `MyPage` like this in the app:
```tsx
<FlatRoutes>
...
<Route path='/my-path' element={<MyPage />}>
...
</FlatRoutes>
```
+19
View File
@@ -0,0 +1,19 @@
---
'@backstage/backend-common': minor
---
Remove fallback option from `UrlReaders.create` and `UrlReaders.default`, as well as the default fallback reader.
To be able to read data from endpoints outside of the configured integrations, you now need to explicitly allow it by
adding an entry in the `backend.reading.allow` list. For example:
```yml
backend:
baseUrl: ...
reading:
allow:
- host: example.com
- host: '*.examples.org'
```
Apart from adding the above configuration, most projects should not need to take any action to migrate existing code. If you do happen to have your own fallback reader configured, this needs to be replaced with a reader factory that selects a specific set of URLs to work with. If you where wrapping the existing fallback reader, the new one that handles the allow list is created using `FetchUrlReader.factory`.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Display systems in catalog table and make both owner and system link to the entity pages.
The owner field is now taken from the relations of the entity instead of its spec.
+53
View File
@@ -0,0 +1,53 @@
---
'@backstage/create-app': patch
---
use `fromConfig` for all scaffolder helpers, and use the url protocol for app-config location entries.
To apply this change to your local installation, replace the contents of your `packages/backend/src/plugins/scaffolder.ts` with the following contents:
```ts
import {
CookieCutter,
createRouter,
Preparers,
Publishers,
CreateReactAppTemplater,
Templaters,
CatalogEntityClient,
} from '@backstage/plugin-scaffolder-backend';
import { SingleHostDiscovery } from '@backstage/backend-common';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
const cookiecutterTemplater = new CookieCutter();
const craTemplater = new CreateReactAppTemplater();
const templaters = new Templaters();
templaters.register('cookiecutter', cookiecutterTemplater);
templaters.register('cra', craTemplater);
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,
});
}
```
This will ensure that the `scaffolder-backend` package can add handlers for the `url` protocol which is becoming the standard when registering entities in the `catalog`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/techdocs-common': patch
---
TechDocs backend now streams files through from Google Cloud Storage to the browser, improving memory usage.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/techdocs-common': patch
---
@backstage/techdocs-common can now be imported in an environment without @backstage/plugin-techdocs-backend being installed.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Add AWS ALB OIDC reverse proxy authentication provider
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-lighthouse': patch
---
Fix display of floating point precision errors in card category scores
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': minor
---
Remove support for HTTPS certificate generation parameters. Use `backend.https = true` instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': minor
---
The catalog no longer attempts to merge old and new annotations, when updating an entity from a remote location. This was a behavior that was copied from kubernetes, and catered to use cases where you wanted to use HTTP POST to update an entity in-place, outside of what the refresh loop does. This has proved to be a mistake, because as a side effect, the refresh loop effectively is unable to ever delete annotations when they are removed from source YAML. This is obviously a breaking change, but we believe that this is not a behavior that is relied upon in the wild, and it has never been an actually supported use flow of the catalog. We therefore choose to break the behavior outright, and instead just store updated annotations verbatim - just like we already do for example for labels
+4
View File
@@ -67,6 +67,7 @@ Dominik
dtuite
dzolotusky
Ek
etag
env
Env
eslint
@@ -78,6 +79,7 @@ Firekube
Fiverr
freben
Fredrik
Georgoulas
gitbeaker
GitHub
GitLab
@@ -100,6 +102,7 @@ incentivised
inlined
inlinehilite
interop
Ioannis
JavaScript
jq
js
@@ -113,6 +116,7 @@ Kumar
learnings
lerna
Lerna
Luxon
magiclink
mailto
maintainership
+14 -1
View File
@@ -8,6 +8,9 @@ jobs:
build:
runs-on: ubuntu-latest
outputs:
needs_release: ${{ steps.release_check.outputs.needs_release }}
strategy:
matrix:
node-version: [12.x, 14.x]
@@ -47,6 +50,15 @@ jobs:
run: yarn install --frozen-lockfile
# End of yarn setup
- name: Fetch previous commit for release check
run: git fetch origin '${{ github.event.before }}'
- name: Check if release
id: release_check
run: node scripts/check-if-release.js
env:
COMMIT_SHA_BEFORE: '${{ github.event.before }}'
- name: validate config
run: yarn backstage-cli config:check
@@ -82,9 +94,10 @@ jobs:
# We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and
# only run the build steps that are necessary for publishing
release:
if: contains(github.event.commits.*.author.username, 'github-actions[bot]') && contains(github.event.head_commit.message, 'from backstage/changeset-release/master')
needs: build
if: needs.build.outputs.needs_release == 'true'
runs-on: ubuntu-latest
strategy:
+1
View File
@@ -18,3 +18,4 @@
| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. |
| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. |
| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit |
| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go |
+2 -2
View File
@@ -39,7 +39,7 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how
- [Main documentation](https://backstage.io/docs)
- [Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview)
- [Architecture](https://backstage.io/docs/overview/architecture-terminology) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview))
- [Architecture](https://backstage.io/docs/overview/architecture-overview) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview))
- [Designing for Backstage](https://backstage.io/docs/dls/design)
- [Storybook - UI components](https://backstage.io/storybook)
@@ -57,6 +57,6 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how
## License
Copyright 2020 © 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
Copyright 2020-2021 © 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
Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
+4
View File
@@ -16,6 +16,10 @@ backend:
credentials: true
csp:
connect-src: ["'self'", 'http:', 'https:']
reading:
allow:
- host: example.com
- host: '*.mozilla.org'
# workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir
# See README.md in the proxy-backend plugin for information on the configuration format
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
Backstage is an open-source developer portal that puts the developer experience first.
annotations:
github.com/project-slug: backstage/backstage
backstage.io/techdocs-ref: url:https://github.com/backstage/backstage/tree/master
backstage.io/techdocs-ref: url:https://github.com/backstage/backstage
lighthouse.com/website-url: https://backstage.io
spec:
type: library
@@ -8,8 +8,6 @@ FROM nginx:mainline
# This dockerfile requires the app to be built on the host first, as it
# simply copies in the build output into the image.
# The safest way to build this image is to use `yarn docker-build:app`
RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/*
COPY packages/app/dist /usr/share/nginx/html
@@ -4,18 +4,10 @@ title: ADR000: [TITLE]
description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION]
---
| Created | Status |
| ---------- | ------ |
| YYYY-MM-DD | Open |
# ADR000: [title]
<!-- These documents have names that are short noun phrases. For example, "ADR001: Deployment on Ruby on Rails 3.0.10" or "ADR009: LDAP for Multitenant Integration" -->
## Status
<!-- A decision may be "proposed" if the project stakeholders haven't agreed with it yet, or "accepted" once it is agreed. If a later ADR changes or reverses a decision, it may be marked as "deprecated" or "superseded" with a reference to its replacement. -->
## Context
<!--
@@ -4,12 +4,16 @@ title: ADR001: Architecture Decision Record (ADR) log
description: Architecture Decision Record (ADR) logs as a reference point for the team
---
| Created | Status |
| ---------- | ------ |
| 2020-04-26 | Open |
## Decision
## Decision: A decision was made to store ADRs in a log in the project repository
A decision was made to store ADRs in a log in the project repository
## Discussion: There is a need to store big decisions made in a log as a reference point for the team, help with onboarding new members and give context to others interested in the project.
## Discussion
## Risks: People stop adding ADRs to the log and context gets lost
There is a need to store big decisions made in a log as a reference point for
the team, help with onboarding new members and give context to others interested
in the project.
## Risks
People stop adding ADRs to the log and context gets lost
@@ -4,10 +4,6 @@ title: ADR002: Default Software Catalog File Format
description: Architecture Decision Record (ADR) log on Default Software Catalog File Format
---
| Created | Status |
| ---------- | ------ |
| 2020-05-17 | Open |
## Background
Backstage comes with a software catalog functionality, that you can use to track
@@ -4,10 +4,6 @@ title: ADR003: Avoid Default Exports and Prefer Named Exports
description: Architecture Decision Record (ADR) log on Avoid Default Exports and Prefer Named Exports
---
| Created | Status |
| ---------- | ------ |
| 2020-05-19 | Open |
## Context
When CommonJS was the primary authoring format, the best practice was to export
@@ -4,10 +4,6 @@ title: ADR004: Module Export Structure
description: Architecture Decision Record (ADR) log on Module Export Structure
---
| Created | Status |
| ---------- | ------ |
| 2020-05-27 | Open |
## Context
With a growing number of exports of packages like `@backstage/core`, it is
@@ -4,10 +4,6 @@ title: ADR005: Catalog Core Entities
description: Architecture Decision Record (ADR) log on Catalog Core Entities
---
| Created | Status |
| ---------- | ------ |
| 2020-05-29 | Open |
## Context
We want to standardize on a few core entities that we are tracking in the
@@ -0,0 +1,38 @@
---
id: adrs-adr010
title: ADR010: Use the Luxon Date Library
description: Architecture Decision Record (ADR) for Luxon Date Library
---
# ADR010: Use the Luxon Date Library
## Context
Date formatting (e.g. `a day ago`) and calculations are common within Backstage.
Some of these useful features are not supported by the standard JavaScript
`Date` object. The popular [Moment.js](https://momentjs.com/) library has been
commonly used to fill this gap but suffers from large bundle sizes and mutable
state issues. On top of this, `momentjs` is
[being sunset](https://momentjs.com/docs/#/-project-status/) and the project
recommends using one of the more modern alternative libraries.
See
[[RFC] Standardized Date & Time Library](https://github.com/backstage/backstage/issues/3401).
## Decision
We will use [Luxon](https://moment.github.io/luxon/index.html) as the standard
date library within Backstage.
`Luxon` provides a similar feature set and API to `Moment.js`, but improves on
its design through immutability and the usage of modern JavaScript APIs (e.g.
`Intl`). This results in smaller bundle sizes while providing a full feature set
and avoids the need for using additional libraries for common date & time tasks.
## Consequences
- All core packages and plugins within Backstage should use `Luxon` for any date
manipulation or formatting that cannot be easily accomplished with the native
JavaScript `Date` object.
- Using a single date library avoids having to learn multiple library APIs
- Having a single date library will reduce bundle sizes
+541
View File
@@ -0,0 +1,541 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="789px" height="766px" viewBox="-0.5 -0.5 789 766" content="&lt;mxfile host=&quot;bd2205bb-07f8-4b61-b1c1-5174fe4ebe37&quot; modified=&quot;2021-01-14T13:46:42.842Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.52.0 Chrome/83.0.4103.122 Electron/9.3.5 Safari/537.36&quot; etag=&quot;jW9IV2PM6529z9FP4d6-&quot; version=&quot;13.10.0&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;AOZgdlUmH_6GT6Gt5u4e&quot; name=&quot;Page-1&quot;&gt;7V1bc6M4Fv41rpp5CMVNAh4TJ5ntre7qbGdqdvqRYMWmG4MXcGLvrx8JSYAuNtgG59JJV3VASALOd3R0zqcjMnGmy80febhafMlmKJnY5mwzca4ntu3ZPv6fFGxpAXQALZjn8YwWWU3Bffx/xApNVrqOZ6gQKpZZlpTxSiyMsjRFUSmUhXmePYvVHrNEvOsqnCOl4D4KE7X0v/GsXNBSH5hN+b9QPF/wO1smu/IQRj/nebZO2f0mtvNY/dDLy5D3xeoXi3CWPbeKnJuJM82zrKRHy80UJUS0XGy03e2Oq/Vz5ygt+zSALm3xFCZrxB+5erByy4XxvIhLdL8KI3L+jPGeOFeLcpngMwsfhsWKQvAYbxDu9uoxTpJplmR51dyxral5CXB5UebZT9S6YpoQAp+0yNKyVQ6m5B8uZ8+G8hJtdr6gVYsNayPKlqjMt7gKawACpnlME90goOfPDa6Wy9BYtDHlhSHTpXnddyNPfMBEukO8zjsXrwteUryepUgTzfBAZqdZXi6yeZaGyU1TelWNTiLJaxOfNXU+Z9mKyfwHKssts0rhusxERNAmLv8mzQ0PsNPvrUvXG9Z1dbJlJ0UZ5uUlMU24IM1SxMtuY/J6tEE64zWiJCyKOKKFrIp1AshELPshxlLM1nnEanlMcfETzhGr5us1IUdJWMZPYvc6VFnTuyzGN641CHKjuuVzBxC7oI/AWjW6gSUVblvVVqRCsfs+ninfx5RUjfbYKF79jr100Q1+saFucaDOMtTXf20vp9HzZuluzOW/s23w14/igrsZ+2QuDvcuBJJ4nuLjBD2WI8hfM+gUSHrL37FcRf62P5b8gf3O1Vt2FF6DegONzGFCNHMWP+HDeVm9Oy16yOUSfFOh3nsdGTVU3AnxXUOHnqVBLxgNPJ1n/QGeatbEadlRcbP5yBRGnTcAcO7pRm2PzFpSZg7fLCwWFWbWyLZMHA9QI1Oos2TOADLtMSXjoHdFDqNtEmMlzu1uDX6g6v75oS6oA+2v6xJ3gzge1Gu3wGvTcwCB4TsiLhpggG84rgqND05Hxla1/XK1wgV3WJYVIXI5IU4xDJdE+pUV+patS1S9OntA+L91xsxTgcI8WrSLTJSgJRORdyX2ZBhG1fttXUwNm3etLZZ1Bku5FFUiRxjr8KGqQGwgCdQ4+i2bF+HnQRjPKwJVHIXJJbuwjGezKiBkkQO+DbiagGv90BV1hhWKRnhE5XEk30TVG8fWDGh7gPFsaygMt+K5cPiKlca+XSXreZxeUHW4IFdwzPoBoOS8i9OcZWpsss4/GQRC1Y/8Wi6wTPHQr7ArVDfktz9RtLjOInwJ929OwzJMsjluXp3elNHvwyHM3JK3jK/igro+YYcUF1Q37Q4x69rwEG+TCGJC2HMui8aE13R1y6oXnJ/nZVb7cg9Hlt7v1/RvZc3AqqELTrSD3/K8WotO0g6NU7bHhn/YbhFBS0LQ1oeXurE9hPnWhpeuo6B0RuqbHxPe2zQ4C65nvrHg823diJzQVpQ8J+dNu+qsocwHG65taht4tKxNbQPmcks0+alk98EcdSAGCI4XtBWls74V2JJitThttbVMKEJLsTZUcAr53tmX7XpiRztY/COIdj3borLv3MW54g4pc3bwwTmdm3dhAmX31Q56cs+j2T/HUgC/Z5OXeZPOCSGAcb5JwgILneJ6nyU5OwrDe3qEysj4AFsC2wuMoP3jCth7fJ5rY++cFXs1JFWx/4SRmOfYWGdkxH8Ot8QYfOC8Z1C7nmto3JpgJGi5ypzZg+HeSO1vMB8GdrsjrRX8Zn2+vYgvZAcILpLXw0e6Q3mMpUj0akz/hw/ftv/jjeP/KG6CvIYJLM+wRlrwd2XSjDlLu1wYuT5gQunnTMlvZpuB0c+XGsoFAi87niTv/qDRtCsfRhtt2PuH0lhRg6sZNc4oo+ZQRfel+NSC3l5Fl+sDb08mjNpa5jMss5eaqyGDPN4OsAVHjJnv0AXbNfzx9Ox9uUfbfJr/jC4cHW0mDaIO7ul1kU0StB7oO6PLKB4zo+tFfHpe1KsSMZQlrMhXlyQyxGq1Vrz8Zu9FvLIG6xZKtAoMgTzfDidj3Zr2MUw65bqxAEzSnbOTIX/D+NUZ5Rw/rQnS5lGNNUSALgmbSr5Yhakga44VkdAFDfzIorhn2KsN/l3JyCRh4wULBcnVatlBgflvfCVdLx8qEip7JLfhQSqOMtdJWbTwpw+yA/+3F6beVj8DWQTTFzTK185p5lhRqqfAcdYU8/4Z5k1k2zjf3wXf+6S49lyZ544m85xmxJzgV/feUABeFO1D4pwW3Ca0xeArCLwO0HcQDgftUWA1mg0Kp4RfQ6gJTec7+6KNJZgnHhUOta9AQ3Z2zl7MHSFzUz5/+K3KBzQp4S0d/15PatKMFxw8302z5QprRNp/XnvT+eJ1Oh3HHUBlVoJQ46fCAczUi05KRy3+SnSrPRTb+nq2TDkahgiaeh0a1yI50lYGx7fb2tVZ34L+oCbMU+OnO4xVnfGZPhTkF1/DkROJ/kT5krx9tYaLgcUTliZhjV4uFyhVr31D5TonpvIbd7w1o+dz+ICSiTavqNuPlh1y1a9OSPdXddZyS+9YnHiEtWLbn9l9J7XD29ZUait2mjHTsHzGF9brQPTsRKr/AgqdAkOm9rLHxwKdyurxQTdQaF4vI17efeobnfNsdiz0MElQks3zcElUoOVdCddabldXYls18903gd2rmgEt2czwPWLtuEyXlgwHiMtc3d6ol+VNTttdedw2mCFCXA++pDchBj3Hxjz16tO54x2iEuwdgzM6Gy5UnY1XEf4Alpgz2Lbq3YnNH+HPSxp/VzT+buDrkmLHioDAyxI14pcfajt1EC/XZbLeEhsDNGwMOBdpt4ffZz5gJ79vmWS8K95gtECzdUII/HfoCkoc/UnWAJq+IacFOLoNMCwSFeyBO4QOdMYBnToQ6FTgj5BlEV9n0XopWfdTFu2Gt+6D4ukEEp4Wl3B7r6qlggmGALNHVsZb3UN8WpDgBYYlBuuOG2jXVw1fs4sYKDlpR8Hjv5V44U1NobptKXAIj15x2YGt32YyQtYVf6tT5mZLZ5ix4LEMkM4yN7zfV2K5v6AynIVl+EsYbuBLnAw25Bq3XLdVwx+ASoC6bKQBpuGKjjNv0tmK6mTvvabvCVs3UCdlzQckRpqUoY5wGwDa/6xR9ZJ3eRahoojTeV8w1bSYIfl6KaWmxfNYmq+3DIozcA0QiEBrxvBY+zJgZ7bbcUB/wvP/5gPoAz85NtamOqhLyz0J5cNWWt6HNgz6gaAeX6odSxu8weftw7ThfUwC5/6w7lgzgNdJqB0Ebu2u8YIvYVp9dcJkRqBG+kFpUnt40xxV7j4h7r+hZfZUH68S4s4JcQDLFcDdx1idRnMOxaWfV8UUqAQO4PstzxAG8HjyvAzBcClDvRf1mgUBu3+i7uQofsA7lU4/anUPmKIW7f1qstoaQm3rLqKh8zGg3NFweyu9Tib5IONHlgXjBNU26WaDIvpJPT7nPeYZSW/al+X0K1kuV2IwgK1xgzQxrn94jItPm7+0QFWl+WsWzs0/&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(255, 255, 255);">
<defs/>
<g>
<rect x="560" y="484" width="140" height="140" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="420" y="484" width="140" height="140" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<path d="M 664.5 595 L 664.5 705 L 595.54 705" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 590.29 705 L 597.29 701.5 L 595.54 705 L 597.29 708.5 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<rect x="420" y="110" width="140" height="140" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="420" y="299" width="280" height="140" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="560" y="110" width="140" height="140" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="90" y="469.25" width="110" height="90" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 108px; height: 1px; padding-top: 514px; margin-left: 92px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<div>
<br/>
</div>
</div>
</div>
</div>
</foreignObject>
<text x="92" y="518" fill="#5C5C5C" font-family="Helvetica" font-size="12px">
&#xa;
</text>
</switch>
</g>
<rect x="5" y="20" width="295" height="170" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 293px; height: 1px; padding-top: 105px; margin-left: 7px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<div>
<br/>
</div>
</div>
</div>
</div>
</foreignObject>
<text x="7" y="109" fill="#5C5C5C" font-family="Helvetica" font-size="12px">
&#xa;
</text>
</switch>
</g>
<rect x="20" y="50" width="260" height="130" fill="none" stroke="#006658" stroke-dasharray="3 3" pointer-events="all"/>
<path d="M 530.83 665 C 530.83 656.72 543.89 650 560 650 C 567.74 650 575.16 651.58 580.63 654.39 C 586.1 657.21 589.17 661.02 589.17 665 L 589.17 720 C 589.17 723.98 586.1 727.79 580.63 730.61 C 575.16 733.42 567.74 735 560 735 C 543.89 735 530.83 728.28 530.83 720 Z" fill="#21c0a5" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 589.17 665 C 589.17 668.98 586.1 672.79 580.63 675.61 C 575.16 678.42 567.74 680 560 680 C 543.89 680 530.83 673.28 530.83 665" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<rect x="0" y="0" width="320" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 10px; margin-left: 160px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
App Package: &lt;Route path="/search" element={&lt;... /&gt;} /&gt;
</div>
</div>
</div>
</foreignObject>
<text x="160" y="14" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
App Package: &lt;Route path="/search" element={&lt;... /&gt;}...
</text>
</switch>
</g>
<rect x="415" y="90" width="210" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 100px; margin-left: 520px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
@backstage/plugin-search-backend
</div>
</div>
</div>
</foreignObject>
<text x="520" y="104" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
@backstage/plugin-search-backend
</text>
</switch>
</g>
<rect x="90" y="433.75" width="160" height="30" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 449px; margin-left: 92px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
Other Plugins
<br/>
(TechDocs, Catalog, Etc)
</div>
</div>
</div>
</foreignObject>
<text x="92" y="452" fill="#5C5C5C" font-family="Helvetica" font-size="12px">
Other Plugins...
</text>
</switch>
</g>
<rect x="90" y="229.25" width="210" height="177.75" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 208px; height: 1px; padding-top: 318px; margin-left: 92px;">
<div style="box-sizing: border-box; font-size: 0; text-align: left; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<div>
<font color="#ffffff" size="1">
<br/>
</font>
</div>
</div>
</div>
</div>
</foreignObject>
<text x="92" y="322" fill="#5C5C5C" font-family="Helvetica" font-size="12px"></text>
</switch>
</g>
<rect x="80" y="209.25" width="160" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 219px; margin-left: 160px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
@backstage/plugin-search
</div>
</div>
</div>
</foreignObject>
<text x="160" y="223" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
@backstage/plugin-search
</text>
</switch>
</g>
<path d="M 630 364.25 L 758 364.3 L 758 177.3 L 661.87 177.25" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 656.62 177.25 L 663.62 173.75 L 661.87 177.25 L 663.62 180.75 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<rect x="415" y="279" width="280" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 289px; margin-left: 555px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
Other Backend Plugin (TechDocs, Catalog, Etc)
</div>
</div>
</div>
</foreignObject>
<text x="555" y="293" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Other Backend Plugin (TechDocs, Catalog, Etc)
</text>
</switch>
</g>
<rect x="445" y="745" width="230" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 755px; margin-left: 560px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
Search Engine (Elastic, Solr, SaaS, etc.)
</div>
</div>
</div>
</foreignObject>
<text x="560" y="759" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Search Engine (Elastic, Solr, SaaS, et...
</text>
</switch>
</g>
<rect x="415" y="459.5" width="190" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 470px; margin-left: 510px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
Search Engine Integration Layer
</div>
</div>
</div>
</foreignObject>
<text x="510" y="473" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Search Engine Integration Layer
</text>
</switch>
</g>
<path d="M 458.13 178.25 L 400 178.3 L 400 559.8 L 443.63 559.77" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 463.38 178.25 L 456.38 181.76 L 458.13 178.25 L 456.38 174.76 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 448.88 559.77 L 441.88 563.27 L 443.63 559.77 L 441.88 556.27 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 655.5 151.75 L 780 151.8 L 780 555 L 690.87 555" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 685.62 555 L 692.62 551.5 L 690.87 555 L 692.62 558.5 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<rect x="80" y="60.5" width="190" height="10" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="30" y="60" width="40" height="70" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="80" y="90" width="190" height="65.5" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<rect x="229" y="160.5" width="40" height="10" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 38px; height: 1px; padding-top: 166px; margin-left: 230px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font color="#ffffff">
1 2 3
</font>
</div>
</div>
</div>
</foreignObject>
<text x="249" y="169" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
1 2 3
</text>
</switch>
</g>
<rect x="73" y="70.5" width="100" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 81px; margin-left: 123px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<span style="font-size: 7.2px ; text-align: left">
X number of search results
</span>
</div>
</div>
</div>
</foreignObject>
<text x="123" y="84" fill="#FFFFFF" font-family="Helvetica" font-size="12px" text-anchor="middle">
X number of sear...
</text>
</switch>
</g>
<path d="M 220 313.37 L 220 330.63" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 220 308.12 L 223.5 315.12 L 220 313.37 L 216.5 315.12 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 220 335.88 L 216.5 328.88 L 220 330.63 L 223.5 328.88 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 187 257.5 L 36.1 257.5 L 36.12 179.61" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<rect x="187" y="241" width="66" height="66" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 64px; height: 1px; padding-top: 274px; margin-left: 188px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<span style="color: rgb(255 , 255 , 255) ; font-size: 9px ; text-align: left">
Components
</span>
</div>
</div>
</div>
</foreignObject>
<text x="220" y="278" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Components
</text>
</switch>
</g>
<path d="M 276.37 367 L 360 367 L 360 152.8 L 458.13 152.75" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 271.12 367 L 278.12 363.5 L 276.37 367 L 278.12 370.5 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 463.38 152.75 L 456.38 156.25 L 458.13 152.75 L 456.38 149.25 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 220px; margin-left: 351px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; background-color: #ffffff; white-space: nowrap; ">
Pass Search
<br/>
Term and Filters
<br/>
and then
<br/>
Return Results
</div>
</div>
</div>
</foreignObject>
<text x="351" y="223" fill="#5C5C5C" font-family="Helvetica" font-size="11px" text-anchor="middle">
Pass Search...
</text>
</switch>
</g>
<path d="M 160 397 L 180 337 L 280 337 L 260 397 Z" fill="#21c0a5" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 367px; margin-left: 161px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font color="#ffffff">
Search API
</font>
</div>
</div>
</div>
</foreignObject>
<text x="220" y="371" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Search API
</text>
</switch>
</g>
<rect x="5" y="20" width="295" height="20" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<path d="M 110 516.25 L 36.1 516.3 L 36.12 180" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<rect x="110" y="483.25" width="66" height="66" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 64px; height: 1px; padding-top: 516px; margin-left: 111px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<span style="color: rgb(255 , 255 , 255) ; font-size: 9px ; text-align: left">
Components
</span>
</div>
</div>
</div>
</foreignObject>
<text x="143" y="520" fill="#5C5C5C" font-family="Helvetica" font-size="12px" text-anchor="middle">
Components
</text>
</switch>
</g>
<path d="M 635 198.75 L 630 198.8 L 630 190" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 573.5 239.75 L 593.5 198.75 L 655.5 198.75 L 635.5 239.75 Z" fill="#21c0a5" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 80px; height: 1px; padding-top: 219px; margin-left: 575px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 10px">
Scheduler
</font>
</div>
</div>
</div>
</foreignObject>
<text x="615" y="223" fill="#FFFFFF" font-family="Helvetica" font-size="12px" text-anchor="middle">
Scheduler
</text>
</switch>
</g>
<rect x="604.5" y="139" width="51" height="51" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 49px; height: 1px; padding-top: 165px; margin-left: 606px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 9px">
Gather Documents
</font>
</div>
</div>
</div>
</foreignObject>
<text x="630" y="168" fill="#FFFFFF" font-family="Helvetica" font-size="12px" text-anchor="middle">
Gather D...
</text>
</switch>
</g>
<path d="M 444.17 349.5 C 444.17 341.22 453.31 334.5 464.59 334.5 C 470.01 334.5 475.2 336.08 479.03 338.89 C 482.86 341.71 485.01 345.52 485.01 349.5 L 485.01 379 C 485.01 387.28 475.87 394 464.59 394 C 453.31 394 444.17 387.28 444.17 379 Z" fill="#21c0a5" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 485.01 349.5 C 485.01 357.78 475.87 364.5 464.59 364.5 C 453.31 364.5 444.17 357.78 444.17 349.5" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 550 364.25 L 517.5 364.3 L 485.01 364.3" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<rect x="550" y="324.25" width="80" height="80" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 364px; margin-left: 551px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 11px">
Collate Documents
<br/>
Or Metadata
</font>
</div>
</div>
</div>
</foreignObject>
<text x="590" y="368" fill="#FFFFFF" font-family="Helvetica" font-size="12px" text-anchor="middle">
Collate Docum...
</text>
</switch>
</g>
<rect x="464.5" y="140" width="51" height="51" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 49px; height: 1px; padding-top: 166px; margin-left: 466px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 9px">
API Endpoint
<br/>
</font>
</div>
</div>
</div>
</foreignObject>
<text x="490" y="169" fill="#FFFFFF" font-family="Helvetica" font-size="12px" text-anchor="middle">
API Endp...
</text>
</switch>
</g>
<rect x="419.59" y="110" width="90" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 120px; margin-left: 465px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font style="font-size: 9px">
Query Processing
</font>
</div>
</div>
</div>
</foreignObject>
<text x="465" y="123" fill="#FFFFFF" font-family="Helvetica" font-size="11px" text-anchor="middle">
Query Processing
</text>
</switch>
</g>
<rect x="560" y="110" width="80" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 120px; margin-left: 600px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #FFFFFF; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font style="font-size: 9px">
Index Processing
</font>
</div>
</div>
</div>
</foreignObject>
<text x="600" y="123" fill="#FFFFFF" font-family="Helvetica" font-size="11px" text-anchor="middle">
Index Processi...
</text>
</switch>
</g>
<rect x="560" y="484" width="80" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 494px; margin-left: 600px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font style="font-size: 9px" color="#ffffff">
Index Processing
</font>
</div>
</div>
</div>
</foreignObject>
<text x="600" y="497" fill="#5C5C5C" font-family="Helvetica" font-size="11px" text-anchor="middle">
Index Processi...
</text>
</switch>
</g>
<rect x="420" y="484" width="90" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 494px; margin-left: 465px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font style="font-size: 9px" color="#ffffff">
Query Processing
</font>
</div>
</div>
</div>
</foreignObject>
<text x="465" y="497" fill="#5C5C5C" font-family="Helvetica" font-size="11px" text-anchor="middle">
Query Processing
</text>
</switch>
</g>
<rect x="604.5" y="515" width="80" height="80" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 555px; margin-left: 606px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 9px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font color="#ffffff">
<b>
Manage Index
</b>
<br/>
Create, Remove, Replace Documents and Indices
</font>
</div>
</div>
</div>
</foreignObject>
<text x="645" y="558" fill="#5C5C5C" font-family="Helvetica" font-size="9px" text-anchor="middle">
Manage Index...
</text>
</switch>
</g>
<path d="M 470.25 601.37 L 470.3 705 L 524.63 705" fill="none" stroke="#006658" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 470.25 596.12 L 473.75 603.12 L 470.25 601.37 L 466.75 603.12 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 529.88 705 L 522.88 708.5 L 524.63 705 L 522.88 701.5 Z" fill="#006658" stroke="#006658" stroke-miterlimit="10" pointer-events="all"/>
<rect x="450" y="514" width="81" height="81" fill="#21c0a5" stroke="#006658" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 79px; height: 1px; padding-top: 555px; margin-left: 451px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 9px; font-family: Helvetica; color: #5C5C5C; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font color="#ffffff">
Compile and Execute Query from Term and Filters
</font>
</div>
</div>
</div>
</foreignObject>
<text x="491" y="557" fill="#5C5C5C" font-family="Helvetica" font-size="9px" text-anchor="middle">
Compile and Execut...
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
<a transform="translate(0,-5)" xlink:href="https://desk.draw.io/support/solutions/articles/16000042487" target="_blank">
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Viewer does not support full SVG 1.1
</text>
</a>
</switch>
</svg>

After

Width:  |  Height:  |  Size: 44 KiB

+100
View File
@@ -0,0 +1,100 @@
---
id: search-overview
title: Search Documentation
sidebar_label: Overview
# prettier-ignore
description: Backstage Search lets you find the right information you are looking for in the Backstage ecosystem.
---
# Backstage Search
## What is it?
Backstage Search lets you find the right information you are looking for in the
Backstage ecosystem.
## Features
- A federated, faceted search, searching across all entities registered in your
Backstage instance.
- A search that lets you plug in your own search engine of choice.
- A standardized search API where you can choose to index other plugins data.
## Project roadmap
| Version | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Backstage Search V.0 ✅ | Search Frontend letting you search through the entities of the software catalog. [See V.0 Use Cases.](#backstage-search-v0) |
| Backstage Search V.1 ⌛ | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See V.1 Use Cases.](#backstage-search-v1) |
| Backstage Search V.2 ⌛ | Search Backend responsible for the indexing process of entities, and their metadata, registered to the Software Catalog. [See V.2 Use Cases.](#backstage-search-v2) |
| Backstage Search V.3 ⌛ | Standardized Search API lets you index other plugins data to the search engine of choice. [See V.3 Use Cases.](#backstage-search-v3) |
## Use Cases
#### Backstage Search V.0
- As a software engineer I should be able to navigate to a search page and
search for entities registered in the Software Catalog.
- As a software engineer I should be able to use the search input field in the
sidebar to search for entities registered in the Software Catalog.
- As a software engineer I should be able to see the number of results my search
returned.
- As a software engineer I should be able to filter on metadata (kind,
lifecycle) when Ive performed a search.
- As a software engineer I should be able to hide the filters if I dont need to
use them.
#### Backstage Search V.1
- As a software engineer I should be able to get a match of a search on all
entity metadata (e.g. owner, name, description, kind).
- As an integrator I should not have to plug in any search engine, instead I can
use the out of the box in-memory indexing process to index entities and their
metadata registered in the Software Catalog.
#### Backstage Search V.2
- As an integrator I should be able to spin up an instance of ElasticSearch.
- As an integrator I should be able to define a ElasticSearch cluster in my
app_config.yaml where my data gets indexed to.
more to come...
#### Backstage Search V.3
- As a contributor I should be able to integrate plugin data to the indexing
process of Backstage Search by using the standardized API.
- As a software engineer I should be able to search for all content (for
example, entities, metadata, documentation) in backstage search.
more to come...
## Search Engines Supported
See [Backstage Search Architecture](architecture.md) to get an overview of how
the search engines are used.
| Search Engine | Support Status |
| ------------- | -------------- |
| ElasticSearch | Not yet ❌ |
[Reach out to us](#feedback) if you want to chat about support for more search
engines.
## Tech Stack
| Stack | Location |
| --------------- | ------------------------ |
| Frontend Plugin | @backstage/plugin-search |
| Backend Plugin | ⌛ |
## Feedback
For any questions of feedback, reach out to us in the `#search` channel of our
[Discord chatroom](https://github.com/backstage/backstage#community).
We are still looking for feedback to improve the architecture to fit your
use-case, see
[this open issue](https://github.com/backstage/backstage/issues/4078).
+39
View File
@@ -0,0 +1,39 @@
---
id: architecture
title: Search Architecture
description: Documentation on Search Architecture
---
# Search Architecture
> _This is a proposed architecture which has not been implemented yet. We are
> still looking for feedback to improve the architecture to fit your use-case,
> see [this open issue](https://github.com/backstage/backstage/issues/4078)._
Below you can explore the Search Architecture. Our aim with this architecture is
to support a wide variety of search engines, while providing a simple developer
experience for plugin developers, and a good out-of-the-box experience for
Backstage end-users.
<img data-zoomable src="../../assets/search/architecture.drawio.svg" alt="Search Architecture" />
At a base-level, we want to support the following:
- We aim to enable the capability to search across the entire Backstage
ecosystem by decoupling search from content management.
- We aim to enable the capability to deploy Backstage using any search engine,
by providing an integration and translation layer between the core search
plugin and search engine specific logic that can be extended for different
search engines. We may also introduce the ability to replace the backend API
endpoint with a custom endpoint for simpler customization.
More advanced use-cases we hope to support with this architecture include:
- It should be easy for any plugin to expose new content to search. (e.g. entity
metadata, documentation from TechDocs)
- It should be easy for any plugin to append relevant metadata to existing
content in search. (e.g. location (path) for TechDocs page)
- It should be easy to refine search queries (e.g. ranking, scoring, etc.)
- It should be easy to customize the search UI
- It should be easy to add search functionality to any Backstage plugin or
deployment
@@ -131,6 +131,19 @@ spec:
$text: https://petstore.swagger.io/v2/swagger.json
```
Note that to be able to read from targets that are outside of the normal
integration points such as `github.com`, you'll need to explicitly allow it by
adding an entry in the `backend.reading.allow` list. For example:
```yml
backend:
baseUrl: ...
reading:
allow:
- host: example.com
- host: '*.examples.org'
```
## Common to All Kinds: The Envelope
The root envelope object has the following structure.
@@ -22,7 +22,7 @@ use.
# Example:
metadata:
annotations:
backstage.io/managed-by-location: github:http://github.com/backstage/backstage/catalog-info.yaml
backstage.io/managed-by-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml
```
The value of this annotation is a so called location reference string, that
@@ -30,8 +30,8 @@ points to the source from which the entity was originally fetched. This
annotation is added automatically by the catalog as it fetches the data from a
registered location, and is not meant to normally be written by humans. The
annotation may point to any type of generic location that the catalog supports,
so it cannot be relied on to always be specifically of type `github`, nor that
it even represents a single file. Note also that a single location can be the
so it cannot be relied on to always be specifically of type `url`, nor that it
even represents a single file. Note also that a single location can be the
source of many entities, so it represents a many-to-one relationship.
The format of the value is `<type>:<target>`. Note that the target may also
@@ -40,13 +40,30 @@ expecting a two-item array out of it. The format of the target part is
type-dependent and could conceivably even be an empty string, but the separator
colon is always present.
### backstage.io/managed-by-origin-location
```yaml
# Example:
metadata:
annotations:
backstage.io/managed-by-origin-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml
```
The value of this annotation is a location reference string (see above). It
points to the location, whose registration lead to the creation of the entity.
In most cases, the `backstage.io/managed-by-location` and
`backstage.io/managed-by-origin-location` will be equal. They will be different
if the original location delegates to another location. A common case is, that a
location is registered as `bootstrap:bootstrap` which means that it is part of
the `app-config.yaml` of a Backstage installation.
### backstage.io/techdocs-ref
```yaml
# Example:
metadata:
annotations:
backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git
backstage.io/techdocs-ref: url:https://github.com/backstage/backstage/tree/master
```
The value of this annotation is a location reference string (see above). If this
@@ -86,10 +86,11 @@ follows:
_note_ Currently the templaters that we provide are basically Docker action
containers that are run on top of the skeleton folder. This keeps dependencies
to a minimal for running backstage scaffolder, but you don't /have/ to use
Docker. You could create your own templater that spins up an EC2 instance and
downloads the folder and does everything using an AMI if you want. It's entirely
up to you!
to a minimum for running backstage scaffolder, but you don't _have_ to use
Docker. You can `pip install cookiecutter` to run it locally in your backend.
You could create your own templater that spins up an EC2 instance and downloads
the folder and does everything using an AMI if you want. It's entirely up to
you!
Now it's up to you to implement the `run` function, and then return a
`TemplaterRunResult` which is `{ resultDir: string }`.
+2 -3
View File
@@ -142,12 +142,11 @@ Status of all the features mentioned above.
- Basic setup with techdocs-backend file server as storage.
- Basic setup with cloud storage solution.
**Work in progress 🚧**
- `techdocs-cli` is able to generate docs in CI/CD environment.
- `techdocs-cli` is able to publish docs site to any storage.
**Work in progress 🚧**
**Not implemented yet ❌**
- `techdocs-backend` integration with Backstage access control management.
@@ -0,0 +1,98 @@
---
id: configuring-ci-cd
title: Configuring CI/CD to generate and publish TechDocs sites
# prettier-ignore
description: Configuring CI/CD to generate and publish TechDocs sites to cloud storage
---
In the [Recommended deployment setup](./architecture.md#recommended-deployment),
TechDocs reads the static generated documentation files from a cloud storage
bucket (GCS, AWS S3, etc.). The documentation site is generated on the CI/CD
workflow associated with the repository containing the documentation files. This
document explains the steps needed to generate docs on CI and publish to a cloud
storage using [`techdocs-cli`](https://github.com/backstage/techdocs-cli).
The steps here target all kinds of CI providers (GitHub Actions, CircleCI,
Jenkins, etc.). Specific tools for individual providers will also be made
available here for simplicity (e.g. a GitHub Actions runner, CircleCI orb,
etc.).
A summary of the instructions below looks like this -
```sh
# This is an example script
# Prepare
REPOSITORY_URL='https://github.com/org/repo'
git clone $REPOSITORY_URL
cd repo
# Generate
npx @techdocs/cli generate
# Publish
npx @techdocs/cli publish --publisher-type awsS3 --storage-name <bucket/container> --entity <Namespace/Kind/Name>
```
That's it!
Take a look at
[`techdocs-cli` README](https://github.com/backstage/techdocs-cli) for the
complete command reference, details, and options.
## 1. Setup a workflow
The TechDocs workflow should trigger on CI when any changes are made in the
repository containing the documentation files. You can be specific and configure
the workflow to be triggered only when files inside the `docs/` directory or
`mkdocs.yml` are changed.
## 2. Prepare step
The first step on the CI is to clone your documentation source repository in a
working directory. This is almost always the first step in most CI workflows.
On GitHub Actions, you can add a step
[`- uses: actions@checkout@v2`](https://github.com/actions/checkout).
On CircleCI, you can add a special
[`checkout`](https://circleci.com/docs/2.0/configuration-reference/#checkout)
step.
Eventually we are trying to do a `git clone <https://path/to/docs-repository/>`.
## 3. Generate step
Install [`npx`](https://www.npmjs.com/package/npx) to use it for running
`techdocs-cli`. Or you can install using `npm install -g @techdocs/cli`.
We are going to use the
[`techdocs-cli generate`](https://github.com/backstage/techdocs-cli#generate-techdocs-site-from-a-documentation-project)
command in this step.
```sh
npx @techdocs/cli generate --no-docker --source-dir PATH_TO_REPO --output-dir ./site
```
`PATH_TO_REPO` should be the location in the file path where the prepare step
above clones the repository.
## 4. Publish step
Depending on your cloud storage provider (AWS, Google Cloud, or Azure), set the
necessary authentication environment variables.
- [Google Cloud authentication](https://cloud.google.com/storage/docs/authentication#libauth)
- [AWS authentication](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html)
And then run the
[`techdocs-cli publish`](https://github.com/backstage/techdocs-cli#publish-generated-techdocs-sites)
command.
```sh
npx @techdocs/cli publish --publisher-type <awsS3|googleGcs> --storage-name <bucket/container> --entity <namespace/kind/name> --directory ./site
```
The updated TechDocs site built in this workflow is now ready to be served by
the TechDocs plugin in your Backstage app.
+16
View File
@@ -74,6 +74,22 @@ those plugins in your backend. This is because the transformation of backend
module tree stops whenever a non-local package is encountered, and from that
point node will `require` packages directly for that entire module subtree.
Type checking can also have issues when linking in external packages, since the
linked in packages will use the types in the external project and dependency
version mismatches between the two projects may cause errors. To fix any of
those errors you need to sync versions of the dependencies in the two projects.
A simple way to do this can be to copy over `yarn.lock` from the external
project and run `yarn install`, although this is quite intrusive and can cause
other issues in existing projects, so use this method with care. It can often be
best to simply ignore the type errors, as app serving will work just fine
anyway.
Another issue with type checking is that the incremental type cache doesn't
invalidate correctly for the linked in packages, causing type checking to not
reflect changes made to types. You can work around this by either setting
`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the
types cache folder `dist-types` before running `yarn tsc`.
### Troubleshooting
The create app command doesn't always work as expected, this is a collection of
+1 -1
View File
@@ -1,5 +1,5 @@
---
id: Glossary
id: glossary
title: Backstage Glossary
# prettier-ignore
description: List of all the terms, abbreviations, and phrases used in Backstage, together with their explanations.
-11
View File
@@ -185,17 +185,6 @@ separate Docker images.
![Boxes around the architecture to indicate how it is containerised](../assets/architecture-overview/containerised.png)
The frontend container can be built with a provided command.
```bash
yarn install
yarn tsc
yarn run docker-build:app
```
Running this will simply generate a Docker container containing the contents of
the UIs `dist` directory.
The backend container can be built by running the following command:
```bash
+82
View File
@@ -0,0 +1,82 @@
# Using GitHub Apps for Backend Authentication
Backstage can be configured to use GitHub Apps for backend authentication. This
comes with advantages such as higher rate limits and that Backstage can act as
an application instead of a user or bot account.
It also provides a much clearer and better authorization model as a opposed to
the OAuth apps and their respective scopes.
## Caveats
- It's not possible to have multiple Backstage GitHub Apps installed in the same
GitHub organization, to be handled by Backstage. We currently don't check
through all the registered GitHub Apps to see which ones are installed for a
particular repository. We only respect global Organization installs right now.
- App permissions is not managed by Backstage. They're created with some simple
default permissions which you are free to change as you need, but you will
need to update them in the GitHub web console, not in Backstage right now. The
permissions that are defaulted are `metadata:read` and `contents:read`.
- The created GitHub App is private by default, this is most likely what you
want for github.com but it's recommended to make your application public for
GitHub Enterprise in order to share application across your GHE organizations.
A GitHub app created with `backstage-cli create-github-app` will have read
access by default. You have to manually update the GitHub App settings in GitHub
to grant the app more permissions if needed.
### Using the CLI (public GitHub only)
You can use the `backstage-cli` to create GitHub App' using a manifest file that
we provide. This gives us a way to automate some of the work required to create
a GitHub app.
You can read more about the `backstage-cli create-github-app` method
[here](../cli/commands.md#create-github-app)
Once you've gone through the CLI command, it should produce a `yaml` file in the
root of the project which you can then use as an `include` in your
`app-config.yaml`. You can go ahead and skip to
[here](#including-in-integrations-config) if you've got to this part.
### GitHub Enterprise
You have to create the GitHub Application manually using these
[instructions](https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app)
as GitHub Enterprise does not support creation of apps from manifests.
Once the application is created you have to generate a private key for the
application it in a `yaml` file.
The yaml file must include the following information. Please note that the
indentation for the `privateKey` is required.
```yaml
appId: 1
clientId: client id
clientSecret: client secret
webhookSecret: webhook secret
privateKey: |
-----BEGIN RSA PRIVATE KEY-----
...Key content...
-----END RSA PRIVATE KEY-----
```
### Including in Integrations Config
Once the credentials are stored in a yaml file generated by `create-github-app`
or manually by following the [GitHub Enterprise](#gitHub-enterprise)
instructions, they can be included in the `app-config.yaml` under the
`integrations` section.
Please note that the credentials file is highly sensitive and should NOT be
checked into any kind of version control. Instead use your preferred secure
method of distributing secrets.
```yaml
integrations:
github:
- host: github.com
apps:
- $include: example-backstage-app-credentials.yaml
```
-9
View File
@@ -54,13 +54,4 @@ addRoute(
Component: ComponentType<any>,
options?: RouteOptions,
): void;
/**
* @deprecated See the `addRoute` method
*/
registerRoute(
path: RoutePath,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
```
-9
View File
@@ -15,15 +15,6 @@ addRoute(
Component: ComponentType<any>,
options?: RouteOptions,
): void;
/**
* @deprecated See the `addRoute` method
*/
registerRoute(
path: RoutePath,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
```
## RouteRef
-4
View File
@@ -32,10 +32,6 @@ the code.
better control over our `yarn.lock` file and hopefully avoid problems due to
yarn versioning differences.
- [`docker/`](https://github.com/backstage/backstage/tree/master/docker) - Files
related to our root Dockerfile. We are planning to refactor this, so expect
this folder to be moved in the future.
- [`contrib/`](https://github.com/backstage/backstage/tree/master/contrib) -
Collection of examples or resources provided by the community. We really
appreciate contributions in here and encourage them being kept up to date.
@@ -0,0 +1,15 @@
const React = require('react');
const Redirect = require('../../../../../core/Redirect.js');
const siteConfig = require(process.cwd() + '/siteConfig.js');
function Docs() {
return (
<Redirect
redirect="/docs/features/software-catalog/software-catalog-overview"
config={siteConfig}
/>
);
}
module.exports = Docs;
@@ -0,0 +1,15 @@
const React = require('react');
const Redirect = require('../../../../../core/Redirect.js');
const siteConfig = require(process.cwd() + '/siteConfig.js');
function Docs() {
return (
<Redirect
redirect="/docs/features/software-templates/software-templates-index"
config={siteConfig}
/>
);
}
module.exports = Docs;
@@ -0,0 +1,15 @@
const React = require('react');
const Redirect = require('../../../../../core/Redirect.js');
const siteConfig = require(process.cwd() + '/siteConfig.js');
function Docs() {
return (
<Redirect
redirect="/docs/features/techdocs/techdocs-overview"
config={siteConfig}
/>
);
}
module.exports = Docs;
+11 -1
View File
@@ -72,6 +72,14 @@
"features/software-templates/extending/extending-preparer"
]
},
{
"type": "subcategory",
"label": "Backstage Search",
"ids": [
"features/search/search-overview",
"features/search/architecture"
]
},
{
"type": "subcategory",
"label": "TechDocs",
@@ -83,6 +91,7 @@
"features/techdocs/creating-and-publishing",
"features/techdocs/configuration",
"features/techdocs/using-cloud-storage",
"features/techdocs/configuring-ci-cd",
"features/techdocs/how-to-guides",
"features/techdocs/troubleshooting",
"features/techdocs/faqs"
@@ -174,7 +183,8 @@
"architecture-decisions/adrs-adr006",
"architecture-decisions/adrs-adr007",
"architecture-decisions/adrs-adr008",
"architecture-decisions/adrs-adr009"
"architecture-decisions/adrs-adr009",
"architecture-decisions/adrs-adr010"
],
"Contribute": ["../CONTRIBUTING"],
"Support": ["support/support", "support/project-structure"],
+4
View File
@@ -48,6 +48,9 @@ nav:
- Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md'
- Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md'
- Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md'
- Backstage Search:
- Overview: 'features/search/README.md'
- Architecture: 'features/search/architecture.md'
- TechDocs:
- Overview: 'features/techdocs/README.md'
- Getting Started: 'features/techdocs/getting-started.md'
@@ -115,6 +118,7 @@ nav:
- ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md'
- ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md'
- ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md'
- ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md'
- Contribute: '../CONTRIBUTING.md'
- Support:
- 'support/support.md'
-1
View File
@@ -20,7 +20,6 @@
"lint:all": "lerna run lint --",
"lint:type-deps": "node scripts/check-type-dependencies.js",
"docgen": "lerna run docgen",
"docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage",
"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",
+10 -11
View File
@@ -20,6 +20,7 @@ import {
OAuthRequestDialog,
SignInPage,
createRouteRef,
FlatRoutes,
} from '@backstage/core';
import React from 'react';
import Root from './components/Root';
@@ -35,7 +36,7 @@ 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 { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { Route, Routes, Navigate } from 'react-router';
import { Route, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
@@ -65,31 +66,31 @@ const catalogRouteRef = createRouteRef({
title: 'Service Catalog',
});
const AppRoutes = () => (
<Routes>
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Route
path="/catalog-import/*"
path="/catalog-import"
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route
path={`${catalogRouteRef.path}/*`}
path={`${catalogRouteRef.path}`}
element={<CatalogRouter EntityPage={EntityPage} />}
/>
<Route path="/docs/*" element={<DocsRouter />} />
<Route path="/docs" element={<DocsRouter />} />
<Route
path="/tech-radar"
element={<TechRadarRouter width={1500} height={800} />}
/>
<Route path="/graphiql" element={<GraphiQLRouter />} />
<Route path="/lighthouse/*" element={<LighthouseRouter />} />
<Route path="/lighthouse" element={<LighthouseRouter />} />
<Route
path="/register-component"
element={<RegisterComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route path="/settings" element={<SettingsRouter />} />
{...deprecatedAppRoutes}
</Routes>
</FlatRoutes>
);
const App = () => (
@@ -97,9 +98,7 @@ const App = () => (
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>
<AppRoutes />
</Root>
<Root>{routes}</Root>
</AppRouter>
</AppProvider>
);
+30 -102
View File
@@ -41,31 +41,16 @@ export interface Config {
https?:
| true
| {
/**
* Certificate configuration or parameters for generating a self-signed certificate
*
* Setting parameters for self-signed certificates is deprecated and will be removed in
* the future, set `backend.https = true` instead.
*/
certificate?:
| {
/** Algorithm to use to generate a self-signed certificate */
algorithm?: string;
keySize?: number;
days?: number;
attributes: {
commonName: string;
};
}
| {
/** 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;
};
/** Certificate configuration */
certificate?: {
/** 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 */
@@ -94,6 +79,26 @@ export interface Config {
optionsSuccessStatus?: number;
};
/**
* Configuration related to URL reading, used for example for reading catalog info
* files, scaffolder templates, and techdocs content.
*/
reading?: {
/**
* A list of targets to allow outgoing requests to. Users will be able to make
* requests on behalf of the backend to the targets that are allowed by this list.
*/
allow?: Array<{
/**
* A host to allow outgoing requests to, being either a full host or
* a subdomain wildcard pattern with a leading `*`. For example `example.com`
* and `*.example.com` are valid values, `prod.*.example.com` is not.
* The host may also contain a port, for example `example.com:8080`.
*/
host: string;
}>;
};
/**
* Content Security Policy options.
*
@@ -104,81 +109,4 @@ export interface Config {
*/
csp?: { [policyId: string]: string[] | false };
};
/** 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;
}>;
};
}
+5
View File
@@ -75,3 +75,8 @@ export class NotFoundError extends CustomErrorBase {}
* resource.
*/
export class ConflictError extends CustomErrorBase {}
/**
* The requested resource has not changed since last request.
*/
export class NotModifiedError extends CustomErrorBase {}
@@ -72,6 +72,9 @@ describe('errorHandler', () => {
it('handles well-known error classes', async () => {
const app = express();
app.use('/NotModifiedError', () => {
throw new errors.NotModifiedError();
});
app.use('/InputError', () => {
throw new errors.InputError();
});
@@ -90,6 +93,7 @@ describe('errorHandler', () => {
app.use(errorHandler());
const r = request(app);
expect((await r.get('/NotModifiedError')).status).toBe(304);
expect((await r.get('/InputError')).status).toBe(400);
expect((await r.get('/AuthenticationError')).status).toBe(401);
expect((await r.get('/NotAllowedError')).status).toBe(403);
@@ -101,6 +101,8 @@ function getStatusCode(error: Error): number {
// Handle well-known error types
switch (error.name) {
case errors.NotModifiedError.name:
return 304;
case errors.InputError.name:
return 400;
case errors.AuthenticationError.name:
@@ -23,6 +23,7 @@ import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
import { msw } from '@backstage/test-utils';
import { ReadTreeResponseFactory } from './tree';
import { NotModifiedError } from '../errors';
const logger = getVoidLogger();
@@ -139,7 +140,12 @@ describe('AzureUrlReader', () => {
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'repo.zip'),
path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'),
);
const processor = new AzureUrlReader(
{ host: 'dev.azure.com' },
{ treeResponseFactory },
);
beforeEach(() => {
@@ -153,24 +159,70 @@ describe('AzureUrlReader', () => {
ctx.body(repoBuffer),
),
),
rest.get(
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20commits?view=azure-devops-rest-6.0#on-a-branch
'https://dev.azure.com/organization/project/_apis/git/repositories/repository/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
count: 2,
value: [
{
commitId: '123abc2',
comment: 'second commit',
},
{
commitId: '123abc1',
comment: 'first commit',
},
],
}),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const processor = new AzureUrlReader(
{ host: 'dev.azure.com' },
{ treeResponseFactory },
);
const response = await processor.readTree(
'https://dev.azure.com/organization/project/_git/repository',
);
expect(response.etag).toBe('123abc2');
const files = await response.files();
expect(files.length).toBe(2);
const mkDocsFile = await files[1].content();
const indexMarkdownFile = await files[0].content();
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('throws a NotModifiedError when given a etag in options', async () => {
const fnAzure = async () => {
await processor.readTree(
'https://dev.azure.com/organization/project/_git/repository',
{ etag: '123abc2' },
);
};
await expect(fnAzure).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await processor.readTree(
'https://dev.azure.com/organization/project/_git/repository',
{ etag: 'outdated123abc' },
);
expect(response.etag).toBe('123abc2');
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');
@@ -20,10 +20,11 @@ import {
getAzureFileFetchUrl,
getAzureDownloadUrl,
getAzureRequestOptions,
getAzureCommitsUrl,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import { Readable } from 'stream';
import { NotFoundError } from '../errors';
import { NotFoundError, NotModifiedError } from '../errors';
import {
ReaderFactory,
ReadTreeOptions,
@@ -75,20 +76,40 @@ export class AzureUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const response = await fetch(
getAzureDownloadUrl(url),
getAzureRequestOptions(this.options, { Accept: 'application/zip' }),
// Get latest commit SHA
const commitsAzureResponse = await fetch(
getAzureCommitsUrl(url),
getAzureRequestOptions(this.options),
);
if (!response.ok) {
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!commitsAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`;
if (commitsAzureResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return this.deps.treeResponseFactory.fromZipArchive({
stream: (response.body as unknown) as Readable,
const commitSha = (await commitsAzureResponse.json()).value[0].commitId;
if (options?.etag && options.etag === commitSha) {
throw new NotModifiedError();
}
const archiveAzureResponse = await fetch(
getAzureDownloadUrl(url),
getAzureRequestOptions(this.options, { Accept: 'application/zip' }),
);
if (!archiveAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`;
if (archiveAzureResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromZipArchive({
stream: (archiveAzureResponse.body as unknown) as Readable,
etag: commitSha,
filter: options?.filter,
});
}
@@ -20,6 +20,7 @@ import fs from 'fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'path';
import { NotModifiedError } from '../errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { ReadTreeResponseFactory } from './tree';
@@ -27,15 +28,24 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const bitbucketProcessor = new BitbucketUrlReader(
{ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' },
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
{
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
},
{ treeResponseFactory },
);
describe('BitbucketUrlReader', () => {
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new BitbucketUrlReader(
{ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' },
{ treeResponseFactory },
);
await expect(
processor.read('https://not.bitbucket.com/apa'),
bitbucketProcessor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
@@ -55,8 +65,30 @@ describe('BitbucketUrlReader', () => {
),
);
it('returns the wanted files from an archive', async () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(
'src',
'reading',
'__fixtures__',
'bitbucket-server-repo.zip',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.zip',
(_, res, ctx) =>
@@ -76,17 +108,35 @@ describe('BitbucketUrlReader', () => {
}),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(privateBitbucketRepoBuffer),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
const processor = new BitbucketUrlReader(
{ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' },
{ treeResponseFactory },
);
const response = await processor.readTree(
it('returns the wanted files from an archive', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(2);
@@ -98,38 +148,12 @@ describe('BitbucketUrlReader', () => {
});
it('uses private bitbucket host', async () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(
'src',
'reading',
'__fixtures__',
'bitbucket-server-repo.zip',
),
);
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(privateBitbucketRepoBuffer),
),
),
);
const processor = new BitbucketUrlReader(
{
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
},
{ treeResponseFactory },
);
const response = await processor.readTree(
const response = await hostedBitbucketProcessor.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
@@ -139,37 +163,12 @@ describe('BitbucketUrlReader', () => {
});
it('returns the wanted files from an archive with a subpath', async () => {
worker.use(
rest.get(
'https://bitbucket.org/backstage/mock/get/master.zip',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
const processor = new BitbucketUrlReader(
{ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' },
{ treeResponseFactory },
);
const response = await processor.readTree(
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
@@ -177,5 +176,25 @@ describe('BitbucketUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: '12ab34cd56ef' },
);
};
await expect(fnBitbucket).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: 'outdatedetag123abc' },
);
expect(response.etag).toBe('12ab34cd56ef');
});
});
});
@@ -23,9 +23,9 @@ import {
readBitbucketIntegrationConfigs,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUri from 'git-url-parse';
import parseGitUrl from 'git-url-parse';
import { Readable } from 'stream';
import { NotFoundError } from '../errors';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import {
ReaderFactory,
@@ -101,19 +101,25 @@ export class BitbucketUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const gitUrl: parseGitUri.GitUrl = parseGitUri(url);
const { name: repoName, owner: project, resource, filepath } = gitUrl;
const { name: repoName, owner: project, resource, filepath } = parseGitUrl(
url,
);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const isHosted = resource === 'bitbucket.org';
const downloadUrl = await getBitbucketDownloadUrl(url, this.config);
const response = await fetch(
const archiveBitbucketResponse = await fetch(
downloadUrl,
getBitbucketRequestOptions(this.config),
);
if (!response.ok) {
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!archiveBitbucketResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`;
if (archiveBitbucketResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
@@ -121,13 +127,13 @@ export class BitbucketUrlReader implements UrlReader {
let folderPath = `${project}-${repoName}`;
if (isHosted) {
const lastCommitShortHash = await this.getLastCommitShortHash(url);
folderPath = `${project}-${repoName}-${lastCommitShortHash}`;
}
return this.treeResponseFactory.fromZipArchive({
stream: (response.body as unknown) as Readable,
return await this.treeResponseFactory.fromZipArchive({
stream: (archiveBitbucketResponse.body as unknown) as Readable,
path: `${folderPath}/${filepath}`,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
@@ -141,8 +147,8 @@ export class BitbucketUrlReader implements UrlReader {
return `bitbucket{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<String> {
const { name: repoName, owner: project, ref } = parseGitUri(url);
private async getLastCommitShortHash(url: string): Promise<string> {
const { name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
@@ -0,0 +1,73 @@
/*
* 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 { msw } from '@backstage/test-utils';
import { setupServer } from 'msw/node';
import { getVoidLogger } from '../logging';
import { FetchUrlReader } from './FetchUrlReader';
import { ReadTreeResponseFactory } from './tree';
describe('FetchUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
jest.clearAllMocks();
});
it('factory should create a single entry with a predicate that matches config', async () => {
const entries = FetchUrlReader.factory({
config: new ConfigReader({
backend: {
reading: {
allow: [
{ host: 'example.com' },
{ host: 'example.com:700' },
{ host: '*.examples.org' },
{ host: '*.examples.org:700' },
],
},
},
}),
logger: getVoidLogger(),
treeResponseFactory: ReadTreeResponseFactory.create({
config: new ConfigReader({}),
}),
});
expect(entries.length).toBe(1);
const [{ predicate }] = entries;
expect(predicate(new URL('https://example.com/test'))).toBe(true);
expect(predicate(new URL('https://a.example.com/test'))).toBe(false);
expect(predicate(new URL('https://example.com:600/test'))).toBe(false);
expect(predicate(new URL('https://a.example.com:600/test'))).toBe(false);
expect(predicate(new URL('https://example.com:700/test'))).toBe(true);
expect(predicate(new URL('https://a.example.com:700/test'))).toBe(false);
expect(predicate(new URL('https://other.com/test'))).toBe(false);
expect(predicate(new URL('https://examples.org/test'))).toBe(false);
expect(predicate(new URL('https://a.examples.org/test'))).toBe(true);
expect(predicate(new URL('https://a.b.examples.org/test'))).toBe(true);
expect(predicate(new URL('https://examples.org:600/test'))).toBe(false);
expect(predicate(new URL('https://a.examples.org:600/test'))).toBe(false);
expect(predicate(new URL('https://a.b.examples.org:600/test'))).toBe(false);
expect(predicate(new URL('https://examples.org:700/test'))).toBe(false);
expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true);
expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true);
});
});
@@ -16,12 +16,39 @@
import fetch from 'cross-fetch';
import { NotFoundError } from '../errors';
import { ReadTreeResponse, UrlReader } from './types';
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
/**
* A UrlReader that does a plain fetch of the URL.
*/
export class FetchUrlReader implements UrlReader {
/**
* The factory creates a single reader that will be used for reading any URL that's listed
* in configuration at `backend.reading.allow`. The allow list contains a list of objects describing
* targets to allow, containing the following fields:
*
* `host`:
* Either full hostnames to match, or subdomain wildcard matchers with a leading `*`.
* For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not.
*/
static factory: ReaderFactory = ({ config }) => {
const predicates =
config
.getOptionalConfigArray('backend.reading.allow')
?.map(allowConfig => {
const host = allowConfig.getString('host');
if (host.startsWith('*.')) {
const suffix = host.slice(1);
return (url: URL) => url.host.endsWith(suffix);
}
return (url: URL) => url.host === host;
}) ?? [];
const reader = new FetchUrlReader();
const predicate = (url: URL) => predicates.some(p => p(url));
return [{ reader, predicate }];
};
async read(url: string): Promise<Buffer> {
let response: Response;
try {
@@ -15,11 +15,13 @@
*/
import { ConfigReader } from '@backstage/config';
import { GithubCredentialsProvider } 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 { NotFoundError, NotModifiedError } from '../errors';
import { GithubUrlReader } from './GithubUrlReader';
import { ReadTreeResponseFactory } from './tree';
@@ -27,37 +29,138 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const mockCredentialsProvider = ({
getCredentials: jest.fn().mockResolvedValue({ headers: {} }),
} as unknown) as GithubCredentialsProvider;
const githubProcessor = new GithubUrlReader(
{
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
},
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
);
const gheProcessor = new GithubUrlReader(
{
host: 'ghe.github.com',
apiBaseUrl: 'https://ghe.github.com/api/v3',
},
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
);
describe('GithubUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
jest.clearAllMocks();
});
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new GithubUrlReader(
{
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
},
{ treeResponseFactory },
);
await expect(
processor.read('https://not.github.com/apa'),
githubProcessor.read('https://not.github.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path',
);
});
});
describe('read', () => {
it('should use the headers from the credentials provider to the fetch request when doing read', async () => {
expect.assertions(2);
const mockHeaders = {
Authorization: 'bearer blah',
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
headers: mockHeaders,
});
worker.use(
rest.get(
'https://api.github.com/repos/backstage/mock/tree/contents/?ref=main',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockHeaders.Authorization,
);
expect(req.headers.get('otherheader')).toBe(
mockHeaders.otherheader,
);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/x-gzip'),
ctx.body('foo'),
);
},
),
);
await githubProcessor.read(
'https://github.com/backstage/mock/tree/blob/main',
);
});
});
describe('readTree', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'repo.tar.gz'),
path.resolve(
'src',
'reading',
'__fixtures__',
'backstage-mock-etag123.tar.gz',
),
);
const reposGithubApiResponse = {
id: '123',
full_name: 'backstage/mock',
default_branch: 'main',
branches_url:
'https://api.github.com/repos/backstage/mock/branches{/branch}',
archive_url:
'https://api.github.com/repos/backstage/mock/{archive_format}{/ref}',
};
const reposGheApiResponse = {
...reposGithubApiResponse,
branches_url:
'https://ghe.github.com/api/v3/repos/backstage/mock/branches{/branch}',
archive_url:
'https://ghe.github.com/api/v3/repos/backstage/mock/{archive_format}{/ref}',
};
const branchesApiResponse = {
name: 'main',
commit: {
sha: 'etag123abc',
},
};
beforeEach(() => {
worker.use(
rest.get('https://api.github.com/repos/backstage/mock', (_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(reposGithubApiResponse),
),
),
rest.get(
'https://github.com/backstage/mock/archive/repo.tar.gz',
'https://api.github.com/repos/backstage/mock/branches/main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(branchesApiResponse),
),
),
rest.get(
'https://api.github.com/repos/backstage/mock/tarball/etag123abc',
(_, res, ctx) =>
res(
ctx.status(200),
@@ -65,21 +168,46 @@ describe('GithubUrlReader', () => {
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.github.com/repos/backstage/mock/branches/branchDoesNotExist',
(_, res, ctx) => res(ctx.status(404)),
),
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/x-gzip'),
ctx.body(repoBuffer),
),
),
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(reposGheApiResponse),
),
),
rest.get(
'https://ghe.github.com/api/v3/repos/backstage/mock/branches/main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(branchesApiResponse),
),
),
);
});
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 githubProcessor.readTree(
'https://github.com/backstage/mock/tree/main',
);
const response = await processor.readTree(
'https://github.com/backstage/mock/tree/repo',
);
expect(response.etag).toBe('etag123abc');
const files = await response.files();
@@ -91,30 +219,45 @@ describe('GithubUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('includes the subdomain in the github url', async () => {
worker.resetHandlers();
it('should use the headers from the credentials provider to the fetch request', async () => {
expect.assertions(2);
const mockHeaders = {
Authorization: 'bearer blah',
otherheader: 'something',
};
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
headers: mockHeaders,
});
worker.use(
rest.get(
'https://ghe.github.com/backstage/mock/archive/repo.tar.gz',
(_, res, ctx) =>
res(
'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockHeaders.Authorization,
);
expect(req.headers.get('otherheader')).toBe(
mockHeaders.otherheader,
);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/x-gzip'),
ctx.body(repoBuffer),
),
);
},
),
);
const processor = new GithubUrlReader(
{
host: 'ghe.github.com',
apiBaseUrl: 'https://api.github.com',
},
{ treeResponseFactory },
await gheProcessor.readTree(
'https://ghe.github.com/backstage/mock/tree/main',
);
});
const response = await processor.readTree(
'https://ghe.github.com/backstage/mock/tree/repo/docs',
it('includes the subdomain in the github url', async () => {
const response = await gheProcessor.readTree(
'https://ghe.github.com/backstage/mock/tree/main/docs',
);
const files = await response.files();
@@ -125,33 +268,9 @@ describe('GithubUrlReader', () => {
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 response = await githubProcessor.readTree(
'https://github.com/backstage/mock/tree/main/docs',
);
const files = await response.files();
@@ -161,5 +280,51 @@ describe('GithubUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnGithub = async () => {
await githubProcessor.readTree('https://github.com/backstage/mock', {
etag: 'etag123abc',
});
};
const fnGhe = async () => {
await gheProcessor.readTree(
'https://ghe.github.com/backstage/mock/tree/main/docs',
{
etag: 'etag123abc',
},
);
};
await expect(fnGithub).rejects.toThrow(NotModifiedError);
await expect(fnGhe).rejects.toThrow(NotModifiedError);
});
it('should not throw error when given an outdated etag in options', async () => {
const response = await githubProcessor.readTree(
'https://github.com/backstage/mock/tree/main',
{
etag: 'outdatedetag123abc',
},
);
expect((await response.files()).length).toBe(2);
});
it('should detect the default branch', async () => {
const response = await githubProcessor.readTree(
'https://github.com/backstage/mock',
);
expect((await response.files()).length).toBe(2);
});
it('should throw error on missing branch', async () => {
const fnGithub = async () => {
await githubProcessor.readTree(
'https://github.com/backstage/mock/tree/branchDoesNotExist',
);
};
await expect(fnGithub).rejects.toThrow(NotFoundError);
});
});
});
@@ -18,12 +18,12 @@ import {
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
getGitHubFileFetchUrl,
getGitHubRequestOptions,
GithubCredentialsProvider,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUri from 'git-url-parse';
import parseGitUrl from 'git-url-parse';
import { Readable } from 'stream';
import { InputError, NotFoundError } from '../errors';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import {
ReaderFactory,
@@ -42,7 +42,11 @@ export class GithubUrlReader implements UrlReader {
config.getOptionalConfigArray('integrations.github') ?? [],
);
return configs.map(provider => {
const reader = new GithubUrlReader(provider, { treeResponseFactory });
const credentialsProvider = GithubCredentialsProvider.create(provider);
const reader = new GithubUrlReader(provider, {
treeResponseFactory,
credentialsProvider,
});
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
@@ -50,7 +54,10 @@ export class GithubUrlReader implements UrlReader {
constructor(
private readonly config: GitHubIntegrationConfig,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
private readonly deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: GithubCredentialsProvider;
},
) {
if (!config.apiBaseUrl && !config.rawBaseUrl) {
throw new Error(
@@ -61,11 +68,17 @@ export class GithubUrlReader implements UrlReader {
async read(url: string): Promise<Buffer> {
const ghUrl = getGitHubFileFetchUrl(url, this.config);
const options = getGitHubRequestOptions(this.config);
const { headers } = await this.deps.credentialsProvider.getCredentials({
url,
});
let response: Response;
try {
response = await fetch(ghUrl.toString(), options);
response = await fetch(ghUrl.toString(), {
headers: {
...headers,
Accept: 'application/vnd.github.v3.raw',
},
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -85,44 +98,93 @@ export class GithubUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const {
name: repoName,
ref,
protocol,
resource,
full_name,
filepath,
} = parseGitUri(url);
const { ref, filepath, full_name } = parseGitUrl(url);
// Caveat: The ref will totally be incorrect if the branch name includes a /
// Thus, readTree can not work on url containing branch name that has a /
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',
);
}
const { headers } = await this.deps.credentialsProvider.getCredentials({
url,
});
// TODO(Rugvip): use API to fetch URL instead
const response = await fetch(
new URL(
`${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`,
).toString(),
getGitHubRequestOptions(this.config),
// Get GitHub API urls for the repository
const repoGitHubResponse = await fetch(
new URL(`${this.config.apiBaseUrl}/repos/${full_name}`).toString(),
{
headers,
},
);
if (!response.ok) {
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!repoGitHubResponse.ok) {
const message = `Failed to read tree (repository) from ${url}, ${repoGitHubResponse.status} ${repoGitHubResponse.statusText}`;
if (repoGitHubResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const path = `${repoName}-${ref}/${filepath}`;
const repoResponseJson = await repoGitHubResponse.json();
return this.deps.treeResponseFactory.fromTarArchive({
// ref is an empty string if no branch is set in provided url to readTree.
// Use GitHub API to get the default branch of the repository.
const branch = ref || repoResponseJson.default_branch;
const branchesApiUrl = repoResponseJson.branches_url;
const archiveApiUrl = repoResponseJson.archive_url;
// Fetch the latest commit in the provided or default branch to compare against
// the provided sha.
const branchGitHubResponse = await fetch(
// branchesApiUrl looks like "https://api.github.com/repos/owner/repo/branches{/branch}"
branchesApiUrl.replace('{/branch}', `/${branch}`),
{
headers,
},
);
if (!branchGitHubResponse.ok) {
const message = `Failed to read tree (branch) from ${url}, ${branchGitHubResponse.status} ${branchGitHubResponse.statusText}`;
if (branchGitHubResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commitSha = (await branchGitHubResponse.json()).commit.sha;
if (options?.etag && options.etag === commitSha) {
throw new NotModifiedError();
}
const archive = await fetch(
// archiveApiUrl looks like "https://api.github.com/repos/owner/repo/{archive_format}{/ref}"
archiveApiUrl
.replace('{archive_format}', 'tarball')
.replace('{/ref}', `/${commitSha}`),
{ headers },
);
if (!archive.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archive.status} ${archive.statusText}`;
if (archive.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
// Note that repoResponseJson.full_name must be used over full_name because the path
// is case sensitive and full_name may not be inq the correct case.
// TODO(OrkoHunter): The directory name inside the tarball should be retrieved from the tar
// instead of being constructed here. Same goes for GitLab, Bitbucket and Azure.
const extractedDirName = `${repoResponseJson.full_name.replace(
'/',
'-',
)}-${commitSha.substr(0, 7)}`;
// The path includes the name of the directory inside the tarball and a sub path
// if requested in readTree.
const path = `${extractedDirName}/${filepath}`;
return await this.deps.treeResponseFactory.fromTarArchive({
// TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
// to stick to using that in exclusively backend code.
stream: (response.body as unknown) as Readable,
stream: (archive.body as unknown) as Readable,
path,
etag: commitSha,
filter: options?.filter,
});
}
@@ -23,6 +23,7 @@ import path from 'path';
import { getVoidLogger } from '../logging';
import { GitlabUrlReader } from './GitlabUrlReader';
import { ReadTreeResponseFactory } from './tree';
import { NotModifiedError, NotFoundError } from '../errors';
const logger = getVoidLogger();
@@ -30,6 +31,22 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const gitlabProcessor = new GitlabUrlReader(
{
host: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
},
{ treeResponseFactory },
);
const hostedGitlabProcessor = new GitlabUrlReader(
{
host: 'gitlab.mycompany.com',
apiBaseUrl: 'https://gitlab.mycompany.com/api/v4',
},
{ treeResponseFactory },
);
describe('GitlabUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
@@ -136,39 +153,94 @@ describe('GitlabUrlReader', () => {
});
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'repo.zip'),
const archiveBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'),
);
const projectGitlabApiResponse = {
id: 11111111,
default_branch: 'main',
};
const branchGitlabApiResponse = {
commit: {
id: 'sha123abc',
},
};
beforeEach(() => {
worker.use(
rest.get(
'https://gitlab.com/backstage/mock/-/archive/repo/mock-repo.zip',
'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(repoBuffer),
ctx.body(archiveBuffer),
),
),
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(projectGitlabApiResponse),
),
),
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(branchGitlabApiResponse),
),
),
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist',
(_, res, ctx) => res(ctx.status(404)),
),
rest.get(
'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(projectGitlabApiResponse),
),
),
rest.get(
'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(branchGitlabApiResponse),
),
),
rest.get(
'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(archiveBuffer),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const processor = new GitlabUrlReader(
{ host: 'gitlab.com' },
{ treeResponseFactory },
);
const response = await processor.readTree(
'https://gitlab.com/backstage/mock/tree/repo',
const response = await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/main',
);
const files = await response.files();
expect(files.length).toBe(2);
const indexMarkdownFile = await files[0].content();
const mkDocsFile = await files[1].content();
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');
@@ -177,23 +249,18 @@ describe('GitlabUrlReader', () => {
it('returns the wanted files from hosted gitlab', async () => {
worker.use(
rest.get(
'https://git.mycompany.com/backstage/mock/-/archive/repo/mock-repo.zip',
'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(repoBuffer),
ctx.body(archiveBuffer),
),
),
);
const processor = new GitlabUrlReader(
{ host: 'git.mycompany.com' },
{ treeResponseFactory },
);
const response = await processor.readTree(
'https://git.mycompany.com/backstage/mock/tree/repo/docs',
const response = await hostedGitlabProcessor.readTree(
'https://gitlab.mycompany.com/backstage/mock/tree/main/docs',
);
const files = await response.files();
@@ -204,27 +271,9 @@ describe('GitlabUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('throws an error when branch is not specified', async () => {
const processor = new GitlabUrlReader(
{ host: 'gitlab.com' },
{ treeResponseFactory },
);
await expect(
processor.readTree('https://gitlab.com/backstage/mock'),
).rejects.toThrow(
'GitLab URL must contain a branch to be able to fetch its tree',
);
});
it('returns the wanted files from an archive with a subpath', async () => {
const processor = new GitlabUrlReader(
{ host: 'gitlab.com' },
{ treeResponseFactory },
);
const response = await processor.readTree(
'https://gitlab.com/backstage/mock/tree/repo/docs',
const response = await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/main/docs',
);
const files = await response.files();
@@ -234,5 +283,51 @@ describe('GitlabUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnGitlab = async () => {
await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', {
etag: 'sha123abc',
});
};
const fnHostedGitlab = async () => {
await hostedGitlabProcessor.readTree(
'https://gitlab.mycompany.com/backstage/mock',
{
etag: 'sha123abc',
},
);
};
await expect(fnGitlab).rejects.toThrow(NotModifiedError);
await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError);
});
it('should not throw error when given an outdated etag in options', async () => {
const response = await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/main',
{
etag: 'outdatedsha123abc',
},
);
expect((await response.files()).length).toBe(2);
});
it('should detect the default branch', async () => {
const response = await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock',
);
expect((await response.files()).length).toBe(2);
});
it('should throw error on missing branch', async () => {
const fnGithub = async () => {
await gitlabProcessor.readTree(
'https://gitlab.com/backstage/mock/tree/branchDoesNotExist',
);
};
await expect(fnGithub).rejects.toThrow(NotFoundError);
});
});
});
@@ -21,7 +21,7 @@ import {
readGitLabIntegrationConfigs,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import { InputError, NotFoundError } from '../errors';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import {
ReaderFactory,
@@ -29,7 +29,7 @@ import {
ReadTreeResponse,
UrlReader,
} from './types';
import parseGitUri from 'git-url-parse';
import parseGitUrl from 'git-url-parse';
import { Readable } from 'stream';
export class GitlabUrlReader implements UrlReader {
@@ -39,26 +39,26 @@ export class GitlabUrlReader implements UrlReader {
const configs = readGitLabIntegrationConfigs(
config.getOptionalConfigArray('integrations.gitlab') ?? [],
);
return configs.map(options => {
const reader = new GitlabUrlReader(options, { treeResponseFactory });
const predicate = (url: URL) => url.host === options.host;
return configs.map(provider => {
const reader = new GitlabUrlReader(provider, { treeResponseFactory });
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
};
constructor(
private readonly options: GitLabIntegrationConfig,
private readonly config: GitLabIntegrationConfig,
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.treeResponseFactory = deps.treeResponseFactory;
}
async read(url: string): Promise<Buffer> {
const builtUrl = await getGitLabFileFetchUrl(url, this.options);
const builtUrl = await getGitLabFileFetchUrl(url, this.config);
let response: Response;
try {
response = await fetch(builtUrl, getGitLabRequestOptions(this.options));
response = await fetch(builtUrl, getGitLabRequestOptions(this.config));
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -78,45 +78,82 @@ export class GitlabUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const {
name: repoName,
ref,
protocol,
resource,
full_name,
filepath,
} = parseGitUri(url);
const { name: repoName, ref, full_name, filepath } = parseGitUrl(url);
if (!ref) {
throw new InputError(
'GitLab URL must contain a branch to be able to fetch its tree',
);
}
const archive = `${protocol}://${resource}/${full_name}/-/archive/${ref}/${repoName}-${ref}.zip`;
const response = await fetch(
archive,
getGitLabRequestOptions(this.options),
// Use GitLab API to get the default branch
// encodeURIComponent is required for GitLab API
// https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding
const projectGitlabResponse = await fetch(
new URL(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(full_name)}`,
).toString(),
getGitLabRequestOptions(this.config),
);
if (!response.ok) {
const msg = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!projectGitlabResponse.ok) {
const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`;
if (projectGitlabResponse.status === 404) {
throw new NotFoundError(msg);
}
throw new Error(msg);
}
const projectGitlabResponseJson = await projectGitlabResponse.json();
const path = filepath ? `${repoName}-${ref}/${filepath}/` : '';
// ref is an empty string if no branch is set in provided url to readTree.
const branch = ref || projectGitlabResponseJson.default_branch;
return this.treeResponseFactory.fromZipArchive({
stream: (response.body as unknown) as Readable,
// Fetch the latest commit in the provided or default branch to compare against
// the provided sha.
const branchGitlabResponse = await fetch(
new URL(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}/repository/branches/${branch}`,
).toString(),
getGitLabRequestOptions(this.config),
);
if (!branchGitlabResponse.ok) {
const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`;
if (branchGitlabResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commitSha = (await branchGitlabResponse.json()).commit.id;
if (options?.etag && options.etag === commitSha) {
throw new NotModifiedError();
}
// https://docs.gitlab.com/ee/api/repositories.html#get-file-archive
const archiveGitLabResponse = await fetch(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}/repository/archive.zip?sha=${branch}`,
getGitLabRequestOptions(this.config),
);
if (!archiveGitLabResponse.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
if (archiveGitLabResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const path = filepath
? `${repoName}-${branch}-${commitSha}/${filepath}/`
: '';
return await this.treeResponseFactory.fromZipArchive({
stream: (archiveGitLabResponse.body as unknown) as Readable,
path,
etag: commitSha,
filter: options?.filter,
});
}
toString() {
const { host, token } = this.options;
const { host, token } = this.config;
return `gitlab{host=${host},authed=${Boolean(token)}}`;
}
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { NotAllowedError } from '../errors';
import {
ReadTreeOptions,
ReadTreeResponse,
@@ -21,22 +22,12 @@ import {
UrlReaderPredicateTuple,
} from './types';
type Options = {
// UrlReader to fall back to if no other reader is matched
fallback?: UrlReader;
};
/**
* A UrlReader implementation that selects from a set of UrlReaders
* based on a predicate tied to each reader.
*/
export class UrlReaderPredicateMux implements UrlReader {
private readonly readers: UrlReaderPredicateTuple[] = [];
private readonly fallback?: UrlReader;
constructor({ fallback }: Options) {
this.fallback = fallback;
}
register(tuple: UrlReaderPredicateTuple): void {
this.readers.push(tuple);
@@ -51,32 +42,25 @@ export class UrlReaderPredicateMux implements UrlReader {
}
}
if (this.fallback) {
return this.fallback.read(url);
}
throw new Error(`No reader found that could handle '${url}'`);
throw new NotAllowedError(`Reading from '${url}' is not allowed`);
}
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse> {
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const parsed = new URL(url);
for (const { predicate, reader } of this.readers) {
if (predicate(parsed)) {
return reader.readTree(url, options);
return await reader.readTree(url, options);
}
}
if (this.fallback) {
return this.fallback.readTree(url, options);
}
throw new Error(`No reader found that could handle '${url}'`);
throw new NotAllowedError(`Reading from '${url}' is not allowed`);
}
toString() {
return `predicateMux{readers=${this.readers
.map(t => t.reader)
.join(',')},fallback=${this.fallback}}`;
return `predicateMux{readers=${this.readers.map(t => t.reader).join(',')}`;
}
}
@@ -22,8 +22,8 @@ import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
import { FetchUrlReader } from './FetchUrlReader';
import { ReadTreeResponseFactory } from './tree';
import { FetchUrlReader } from './FetchUrlReader';
type CreateOptions = {
/** Root config object */
@@ -32,8 +32,6 @@ type CreateOptions = {
logger: Logger;
/** A list of factories used to construct individual readers that match on URLs */
factories?: ReaderFactory[];
/** Fallback reader to use if none of the readers created by the factories match */
fallback?: UrlReader;
};
/**
@@ -43,13 +41,8 @@ export class UrlReaders {
/**
* Creates a UrlReader without any known types.
*/
static create({
logger,
config,
factories,
fallback,
}: CreateOptions): UrlReader {
const mux = new UrlReaderPredicateMux({ fallback: fallback });
static create({ logger, config, factories }: CreateOptions): UrlReader {
const mux = new UrlReaderPredicateMux();
const treeResponseFactory = ReadTreeResponseFactory.create({ config });
for (const factory of factories ?? []) {
@@ -67,10 +60,8 @@ export class UrlReaders {
* Creates a UrlReader that includes all the default factories from this package.
*
* Any additional factories passed will be loaded before the default ones.
*
* If no fallback reader is passed, a plain fetch reader will be used.
*/
static default({ logger, config, factories = [], fallback }: CreateOptions) {
static default({ logger, config, factories = [] }: CreateOptions) {
return UrlReaders.create({
logger,
config,
@@ -79,8 +70,8 @@ export class UrlReaders {
BitbucketUrlReader.factory,
GithubUrlReader.factory,
GitlabUrlReader.factory,
FetchUrlReader.factory,
]),
fallback: fallback ?? new FetchUrlReader(),
});
}
}
@@ -26,6 +26,8 @@ type FromArchiveOptions = {
stream: Readable;
// If set, the root of the tree will be set to the given directory path.
path?: string;
// etag of the blob
etag: string;
// Filter passed on from the ReadTreeOptions
filter?: (path: string) => boolean;
};
@@ -45,6 +47,7 @@ export class ReadTreeResponseFactory {
options.stream,
options.path ?? '',
this.workDir,
options.etag,
options.filter,
);
}
@@ -54,6 +57,7 @@ export class ReadTreeResponseFactory {
options.stream,
options.path ?? '',
this.workDir,
options.etag,
options.filter,
);
}
@@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path';
import { TarArchiveResponse } from './TarArchiveResponse';
const archiveData = fs.readFileSync(
resolvePath(__filename, '../../__fixtures__/repo.tar.gz'),
resolvePath(__filename, '../../__fixtures__/mock-main.tar.gz'),
);
describe('TarArchiveResponse', () => {
@@ -38,7 +38,7 @@ describe('TarArchiveResponse', () => {
it('should read files', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
const files = await res.files();
expect(files).toEqual([
@@ -61,8 +61,12 @@ describe('TarArchiveResponse', () => {
it('should read files with filter', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
path.endsWith('.yml'),
const res = new TarArchiveResponse(
stream,
'mock-main/',
'/tmp',
'etag',
path => path.endsWith('.yml'),
);
const files = await res.files();
@@ -79,14 +83,14 @@ describe('TarArchiveResponse', () => {
it('should read as archive and files', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
const buffer = await res.archive();
await expect(res.archive()).rejects.toThrow(
'Response has already been read',
);
const res2 = new TarArchiveResponse(buffer, '', '/tmp');
const res2 = new TarArchiveResponse(buffer, '', '/tmp', 'etag');
const files = await res2.files();
expect(files).toEqual([
@@ -109,21 +113,26 @@ describe('TarArchiveResponse', () => {
it('should extract entire archive into directory', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, '', '/tmp');
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
const dir = await res.dir();
await expect(
fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'),
fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'),
fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('should extract archive into directory with a subpath', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
const res = new TarArchiveResponse(
stream,
'mock-main/docs/',
'/tmp',
'etag',
);
const dir = await res.dir();
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -135,8 +144,12 @@ describe('TarArchiveResponse', () => {
it('should extract archive into directory with a subpath and filter', async () => {
const stream = fs.createReadStream('/test-archive.tar.gz');
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
path.endsWith('.yml'),
const res = new TarArchiveResponse(
stream,
'mock-main/',
'/tmp',
'etag',
path => path.endsWith('.yml'),
);
const dir = await res.dir({ targetDir: '/tmp' });
@@ -41,6 +41,7 @@ export class TarArchiveResponse implements ReadTreeResponse {
private readonly stream: Readable,
private readonly subPath: string,
private readonly workDir: string,
public readonly etag: string,
private readonly filter?: (path: string) => boolean,
) {
if (subPath) {
@@ -53,6 +54,8 @@ export class TarArchiveResponse implements ReadTreeResponse {
);
}
}
this.etag = etag;
}
// Make sure the input stream is only read once
@@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path';
import { ZipArchiveResponse } from './ZipArchiveResponse';
const archiveData = fs.readFileSync(
resolvePath(__filename, '../../__fixtures__/repo.zip'),
resolvePath(__filename, '../../__fixtures__/mock-main.zip'),
);
describe('ZipArchiveResponse', () => {
@@ -38,31 +38,35 @@ describe('ZipArchiveResponse', () => {
it('should read files', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
const files = await res.files();
expect(files).toEqual([
{
path: 'docs/index.md',
path: 'mkdocs.yml',
content: expect.any(Function),
},
{
path: 'mkdocs.yml',
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([
'# Test',
'site_name: Test',
'# Test',
]);
});
it('should read files with filter', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
path.endsWith('.yml'),
const res = new ZipArchiveResponse(
stream,
'mock-main/',
'/tmp',
'etag',
path => path.endsWith('.yml'),
);
const files = await res.files();
@@ -79,51 +83,56 @@ describe('ZipArchiveResponse', () => {
it('should read as archive and files', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
const buffer = await res.archive();
await expect(res.archive()).rejects.toThrow(
'Response has already been read',
);
const res2 = new ZipArchiveResponse(buffer, '', '/tmp');
const res2 = new ZipArchiveResponse(buffer, '', '/tmp', 'etag');
const files = await res2.files();
expect(files).toEqual([
{
path: 'docs/index.md',
path: 'mkdocs.yml',
content: expect.any(Function),
},
{
path: 'mkdocs.yml',
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([
'# Test',
'site_name: Test',
'# Test',
]);
});
it('should extract entire archive into directory', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, '', '/tmp');
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
const dir = await res.dir();
await expect(
fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'),
fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'),
fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('should extract archive into directory with a subpath', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
const res = new ZipArchiveResponse(
stream,
'mock-main/docs/',
'/tmp',
'etag',
);
const dir = await res.dir();
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -135,8 +144,12 @@ describe('ZipArchiveResponse', () => {
it('should extract archive into directory with a subpath and filter', async () => {
const stream = fs.createReadStream('/test-archive.zip');
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
path.endsWith('.yml'),
const res = new ZipArchiveResponse(
stream,
'mock-main/',
'/tmp',
'etag',
path => path.endsWith('.yml'),
);
const dir = await res.dir({ targetDir: '/tmp' });
@@ -35,6 +35,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
private readonly stream: Readable,
private readonly subPath: string,
private readonly workDir: string,
public readonly etag: string,
private readonly filter?: (path: string) => boolean,
) {
if (subPath) {
@@ -47,6 +48,8 @@ export class ZipArchiveResponse implements ReadTreeResponse {
);
}
}
this.etag = etag;
}
// Make sure the input stream is only read once
@@ -32,6 +32,19 @@ export type ReadTreeOptions = {
* If no filter is provided all files are extracted.
*/
filter?(path: string): boolean;
/**
* An etag can be provided to check whether readTree's response has changed from a previous execution.
*
* In the readTree() response, an etag is returned along with the tree blob. The etag is a unique identifer
* of the tree blob, usually the commit SHA or etag from the target.
*
* When a etag is given in ReadTreeOptions, readTree will first compare the etag against the etag
* on the target branch. If they match, readTree will throw a NotModifiedError indicating that the readTree
* response will not differ from the previous response which included this particular etag. If they mismatch,
* readTree will return the rest of ReadTreeResponse along with a new etag.
*/
etag?: string;
};
/**
@@ -70,5 +83,14 @@ export type ReadTreeResponseDirOptions = {
export type ReadTreeResponse = {
files(): Promise<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
/**
* dir() extracts the tree response into a directory and returns the path of the directory.
*/
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
/**
* A unique identifer of the tree blob, usually the commit SHA or etag from the target.
*/
etag: string;
};
@@ -22,23 +22,8 @@ export type BaseOptions = {
listenHost?: string;
};
export type CertificateOptions = {
key?: CertificateKeyOptions;
attributes?: CertificateAttributeOptions;
};
export type CertificateKeyOptions = {
size?: number;
algorithm?: string;
days?: number;
};
export type CertificateAttributeOptions = {
commonName?: string;
};
export type HttpsSettings = {
certificate: CertificateSigningOptions | CertificateReferenceOptions;
certificate: CertificateGenerationOptions | CertificateReferenceOptions;
};
export type CertificateReferenceOptions = {
@@ -46,11 +31,8 @@ export type CertificateReferenceOptions = {
cert: string;
};
export type CertificateSigningOptions = {
algorithm?: string;
size?: number;
days?: number;
attributes: CertificateAttributes;
export type CertificateGenerationOptions = {
hostname: string;
};
export type CertificateAttributes = {
@@ -196,20 +178,14 @@ export function readHttpsSettings(config: Config): HttpsSettings | undefined {
const https = config.getOptional('https');
if (https === true) {
const baseUrl = config.getString('baseUrl');
let commonName;
let hostname;
try {
commonName = new URL(baseUrl).hostname;
hostname = new URL(baseUrl).hostname;
} catch (error) {
throw new Error(`Invalid backend.baseUrl "${baseUrl}"`);
}
return {
certificate: {
attributes: {
commonName,
},
},
};
return { certificate: { hostname } };
}
const cc = config.getOptionalConfig('https');
@@ -20,10 +20,12 @@ import express from 'express';
import * as http from 'http';
import * as https from 'https';
import { Logger } from 'winston';
import { CertificateSigningOptions, HttpsSettings } from './config';
import { HttpsSettings } from './config';
const ALMOST_MONTH_IN_MS = 25 * 24 * 60 * 60 * 1000;
const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
/**
* Creates a Http server instance based on an Express application.
*
@@ -59,17 +61,17 @@ export async function createHttpsServer(
let credentials: { key: string | Buffer; cert: string | Buffer };
const signingOptions: any = httpsSettings?.certificate;
// TODO(Rugvip): remove support for generated certificate params and make this a more straightforward check
if (signingOptions?.attributes) {
credentials = await getGeneratedCertificate(signingOptions, logger);
if ('hostname' in httpsSettings?.certificate) {
credentials = await getGeneratedCertificate(
httpsSettings.certificate.hostname,
logger,
);
} else {
logger?.info('Loading certificate from config');
credentials = {
key: signingOptions?.key,
cert: signingOptions?.cert,
key: httpsSettings?.certificate?.key,
cert: httpsSettings?.certificate?.cert,
};
}
@@ -80,16 +82,7 @@ export async function createHttpsServer(
return https.createServer(credentials, app) as http.Server;
}
async function getGeneratedCertificate(
options: CertificateSigningOptions,
logger?: Logger,
) {
if (options?.algorithm) {
logger?.warn(
'Certificate generation configuration with parameters in backend.https.certificate is deprecated, set backend.https = true instead',
);
}
async function getGeneratedCertificate(hostname: string, logger?: Logger) {
const hasModules = await fs.pathExists('node_modules');
let certPath;
if (hasModules) {
@@ -119,20 +112,61 @@ async function getGeneratedCertificate(
}
logger?.info('Generating new self-signed certificate');
const newCert = await createCertificate(options);
const newCert = await createCertificate(hostname);
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
return newCert;
}
async function createCertificate(options: CertificateSigningOptions) {
const attributes: Array<any> = Object.entries(
options.attributes,
).map(([name, value]) => ({ name, value }));
async function createCertificate(hostname: string) {
const attributes = [
{
name: 'commonName',
value: 'dev-cert',
},
];
const sans = [
{
type: 2, // DNS
value: 'localhost',
},
{
type: 2,
value: 'localhost.localdomain',
},
{
type: 2,
value: '[::1]',
},
{
type: 7, // IP
ip: '127.0.0.1',
},
{
type: 7,
ip: 'fe80::1',
},
];
// Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs
if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) {
sans.push(
IP_HOSTNAME_REGEX.test(hostname)
? {
type: 7,
ip: hostname,
}
: {
type: 2,
value: hostname,
},
);
}
const params = {
algorithm: options?.algorithm || 'sha256',
keySize: options?.size || 2048,
days: options?.days || 30,
algorithm: 'sha256',
keySize: 2048,
days: 30,
extensions: [
{
name: 'keyUsage',
@@ -151,36 +185,7 @@ async function createCertificate(options: CertificateSigningOptions) {
},
{
name: 'subjectAltName',
altNames: [
{
type: 2, // DNS
value: 'localhost',
},
{
type: 2,
value: 'localhost.localdomain',
},
{
type: 2,
value: '[::1]',
},
{
type: 7, // IP
ip: '127.0.0.1',
},
{
type: 7,
ip: 'fe80::1',
},
...(options.attributes.commonName
? [
{
type: 2, // DNS
value: options.attributes.commonName,
},
]
: []),
],
altNames: sans,
},
],
};
@@ -96,18 +96,12 @@ describe('util', () => {
b = lodash.cloneDeep(a);
b.metadata.labels.labelKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects annotation changes, but not removals', () => {
let b: any = lodash.cloneDeep(a);
b = lodash.cloneDeep(a);
b.metadata.annotations.annotationKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.annotations.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.annotations.annotationKey;
expect(entityHasChanges(a, b)).toBe(false);
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects spec changes', () => {
+12 -39
View File
@@ -54,10 +54,6 @@ export function generateEntityEtag(): string {
* @param next The new state of the entity
*/
export function entityHasChanges(previous: Entity, next: Entity): boolean {
if (entityHasAnnotationChanges(previous, next)) {
return true;
}
const e1 = lodash.cloneDeep(previous);
const e2 = lodash.cloneDeep(next);
@@ -67,6 +63,18 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean {
if (!e2.metadata.labels) {
e2.metadata.labels = {};
}
if (!e1.metadata.annotations) {
e1.metadata.annotations = {};
}
if (!e2.metadata.annotations) {
e2.metadata.annotations = {};
}
if (!e1.metadata.tags) {
e1.metadata.tags = [];
}
if (!e2.metadata.tags) {
e2.metadata.tags = [];
}
// Remove generated fields
delete e1.metadata.uid;
@@ -76,10 +84,6 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean {
delete e2.metadata.etag;
delete e2.metadata.generation;
// Remove already compared things
delete e1.metadata.annotations;
delete e2.metadata.annotations;
// Remove things that we explicitly do not compare
delete e1.relations;
delete e2.relations;
@@ -106,14 +110,6 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity {
const result = lodash.cloneDeep(next);
// Annotations are merged, with the new ones taking precedence
if (previous.metadata.annotations) {
next.metadata.annotations = {
...previous.metadata.annotations,
...next.metadata.annotations,
};
}
// Generated fields are copied and updated
const bumpEtag = entityHasChanges(previous, result);
const bumpGeneration = !lodash.isEqual(previous.spec, result.spec);
@@ -123,26 +119,3 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity {
return result;
}
function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean {
// Since the next annotations get merged into the previous, extract only
// the overlapping keys and check if their values match.
if (next.metadata.annotations) {
if (!previous.metadata.annotations) {
return true;
}
if (
!lodash.isEqual(
next.metadata.annotations,
lodash.pick(
previous.metadata.annotations,
Object.keys(next.metadata.annotations),
),
)
) {
return true;
}
}
return false;
}
@@ -15,3 +15,5 @@
*/
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
export const ORIGIN_LOCATION_ANNOTATION =
'backstage.io/managed-by-origin-location';
+1 -1
View File
@@ -20,4 +20,4 @@ export {
locationSpecSchema,
analyzeLocationSchema,
} from './validation';
export { LOCATION_ANNOTATION } from './annotation';
export { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION } from './annotation';
-3
View File
@@ -68,9 +68,6 @@ export class PluginImpl<
options,
});
},
registerRoute(path, component, options) {
outputs.push({ type: 'legacy-route', path, component, options });
},
},
featureFlags: {
register(name) {
+6 -10
View File
@@ -99,26 +99,22 @@ export type PluginConfig<
};
export type PluginHooks = {
/**
* @deprecated All router hooks have been deprecated
*/
router: RouterHooks;
featureFlags: FeatureFlagsHooks;
};
export type RouterHooks = {
/**
* @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins
*/
addRoute(
target: RouteRef,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
/**
* @deprecated See the `addRoute` method
* @see https://github.com/backstage/backstage/issues/418
*/
registerRoute(
path: RoutePath,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
};
export type FeatureFlagsHooks = {
@@ -76,26 +76,11 @@ const VARIANT_STYLES = {
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
/**
* @deprecated This variant is replaced by 'gridItem'.
*/
height100: {
display: 'flex',
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
},
cardContent: {
fullHeight: {
flex: 1,
},
/**
* @deprecated This variant is replaced by 'gridItem'.
*/
height100: {
flex: 1,
},
gridItem: {
flex: 1,
},
@@ -167,12 +152,6 @@ export const InfoCard = ({
if (variant) {
const variants = variant.split(/[\s]+/g);
variants.forEach(name => {
if (name === 'height100') {
// eslint-disable-next-line no-console
console.warn(
"Variant 'height100' of InfoCard is deprecated. Use variant 'gridItem' instead.",
);
}
calculatedStyle = {
...calculatedStyle,
...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']],
@@ -88,23 +88,23 @@ catalog:
target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml
# Backstage example templates
- type: github
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml
rules:
- allow: [Template]
- type: github
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml
rules:
- allow: [Template]
- type: github
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml
rules:
- allow: [Template]
- type: github
- type: url
target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
rules:
- allow: [Template]
- type: github
- type: url
target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml
rules:
- allow: [Template]
@@ -5,16 +5,18 @@ import {
OAuthRequestDialog,
SidebarPage,
createRouteRef,
FlatRoutes,
} from '@backstage/core';
import { apis } from './apis';
import * as plugins from './plugins';
import { AppSidebar } from './sidebar';
import { Route, Routes, Navigate } from 'react-router';
import { Route, Navigate } from 'react-router';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { SearchPage as SearchRouter } from '@backstage/plugin-search';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { EntityPage } from './components/catalog/EntityPage';
@@ -40,13 +42,13 @@ const App = () => (
<AppRouter>
<SidebarPage>
<AppSidebar />
<Routes>
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Route
path="/catalog/*"
path="/catalog"
element={<CatalogRouter EntityPage={EntityPage} />}
/>
<Route path="/docs/*" element={<DocsRouter />} />
<Route path="/docs" element={<DocsRouter />} />
<Route
path="/tech-radar"
element={<TechRadarRouter width={1500} height={800} />}
@@ -59,8 +61,9 @@ const App = () => (
path="/search"
element={<SearchRouter/>}
/>
<Route path="/settings" element={<SettingsRouter />} />
{deprecatedAppRoutes}
</Routes>
</FlatRoutes>
</SidebarPage>
</AppRouter>
</AppProvider>
@@ -6,3 +6,5 @@ export { plugin as GithubActions } from '@backstage/plugin-github-actions';
export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs';
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
@@ -1,21 +1,13 @@
import {
CookieCutter,
createRouter,
FilePreparer,
GithubPreparer,
GitlabPreparer,
Preparers,
Publishers,
GithubPublisher,
GitlabPublisher,
CreateReactAppTemplater,
Templaters,
RepoVisibilityOptions,
CatalogEntityClient,
} from '@backstage/plugin-scaffolder-backend';
import { SingleHostDiscovery } from '@backstage/backend-common';
import { Octokit } from '@octokit/rest';
import { Gitlab } from '@gitbeaker/node';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -29,74 +21,8 @@ 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 preparers = new Preparers();
preparers.register('file', filePreparer);
preparers.register('github', githubPreparer);
preparers.register('gitlab', gitlabPreparer);
preparers.register('gitlab/api', gitlabPreparer);
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 githubHost = githubConfig.getOptionalString('host');
const githubClient = new Octokit({ auth: githubToken, baseUrl: githubHost });
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 preparers = await Preparers.fromConfig(config, { logger });
const publishers = await Publishers.fromConfig(config, { logger });
const dockerClient = new Docker();

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