diff --git a/.changeset/angry-clocks-exercise.md b/.changeset/angry-clocks-exercise.md new file mode 100644 index 0000000000..e7eb6ae1d9 --- /dev/null +++ b/.changeset/angry-clocks-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-nomad-backend': patch +--- + +Added support for the [new backend system](https://backstage.io/docs/backend-system/) diff --git a/.changeset/calm-carpets-kiss.md b/.changeset/calm-carpets-kiss.md new file mode 100644 index 0000000000..b946e4d9c6 --- /dev/null +++ b/.changeset/calm-carpets-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +The `HostDiscovery` export has been deprecated, import it from `@backstage/backend-app-api` instead. diff --git a/.changeset/fresh-badgers-study.md b/.changeset/fresh-badgers-study.md new file mode 100644 index 0000000000..4c9e7ee627 --- /dev/null +++ b/.changeset/fresh-badgers-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Wrap entire app in ``, enabling support for using translations outside plugins. diff --git a/.changeset/metal-peas-beam.md b/.changeset/metal-peas-beam.md new file mode 100644 index 0000000000..a4ebdeb69a --- /dev/null +++ b/.changeset/metal-peas-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Display log visibility button on the template panel diff --git a/.changeset/serious-penguins-tease.md b/.changeset/serious-penguins-tease.md new file mode 100644 index 0000000000..aa6aba7208 --- /dev/null +++ b/.changeset/serious-penguins-tease.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Updated to import `HostDiscovery` from `@backstage/backend-app-api`. diff --git a/.changeset/six-weeks-raise.md b/.changeset/six-weeks-raise.md new file mode 100644 index 0000000000..319555afa4 --- /dev/null +++ b/.changeset/six-weeks-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Moved `HostDiscovery` from `@backstage/backend-common`. diff --git a/.changeset/twelve-days-walk.md b/.changeset/twelve-days-walk.md new file mode 100644 index 0000000000..0b2d261c1a --- /dev/null +++ b/.changeset/twelve-days-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': minor +--- + +URL encode some well known unsafe characters in `RouteResolver` (and therefore `useRouteRef`) diff --git a/docs/assets/search/search-extensions-example.drawio.svg b/docs/assets/search/search-extensions-example.drawio.svg new file mode 100644 index 0000000000..d0b0b4a481 --- /dev/null +++ b/docs/assets/search/search-extensions-example.drawio.svg @@ -0,0 +1,4 @@ + + + +
Core Routes
EXTENSION
Core Routes...

Routes

Routes
Attachment
Point
Attachment...
Route Artifact
Route Artifact
PATH
+
ELEMENT
PATH...
Input
Input
COMPONENT
COMPONENT
Item Artifact
Item Artifact

Search Page
EXTENSION

Search Page...

Items

Items
Attachment
Point
Attachment...
Output
Output
Input
Input

Search Result Item
EXTENSION

Search Result Item...


Output
Output
id: plugin.search.page
id: plugin.search.page
at: plugin.search.page/items
at: plugin.search.page/items
id: core.router
id: core.router
id: plugin.search.result.item
id: plugin.search.result.i...
at: core.router/routes
at: core.router/routes
Target Extension
Identification
Target Extension...
Target Extension
Input Attachment Point
Target Extension...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/features/search/declarative-integration.md b/docs/features/search/declarative-integration.md new file mode 100644 index 0000000000..f0083e4d4b --- /dev/null +++ b/docs/features/search/declarative-integration.md @@ -0,0 +1,222 @@ +# Declarative Integrated Search Plugin + +> **Disclaimer:** +> Declarative integration is in an experimental stage and is not recommended for production. + +This is a guide for experimenting with `Search` in a declarative integrated Backstage front-end application. + +## Main Concepts + +Using declarative integration, you can customize your Backstage instance without writing code, see this [RFC](https://github.com/backstage/backstage/issues/18372) for more information. + +In the new frontend system, everything that extends Backstage's core features is called an extension, so an extension can be anything from an API to a page component. + +Extensions produces output artifacts and these artifacts are inputs consumed by other extensions: + +![search extensions example](../../assets/search/search-extensions-example.drawio.svg) + +In the image above, a `SearchResultItem` extension outputs a component and this component is injected as input to the `SearchPage` "items" attachment point. The `SearchPage` in turn uses the search result items to compose a search page element and outputs a route path and the page element so they are used as inputs attached to the `CoreRoutes` extension. Finally, the `CoreRoutes` renders the page element when the location matches the search page path. + +The basic concepts briefly mentioned are crucial to understanding how the declarative version of the `Search` plugin works. + +## Search Plugin + +The search plugin is a collection of extensions that implement the search feature in Backstage. + +### Installation + +Only one step is required to start using the `Search` plugin within declarative integration, so all you have to do is to install the `@backstage/plugin-catalog` and `@backstage/plugin-search` packages, (e.g., [app-next](https://github.com/backstage/backstage/tree/master/packages/app-next)): + +```sh +yarn add @backstage/plugin-catalog @backstage/plugin-search +``` + +The `Search` plugin depends on the `Catalog API`, that's the reason we have to install the ` @backstage/plugin-catalog` package too. + +### Extensions + +The `Search` plugin provides the following [extensions preset](https://github.com/backstage/backstage/blob/3f4a44aef39bd8dbf5098e60b6fdf66fd754c6d9/plugins/search/src/alpha.tsx#L246): + +- **SearchApi**: Outputs a concrete implementation for the `Search API` that is attached as an input to the `Core` apis holder; +- **SearchPage**: Outputs a component that represents the advanced `Search` page interface, this extension expects `Search` result items components as inputs to use them for rendering results in a custom way; +- **SearchNavItem**: It is an extension that outputs a data that represents a `Search` item in the main application sidebar, in other words, it inputs a sidebar item to the `Core` nav extension. + +### Configurations + +The `Search` extensions are configurable via `app-config.yaml` file in the `app.extensions` field using the extension id as the configuration key: + +_Example disabling the search page extension_ + +```yaml +# app-config.yaml +app: + extensions: + - plugin.search.page: false # ✨ +``` + +_Example setting the search sidebar item label_ + +```yaml +# app-config.yaml +app: + extensions: + - plugin.search.nav.index: # ✨ + config: + label: 'Search Page' +``` + +> **Known limitations:** +> It is currently not possible to open modals in sidebar items and also configure a different icon via configuration file, but it is already on the maintainers' radar. + +### Customizations + +Plugin developers can use the `createSearchResultItemExtension` factory provided by the `@backstage/plugin-search-react` for building their own custom `Search` result item extensions. + +_Example creating a custom `TechDocsSearchResultItemExtension`_ + +```tsx +// plugins/techdocs/alpha.tsx +import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; + +/** @alpha */ +export const TechDocsSearchResultListItemExtension = + createSearchResultListItemExtension({ + id: 'techdocs', + configSchema: createSchemaFromZod(z => + z.object({ + noTrack: z.boolean().default(false), + lineClamp: z.number().default(5), + }), + ), + predicate: result => result.type === 'techdocs', + component: async ({ config }) => { + const { TechDocsSearchResultListItem } = await import( + './components/TechDocsSearchResultListItem' + ); + return props => ; + }, + }); +``` + +In the snippet above, a plugin developer is providing a custom component for rendering search results of type "techdocs". The custom result item extension will be enabled by default once the `@backstage/plugin-techdocs` package is installed, that means adopters don't have to enable the extension manually via configuration file. + +When a Backstage adopter doesn't want to use the custom `TechDocs` search result item after installing the `TechDocs` plugin, they could disable it via Backstage configuration file: + +```yaml +# app-config.yaml +app: + extensions: + - plugin.search.result.item.techdocs: false # ✨ +``` + +Because a configuration schema was provided to the extension factory, Backstage adopters will be able to customize `TechDocs` search results **line clamp** that defaults to 3 and also **disable automatic analytics events tracking**: + +```yaml +# app-config.yaml +app: + extensions: + - plugin.search.result.item.techdocs: + config: # ✨ + noTrack: true + lineClamp: 3 +``` + +[comment]: <> (TODO: Extract this explanation to a more central place in the future) +The `createSearchResultItemExtension` function returns a Backstage's extension representation as follows: + +```ts +{ + "$$type": "@backstage/Extension", // [1] + "id": "plugin.search.result.item.techdocs", // [2] + "at": "plugin.search.page/items", // [3] + "inputs": {} // [4️] + "output": { // [5️] + "item": { + "$$type": "@backstage/ExtensionDataRef", + "id": "plugin.search.result.item.data", + "config": {} + } + }, + "configSchema": { // [6️] + "schema": { + "type": "object", + "properties": { + "noTrack": { + "type": "boolean", + "default": false + }, + "lineClamp": { + "type": "number", + "default": 5 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "disabled": false, // [7️] +} +``` + +In this object, you can see exactly what will happen once the custom extension is installed: + +- **[1] $$type**: declares that the object represents an extension; +- **[2] id**: Is a unique identification for the extension, the `plugin.search.result.item.techdocs` is the key used to configure the extension in the `app-config.yaml` file; +- **[3] at**: It represents the extension attachment point, so the value `plugin.search.page/items` says that the `TechDocs`'s search result item output will be injected as input on the "items" attachment expected by the search page extension; +- **[4] inputs**: in this case is an empty object because this extension doesn't expect inputs; +- **[5] output**: Object representing the artifact produced by the `TechDocs` result item extension, on the example, it is a react component reference; +- **[6] configSchema**: represents the `TechDocs` search result item configuration definition, this is the same schema that adopters will use for customizing the extension via `app-config.yaml` file; +- **[7] disable**: Says that the result item extension will be enable by default when the `TechDocs` plugin is installed in the app. + +To complete the development cycle for creating a custom search result item extension, we should provide the extension via `TechDocs` plugin: + +```tsx +// plugins/techdocs/alpha.tsx +import { createPlugin } from "@backstage/frontend-plugin-api"; + +// plugins should be always exported as default +export default createPlugin({ + id: 'techdocs' + extensions: [TechDocsSearchResultItemExtension] +}) +``` + +Here is the `plugins/techdocs/alpha.tsx` final version, and you can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha.tsx) of a custom `TechDocs` search result item: + +```tsx +// plugins/techdocs/alpha.tsx +import { createPlugin } from '@backstage/frontend-plugin-api'; +import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; + +/** @alpha */ +export const TechDocsSearchResultListItemExtension = + createSearchResultListItemExtension({ + id: 'techdocs', + configSchema: createSchemaFromZod(z => + z.object({ + noTrack: z.boolean().default(false), + lineClamp: z.number().default(5), + }), + ), + predicate: result => result.type === 'techdocs', + component: async ({ config }) => { + const { TechDocsSearchResultListItem } = await import( + './components/TechDocsSearchResultListItem' + ); + return props => ; + }, + }); + +/** @alpha */ +export default createPlugin({ + // plugins should be always exported as default + id: 'techdocs', + extensions: [TechDocsSearchResultListItemExtension], +}); +``` + +### Future Enhancement Opportunities + +Backstage maintainers are currently working on the extension replacement feature, and with this release, adopters will also be able to replace extensions provided by plugins, so stay tuned for future updates to this documentation. + +The first version of the `SearchPage` extension makes room for the `Search` plugin maintainers to convert filters into extensions as well in the future, if you also would like to collaborate with them on this idea, don't hesitate to open an issue and submit a pull request, your contribution is more than welcome! diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 3bddcc48d5..ecfbc1fe7d 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -10,6 +10,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { CacheClient } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { ErrorRequestHandler } from 'express'; import { Express as Express_2 } from 'express'; import { Format } from 'logform'; @@ -25,7 +26,6 @@ import { LoadConfigOptionsRemote } from '@backstage/config-loader'; import { LoggerService } from '@backstage/backend-plugin-api'; import { PermissionsService } from '@backstage/backend-plugin-api'; import { PluginDatabaseManager } from '@backstage/backend-common'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { RemoteConfigSourceOptions } from '@backstage/config-loader'; import { RequestHandler } from 'express'; import { RequestListener } from 'http'; @@ -114,7 +114,7 @@ export interface DefaultRootHttpRouterOptions { // @public (undocumented) export const discoveryServiceFactory: () => ServiceFactory< - PluginEndpointDiscovery, + DiscoveryService, 'plugin' >; @@ -128,6 +128,20 @@ export interface ExtendedHttpServer extends http.Server { stop(): Promise; } +// @public +export class HostDiscovery implements DiscoveryService { + static fromConfig( + config: Config, + options?: { + basePath?: string; + }, + ): HostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; +} + // @public (undocumented) export interface HttpRouterFactoryOptions { getPath?(pluginId: string): string; diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts new file mode 100644 index 0000000000..86b63b527a --- /dev/null +++ b/packages/backend-app-api/config.d.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** Discovery options. */ + discovery?: { + /** + * Endpoints + * + * A list of target baseUrls and the associated plugins. + */ + endpoints: { + /** + * The target baseUrl to use for the plugin + * + * Can be either a string or an object with internal and external keys. + * Targets with `{{pluginId}}` or `{{ pluginId }} in the url will be replaced with the pluginId. + */ + target: string | { internal: string; external: string }; + /** Array of plugins which use the target baseUrl. */ + plugins: string[]; + }[]; + }; +} diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 1b395ff77f..24959ff290 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -90,8 +90,10 @@ "mock-fs": "^5.2.0", "supertest": "^6.1.3" }, + "configSchema": "config.d.ts", "files": [ "dist", + "config.d.ts", "alpha" ] } diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.test.ts similarity index 100% rename from packages/backend-common/src/discovery/HostDiscovery.test.ts rename to packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.test.ts diff --git a/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts b/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts new file mode 100644 index 0000000000..180c8706d5 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts @@ -0,0 +1,132 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config } from '@backstage/config'; +import { readHttpServerOptions } from '@backstage/backend-app-api'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; + +type Target = string | { internal: string; external: string }; + +/** + * HostDiscovery is a basic PluginEndpointDiscovery implementation + * that can handle plugins that are hosted in a single or multiple deployments. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + * + * @public + */ +export class HostDiscovery implements DiscoveryService { + /** + * Creates a new HostDiscovery discovery instance by reading + * from the `backend` config section, specifically the `.baseUrl` for + * discovering the external URL, and the `.listen` and `.https` config + * for the internal one. + * + * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`. + * eg. + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permission] + * - target: + * internal: https://internal.example.com/search + * external: https://example.com/search + * plugins: [search] + * ``` + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config + .getString('backend.baseUrl') + .replace(/\/+$/, ''); + + const { + listen: { host: listenHost = '::', port: listenPort }, + } = readHttpServerOptions(config.getConfig('backend')); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::' || host === '') { + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new HostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + config.getOptionalConfig('discovery'), + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + private readonly discoveryConfig: Config | undefined, + ) {} + + private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') { + const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); + + const target = endpoints + ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) + ?.get('target'); + + if (!target) { + const baseUrl = + type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; + + return `${baseUrl}/${encodeURIComponent(pluginId)}`; + } + + if (typeof target === 'string') { + return target.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + return target[type].replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + async getBaseUrl(pluginId: string): Promise { + return this.getTargetFromConfig(pluginId, 'internal'); + } + + async getExternalBaseUrl(pluginId: string): Promise { + return this.getTargetFromConfig(pluginId, 'external'); + } +} diff --git a/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts b/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts index 6bdc4b4856..bfc5a6a489 100644 --- a/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { HostDiscovery } from '@backstage/backend-common'; import { coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; +import { HostDiscovery } from './HostDiscovery'; /** @public */ export const discoveryServiceFactory = createServiceFactory({ diff --git a/packages/backend-app-api/src/services/implementations/discovery/index.ts b/packages/backend-app-api/src/services/implementations/discovery/index.ts index 7b3b9816b1..ee4851271a 100644 --- a/packages/backend-app-api/src/services/implementations/discovery/index.ts +++ b/packages/backend-app-api/src/services/implementations/discovery/index.ts @@ -15,3 +15,4 @@ */ export { discoveryServiceFactory } from './discoveryServiceFactory'; +export { HostDiscovery } from './HostDiscovery'; diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 36cb225a27..d6ac25326f 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -28,6 +28,7 @@ import { GiteaIntegration } from '@backstage/integration'; import { GithubCredentialsProvider } from '@backstage/integration'; import { GithubIntegration } from '@backstage/integration'; import { GitLabIntegration } from '@backstage/integration'; +import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api'; import { IdentityService } from '@backstage/backend-plugin-api'; import { isChildPath } from '@backstage/cli-common'; import { Knex } from 'knex'; @@ -479,18 +480,7 @@ export class GitlabUrlReader implements UrlReader { } // @public -export class HostDiscovery implements PluginEndpointDiscovery { - static fromConfig( - config: Config, - options?: { - basePath?: string; - }, - ): HostDiscovery; - // (undocumented) - getBaseUrl(pluginId: string): Promise; - // (undocumented) - getExternalBaseUrl(pluginId: string): Promise; -} +export const HostDiscovery: typeof HostDiscovery_2; export { isChildPath }; @@ -751,7 +741,7 @@ export type ServiceBuilder = { export function setRootLogger(newLogger: winston.Logger): void; // @public @deprecated -export const SingleHostDiscovery: typeof HostDiscovery; +export const SingleHostDiscovery: typeof HostDiscovery_2; // @public export type StatusCheck = () => Promise; diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index e5b193c543..f6330d16bc 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -216,24 +216,4 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; }; - - /** Discovery options. */ - discovery?: { - /** - * Endpoints - * - * A list of target baseUrls and the associated plugins. - */ - endpoints: { - /** - * The target baseUrl to use for the plugin - * - * Can be either a string or an object with internal and external keys. - * Targets with `{{pluginId}}` or `{{ pluginId }} in the url will be replaced with the pluginId. - */ - target: string | { internal: string; external: string }; - /** Array of plugins which use the target baseUrl. */ - plugins: string[]; - }[]; - }; } diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index 89b6404143..38ac975747 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery } from './types'; -import { readHttpServerOptions } from '@backstage/backend-app-api'; +import { HostDiscovery as _HostDiscovery } from '@backstage/backend-app-api'; -type Target = string | { internal: string; external: string }; +export type { DiscoveryService as PluginEndpointDiscovery } from '@backstage/backend-plugin-api'; /** * HostDiscovery is a basic PluginEndpointDiscovery implementation @@ -30,106 +28,7 @@ type Target = string | { internal: string; external: string }; * * @public */ -export class HostDiscovery implements PluginEndpointDiscovery { - /** - * Creates a new HostDiscovery discovery instance by reading - * from the `backend` config section, specifically the `.baseUrl` for - * discovering the external URL, and the `.listen` and `.https` config - * for the internal one. - * - * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`. - * eg. - * ```yaml - * discovery: - * endpoints: - * - target: https://internal.example.com/internal-catalog - * plugins: [catalog] - * - target: https://internal.example.com/secure/api/{{pluginId}} - * plugins: [auth, permission] - * - target: - * internal: https://internal.example.com/search - * external: https://example.com/search - * plugins: [search] - * ``` - * - * The basePath defaults to `/api`, meaning the default full internal - * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. - */ - static fromConfig(config: Config, options?: { basePath?: string }) { - const basePath = options?.basePath ?? '/api'; - const externalBaseUrl = config - .getString('backend.baseUrl') - .replace(/\/+$/, ''); - - const { - listen: { host: listenHost = '::', port: listenPort }, - } = readHttpServerOptions(config.getConfig('backend')); - const protocol = config.has('backend.https') ? 'https' : 'http'; - - // Translate bind-all to localhost, and support IPv6 - let host = listenHost; - if (host === '::' || host === '') { - // We use localhost instead of ::1, since IPv6-compatible systems should default - // to using IPv6 when they see localhost, but if the system doesn't support IPv6 - // things will still work. - host = 'localhost'; - } else if (host === '0.0.0.0') { - host = '127.0.0.1'; - } - if (host.includes(':')) { - host = `[${host}]`; - } - - const internalBaseUrl = `${protocol}://${host}:${listenPort}`; - - return new HostDiscovery( - internalBaseUrl + basePath, - externalBaseUrl + basePath, - config.getOptionalConfig('discovery'), - ); - } - - private constructor( - private readonly internalBaseUrl: string, - private readonly externalBaseUrl: string, - private readonly discoveryConfig: Config | undefined, - ) {} - - private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') { - const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); - - const target = endpoints - ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) - ?.get('target'); - - if (!target) { - const baseUrl = - type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; - - return `${baseUrl}/${encodeURIComponent(pluginId)}`; - } - - if (typeof target === 'string') { - return target.replace( - /\{\{\s*pluginId\s*\}\}/g, - encodeURIComponent(pluginId), - ); - } - - return target[type].replace( - /\{\{\s*pluginId\s*\}\}/g, - encodeURIComponent(pluginId), - ); - } - - async getBaseUrl(pluginId: string): Promise { - return this.getTargetFromConfig(pluginId, 'internal'); - } - - async getExternalBaseUrl(pluginId: string): Promise { - return this.getTargetFromConfig(pluginId, 'external'); - } -} +export const HostDiscovery = _HostDiscovery; /** * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation @@ -142,4 +41,4 @@ export class HostDiscovery implements PluginEndpointDiscovery { * @public * @deprecated Use {@link HostDiscovery} instead */ -export const SingleHostDiscovery = HostDiscovery; +export const SingleHostDiscovery = _HostDiscovery; diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts index 5c7f5300c0..bad721d4da 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -13,5 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { HostDiscovery, SingleHostDiscovery } from './HostDiscovery'; -export type { PluginEndpointDiscovery } from './types'; +export { + HostDiscovery, + SingleHostDiscovery, + type PluginEndpointDiscovery, +} from './HostDiscovery'; diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index d0fc53ff58..3e5b4f8057 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -41,6 +41,7 @@ "@backstage/plugin-kubernetes-backend": "workspace:^", "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", + "@backstage/plugin-nomad-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 9fda69635b..1b4867c05e 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -33,6 +33,7 @@ backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); backend.add(import('@backstage/plugin-lighthouse-backend')); backend.add(import('@backstage/plugin-linguist-backend')); backend.add(import('@backstage/plugin-playlist-backend')); +backend.add(import('@backstage/plugin-nomad-backend')); backend.add( import('@backstage/plugin-permission-backend-module-allow-all-policy'), ); diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 59b5906eb4..21ae6159c9 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -20,9 +20,9 @@ import { MiddlewareFactory, createHttpServer, ExtendedHttpServer, + HostDiscovery, DefaultRootHttpRouter, } from '@backstage/backend-app-api'; -import { HostDiscovery } from '@backstage/backend-common'; import { createServiceFactory, BackendFeature, diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 4d9e8f0430..5852076136 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -50,6 +50,7 @@ "@types/react": "^16.13.1 || ^17.0.0", "history": "^5.0.0", "i18next": "^22.4.15", + "lodash": "^4.17.21", "prop-types": "^15.7.2", "react-use": "^17.2.4", "zen-observable": "^0.10.0", diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 98ce0b93e2..3d0c47c4d5 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -35,16 +35,34 @@ import { createRoutableExtension, analyticsApiRef, useApi, + errorApiRef, } from '@backstage/core-plugin-api'; import { AppManager } from './AppManager'; import { AppComponents, AppIcons } from './types'; import { FeatureFlagged } from '../routing/FeatureFlagged'; +import { + createTranslationRef, + useTranslationRef, +} from '@backstage/core-plugin-api/alpha'; describe('Integration Test', () => { const noOpAnalyticsApi = createApiFactory( analyticsApiRef, new NoOpAnalyticsApi(), ); + const noopErrorApi = createApiFactory(errorApiRef, { + error$() { + return { + subscribe() { + return { unsubscribe() {}, closed: true }; + }, + [Symbol.observable]() { + return this; + }, + }; + }, + post() {}, + }); const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); const plugin1RouteRef2 = createRouteRef({ id: 'ref-1-2' }); const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); @@ -175,6 +193,10 @@ describe('Integration Test', () => { }, ]; + afterEach(() => { + localStorage.clear(); + }); + it('runs happy paths', async () => { const app = new AppManager({ apis: [noOpAnalyticsApi], @@ -262,7 +284,7 @@ describe('Integration Test', () => { it('runs success with __experimentalTranslations', async () => { const app = new AppManager({ - apis: [noOpAnalyticsApi], + apis: [noOpAnalyticsApi, noopErrorApi], defaultApis: [], themes, icons, @@ -277,27 +299,41 @@ describe('Integration Test', () => { }, __experimentalTranslations: { availableLanguages: ['en', 'de'], + defaultLanguage: 'de', }, }); const Provider = app.getProvider(); const Router = app.getRouter(); + const translationRef = createTranslationRef({ + id: 'test', + messages: { + foo: 'Foo', + }, + translations: { + de: () => Promise.resolve({ default: { foo: 'Bar' } }), + }, + }); + + const TranslatedComponent = () => { + const { t } = useTranslationRef(translationRef); + return
translation: {t('foo')}
; + }; + await renderWithEffects( - } /> - } /> + } /> , ); - expect(screen.getByText('extLink1: /')).toBeInTheDocument(); - expect(screen.getByText('extLink2: /foo')).toBeInTheDocument(); - expect(screen.getByText('extLink3: ')).toBeInTheDocument(); - expect(screen.getByText('extLink4: ')).toBeInTheDocument(); + await expect( + screen.findByText('translation: Bar'), + ).resolves.toBeInTheDocument(); }); it('should wait for the config to load before calling feature flags', async () => { diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 7a941b7986..37407283b5 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -18,6 +18,7 @@ import { Config } from '@backstage/config'; import React, { ComponentType, PropsWithChildren, + Suspense, useMemo, useRef, } from 'react'; @@ -341,7 +342,7 @@ export class AppManager implements BackstageApp { } } - const { ThemeProvider = AppThemeProvider } = this.components; + const { ThemeProvider = AppThemeProvider, Progress } = this.components; return ( @@ -360,7 +361,7 @@ export class AppManager implements BackstageApp { appIdentityProxy: this.appIdentityProxy, }} > - {children} + }>{children} diff --git a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts index 7cd9eae021..c0ee452d28 100644 --- a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts @@ -364,4 +364,31 @@ describe('RouteResolver', () => { /^Cannot route.*with parent.*as it has parameters$/, ); }); + + it('should encode some characters in params', () => { + const r = new RouteResolver( + new Map([ + [ref2, 'my-parent/:x'], + [ref1, 'my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map(), + '/base', + ); + + expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe( + '/base/my-parent/a%2F%23%26%3Fb', + ); + }); }); diff --git a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts index 41e5aba712..0b4bf588a2 100644 --- a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts @@ -390,4 +390,33 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => { /^Cannot route.*with parent.*as it has parameters$/, ); }); + + it('should encode some characters in params', () => { + const { RouteResolver } = + require('./RouteResolver') as typeof import('./RouteResolver'); + const r = new RouteResolver( + new Map([ + [ref2, 'my-parent/:x'], + [ref1, 'my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map(), + '/base', + ); + + expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe( + '/base/my-parent/a%2F%23%26%3Fb', + ); + }); }); diff --git a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts index 8c484effae..05f7678078 100644 --- a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts @@ -364,4 +364,31 @@ describe('RouteResolver', () => { /^Cannot route.*with parent.*as it has parameters$/, ); }); + + it('should encode some characters in params', () => { + const r = new RouteResolver( + new Map([ + [ref2, 'my-parent/:x'], + [ref1, 'my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: 'my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: 'my-route', ...rest }, + ], + }, + ], + new Map(), + '/base', + ); + + expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe( + '/base/my-parent/a%2F%23%26%3Fb', + ); + }); }); diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts index f17c549bc6..11eb753bc6 100644 --- a/packages/core-app-api/src/routing/RouteResolver.ts +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -31,6 +31,7 @@ import { SubRouteRef, } from '@backstage/core-plugin-api'; import { joinPaths } from './helpers'; +import mapValues from 'lodash/mapValues'; /** * Resolves the absolute route ref that our target route ref is pointing pointing to, as well @@ -225,7 +226,23 @@ export class RouteResolver { ); const routeFunc: RouteFunc = (...[params]) => { - return joinPaths(basePath, generatePath(targetPath, params)); + // We selectively encode some some known-dangerous characters in the + // params. The reason that we don't perform a blanket `encodeURIComponent` + // here is that this encoding was added defensively long after the initial + // release of this code. There's likely to be many users of this code that + // already encode their parameters knowing that this code didn't do this + // for them in the past. Therefore, we are extra careful NOT to include + // the percent character in this set, even though that might seem like a + // bad idea. + const encodedParams = + params && + mapValues(params, value => { + if (typeof value === 'string') { + return value.replaceAll(/[&?#;\/]/g, c => encodeURIComponent(c)); + } + return value; + }); + return joinPaths(basePath, generatePath(targetPath, encodedParams)); }; return routeFunc; } diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md index d06444809e..231f1ddccb 100644 --- a/plugins/nomad-backend/README.md +++ b/plugins/nomad-backend/README.md @@ -1,6 +1,20 @@ # @backstage/plugin-nomad-backend -A backend for [Nomad](https://www.nomadproject.io/), this plugin exposes a service with routes that are used by the `@backstage/plugin-nomad` plugin to query Job and Group information from a Nomad API. +A backend for [Nomad](https://www.nomadproject.io/), this plugin exposes a service with routes that are used by the `@backstage/plugin-nomad-backend` plugin to query Job and Group information from a Nomad API. + +## New Backend System + +The Nomad backend plugin has support for the [new backend system](https://backstage.io/docs/backend-system/), here's how you can set that up: + +In your `packages/backend/src/index.ts` make the following changes: + +```diff + import { createBackend } from '@backstage/backend-defaults'; + const backend = createBackend(); + // ... other feature additions + backend.add(import('@backstage/plugin-nomad-backend')); + backend.start(); +``` ## Set Up diff --git a/plugins/nomad-backend/api-report.md b/plugins/nomad-backend/api-report.md index 5b56661e43..82bb823d2d 100644 --- a/plugins/nomad-backend/api-report.md +++ b/plugins/nomad-backend/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; @@ -10,6 +11,10 @@ import { Logger } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// @public +const nomadPlugin: () => BackendFeature; +export default nomadPlugin; + // @public (undocumented) export interface RouterOptions { // (undocumented) diff --git a/plugins/nomad-backend/package.json b/plugins/nomad-backend/package.json index a9c86dc376..7873f846ba 100644 --- a/plugins/nomad-backend/package.json +++ b/plugins/nomad-backend/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@types/express": "*", diff --git a/plugins/nomad-backend/src/index.ts b/plugins/nomad-backend/src/index.ts index d2e8d61bad..21aa299d6d 100644 --- a/plugins/nomad-backend/src/index.ts +++ b/plugins/nomad-backend/src/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './service/router'; +export { nomadPlugin as default } from './plugin'; diff --git a/packages/backend-common/src/discovery/types.ts b/plugins/nomad-backend/src/plugin.test.ts similarity index 73% rename from packages/backend-common/src/discovery/types.ts rename to plugins/nomad-backend/src/plugin.test.ts index 3ecdb7aa39..6ed8d2b194 100644 --- a/packages/backend-common/src/discovery/types.ts +++ b/plugins/nomad-backend/src/plugin.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2023 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { nomadPlugin } from './plugin'; -export type { DiscoveryService as PluginEndpointDiscovery } from '@backstage/backend-plugin-api'; +describe('nomad', () => { + it('should export the nomad plugin', () => { + expect(nomadPlugin).toBeDefined(); + }); +}); diff --git a/plugins/nomad-backend/src/plugin.ts b/plugins/nomad-backend/src/plugin.ts new file mode 100644 index 0000000000..f3a43c4a91 --- /dev/null +++ b/plugins/nomad-backend/src/plugin.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + createBackendPlugin, + coreServices, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; + +/** + * Nomad backend plugin + * + * @public + */ +export const nomadPlugin = createBackendPlugin({ + pluginId: 'nomad', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + config: coreServices.rootConfig, + httpRouter: coreServices.httpRouter, + }, + async init({ logger, config, httpRouter }) { + const winstonLogger = loggerToWinstonLogger(logger); + httpRouter.use( + await createRouter({ + /** + * Logger for logging purposes + */ + logger: winstonLogger, + config, + }), + ); + }, + }); + }, +}); diff --git a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx index 16d9740c15..9d3f29fe7b 100644 --- a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx +++ b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.test.tsx @@ -112,4 +112,29 @@ describe('OngoingTask', () => { expect(getByTestId('cancel-button')).toHaveClass('Mui-disabled'); }); }); + + it('should initially do not display logs', async () => { + const rendered = await renderInTestApp( + + + , + { mountedRoutes: { '/': rootRouteRef } }, + ); + await expect(rendered.findByText('Show Logs')).resolves.toBeInTheDocument(); + }); + + it('should toggle logs visibility', async () => { + const rendered = await renderInTestApp( + + + , + { mountedRoutes: { '/': rootRouteRef } }, + ); + await act(async () => { + const element = await rendered.findByText('Show Logs'); + fireEvent.click(element); + }); + + await expect(rendered.findByText('Hide Logs')).resolves.toBeInTheDocument(); + }); }); diff --git a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx index 91c04897d9..b483cd27cb 100644 --- a/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/next/OngoingTask/OngoingTask.tsx @@ -46,6 +46,9 @@ const useStyles = makeStyles(theme => ({ cancelButton: { marginRight: theme.spacing(1), }, + logsVisibilityButton: { + marginRight: theme.spacing(1), + }, })); export const OngoingTask = (props: { @@ -188,6 +191,14 @@ export const OngoingTask = (props: { > Cancel +