diff --git a/.changeset/add-badge-component.md b/.changeset/add-badge-component.md new file mode 100644 index 0000000000..5f07272a44 --- /dev/null +++ b/.changeset/add-badge-component.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added new `Badge` component for non-interactive labeling and categorization of content. It shares the visual appearance of `Tag` but renders as a plain DOM element with no interactive states. + +**Affected components:** Badge diff --git a/.changeset/breezy-bushes-divide.md b/.changeset/breezy-bushes-divide.md new file mode 100644 index 0000000000..a7b7d9f4dd --- /dev/null +++ b/.changeset/breezy-bushes-divide.md @@ -0,0 +1,7 @@ +--- +'@backstage/theme': patch +--- + +Fixes occasional duplication of v5 class name prefix for MUI 5 components. + +Documentation added to explain how to resolve missing v5 prefix in class names when using MUI 5 components in main app. diff --git a/.changeset/catalog-backend-permissions-cleanup-step-1.md b/.changeset/catalog-backend-permissions-cleanup-step-1.md new file mode 100644 index 0000000000..cf3d04679c --- /dev/null +++ b/.changeset/catalog-backend-permissions-cleanup-step-1.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Removed deprecated `PermissionAuthorizer` support and the `createPermissionIntegrationRouter` fallback path from `CatalogBuilder`. The `permissionsRegistry` service is now required, and `permissions` is always a `PermissionsService`. diff --git a/.changeset/catalog-backend-permissions-cleanup-step-2.md b/.changeset/catalog-backend-permissions-cleanup-step-2.md new file mode 100644 index 0000000000..7ecf6e0d30 --- /dev/null +++ b/.changeset/catalog-backend-permissions-cleanup-step-2.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Removed the internal `addPermissions` and `addPermissionRules` methods from `CatalogBuilder`, and removed the `catalogPermissionExtensionPoint` wiring from `CatalogPlugin`. Custom permission rules and permissions should be registered via `coreServices.permissionsRegistry` directly. diff --git a/.changeset/catalog-node-permissions-cleanup-step-2.md b/.changeset/catalog-node-permissions-cleanup-step-2.md new file mode 100644 index 0000000000..bee3ff765d --- /dev/null +++ b/.changeset/catalog-node-permissions-cleanup-step-2.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +**BREAKING ALPHA**: Removed the deprecated `CatalogPermissionRuleInput`, `CatalogPermissionExtensionPoint`, and `catalogPermissionExtensionPoint` exports. Use `coreServices.permissionsRegistry` directly to register catalog entity permission rules and permissions. diff --git a/.changeset/evil-seals-smell.md b/.changeset/evil-seals-smell.md new file mode 100644 index 0000000000..a533720434 --- /dev/null +++ b/.changeset/evil-seals-smell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Migrated OpenTelemetry metrics to use the `MetricsService` from `@backstage/backend-plugin-api/alpha` instead of the raw `@opentelemetry/api` meter. diff --git a/.changeset/host-discovery-baseurl-warnings.md b/.changeset/host-discovery-baseurl-warnings.md new file mode 100644 index 0000000000..658b24bf0c --- /dev/null +++ b/.changeset/host-discovery-baseurl-warnings.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +`HostDiscovery` now logs a warning when `backend.baseUrl` is set to a localhost address while `NODE_ENV` is `production`, and when `backend.baseUrl` is not a valid URL. diff --git a/.changeset/nine-signs-end.md b/.changeset/nine-signs-end.md new file mode 100644 index 0000000000..544b0bb231 --- /dev/null +++ b/.changeset/nine-signs-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Support configuring `showArrowHeads` on `page:catalog-graph` and `entity-card:catalog-graph/relations`. diff --git a/.changeset/thin-elephants-joke.md b/.changeset/thin-elephants-joke.md new file mode 100644 index 0000000000..a7ed28bc9e --- /dev/null +++ b/.changeset/thin-elephants-joke.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli-module-build': patch +'@backstage/backend-defaults': patch +--- + +Added experimental support for using `embedded-postgres` as the database for local development. Set `backend.database.client` to `embedded-postgres` in your app config to enable this. The `embedded-postgres` package must be installed as an explicit dependency in your project. diff --git a/.changeset/true-groups-slide.md b/.changeset/true-groups-slide.md new file mode 100644 index 0000000000..0bf2bdb233 --- /dev/null +++ b/.changeset/true-groups-slide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +Added automatic retry on temporary errors (like 5XX) to the shared GitHub GraphQL client used by `GithubOrgEntityProvider` and replaced the GraphQL client in `GithubEntityProvider` by this one as well, improving resilience against intermittent GitHub API failures. diff --git a/REVIEWING.md b/REVIEWING.md index 75f6a67eeb..4fa203d709 100644 --- a/REVIEWING.md +++ b/REVIEWING.md @@ -143,7 +143,7 @@ Some things that changeset should NOT contain are: ### Backstage UI Changeset Format -Changesets for `@backstage/ui` must follow a standardized format to enable proper documentation generation. See [`.changeset/README.md`](.changeset/README.md#backstage-ui-changeset-format) for the complete guide. +Changesets for `@backstage/ui` must follow a standardized format to enable proper documentation generation. See [`.changeset/README.md`](.changeset/README.md#backstage-ui-changesets) for the complete guide. **Required structure:** diff --git a/beps/0002-dynamic-frontend-plugins/README.md b/beps/0002-dynamic-frontend-plugins/README.md index c783cf052e..583da8b174 100644 --- a/beps/0002-dynamic-frontend-plugins/README.md +++ b/beps/0002-dynamic-frontend-plugins/README.md @@ -633,7 +633,7 @@ Chunk optimization should be disabled for the initial implementation. The `publicPath` output config in webpack is a mandatory attribute for federated modules. However, at build time, it is impossible to guess where the assets are served from. From origin to the pathname, this is specific to each installation. -We can leverage the [auto](https://webpack.js.org/guides/public-path/#automatic-publicpath) option. However this means that some manifest transformation has to happen at runtime when entry scripts are loaded into the browser. More on that in the [Plugin manifest](#plugin-manifest), [CDN Plugin](#dynamic-assets-server-plugin), and [Plugin loading](#plugin-loading) sections. +We can leverage the [auto](https://webpack.js.org/guides/public-path/#automatic-publicpath) option. However this means that some manifest transformation has to happen at runtime when entry scripts are loaded into the browser. More on that in the [Plugin manifest](#plugin-manifest), [CDN Plugin](#dynamic-assets-server), and [Plugin loading](#plugin-loading) sections. #### Sample webpack configuration diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index 33bf80a099..eedd63e850 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -36,7 +36,7 @@ The changes to the service-to-service auth are aimed to be the minimum needed to ## Motivation -This proposal aims to address several of the points in the [Auth Meta issue](https://github.com/backstage/backstage/issues/15999), with the overarching goal being to replace the existing [API request authentication](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/authenticate-api-requests.md) tutorial in `contrib/` with a more robust and secure built-in solution. The tutorial exists for two purposes: to add authentication of API requests as part of using the permission system in Backstage, and to protect a Backstage instance from external access. It does a fairly good job of the former, although we want to avoid placing user tokens in cookies, but it does a quite poor job of the latter, which we want to fix. +This proposal aims to address several of the points in the [Auth Meta issue](https://github.com/backstage/backstage/issues/15999), with the overarching goal being to replace the existing [API request authentication](https://github.com/backstage/backstage/blob/a93b7fdafb6789277661ba77b8cc3390de82db27/contrib/docs/tutorials/authenticate-api-requests.md) tutorial in `contrib/` with a more robust and secure built-in solution. The tutorial exists for two purposes: to add authentication of API requests as part of using the permission system in Backstage, and to protect a Backstage instance from external access. It does a fairly good job of the former, although we want to avoid placing user tokens in cookies, but it does a quite poor job of the latter, which we want to fix. A secondary goal is to do this work before stabilizing the APIs in the new Backend system, as it will have some impact on how plugin backends are built. This will inevitably also lead to the need to improve the way that service-to-service auth is handled in Backstage, although that is not the primary goal of this work. diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md deleted file mode 100644 index 8c858bea0f..0000000000 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ /dev/null @@ -1,459 +0,0 @@ -# Authenticate API requests - -> [!CAUTION] -> This entire guide MUST NOT BE USED by users of Backstage 1.26 and -> newer. If you have applied the changes in this guide, you need to remove them -> again as you upgrade to recent versions of Backstage. When [the new auth changes](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution) -> landed backends became natively secured through the framework, and the -> instructions outlined in here can interfere with the backend functioning -> correctly. - -The Backstage backend APIs are by default available without authentication. To avoid evil-doers from accessing or modifying data, one might use a network protection mechanism such as a firewall or an authenticating reverse proxy. For Backstage instances that are available on the Internet one can instead use the experimental IdentityClient as outlined below. - -API requests from frontend plugins include an authorization header with a Backstage identity token acquired when the user logs in. By adding a middleware that verifies said token to be valid and signed by Backstage, non-authenticated requests can be blocked with a 401 Unauthorized response. - -**NOTE**: Enabling this means that Backstage will stop working for guests, as no token is issued for them. If you have not done so already, you will also need to implement [service-to-service auth](https://backstage.io/docs/auth/service-to-service-auth). - -As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire). - -## Old Backend System Setup - -Create `packages/backend/src/authMiddleware.ts`: - -```typescript -import type { Config } from '@backstage/config'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { NextFunction, Request, Response, RequestHandler } from 'express'; -import { decodeJwt } from 'jose'; -import { URL } from 'url'; -import { PluginEnvironment } from './types'; - -function setTokenCookie( - res: Response, - options: { token: string; secure: boolean; cookieDomain: string }, -) { - try { - const payload = decodeJwt(options.token); - res.cookie('token', options.token, { - expires: new Date(payload.exp ? payload.exp * 1000 : 0), - secure: options.secure, - sameSite: 'lax', - domain: options.cookieDomain, - path: '/', - httpOnly: true, - }); - } catch (_err) { - // Ignore - } -} - -export const createAuthMiddleware = async ( - config: Config, - appEnv: PluginEnvironment, -) => { - const baseUrl = config.getString('backend.baseUrl'); - const secure = baseUrl.startsWith('https://'); - const cookieDomain = new URL(baseUrl).hostname; - const authMiddleware: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ) => { - try { - const token = - getBearerTokenFromAuthorizationHeader(req.headers.authorization) || - (req.cookies?.token as string | undefined); - if (!token) { - res.status(401).send('Unauthorized'); - return; - } - try { - req.user = await appEnv.identity.getIdentity({ request: req }); - } catch { - await appEnv.tokenManager.authenticate(token); - } - if (!req.headers.authorization) { - // Authorization header may be forwarded by plugin requests - req.headers.authorization = `Bearer ${token}`; - } - if (token && token !== req.cookies?.token) { - setTokenCookie(res, { - token, - secure, - cookieDomain, - }); - } - next(); - } catch (error) { - res.status(401).send('Unauthorized'); - } - }; - return authMiddleware; -}; -``` - -Install cookie-parser: - -```bash -# From your Backstage root directory -yarn --cwd packages/backend add cookie-parser -``` - -Update routes in `packages/backend/src/index.ts`: - -```typescript -// packages/backend/src/index.ts from a create-app deployment - -import { createAuthMiddleware } from './authMiddleware'; -import cookieParser from 'cookie-parser'; - -// ... - -async function main() { - // ... - - const authMiddleware = await createAuthMiddleware(config, appEnv); - - const apiRouter = Router(); - apiRouter.use(cookieParser()); - // The auth route must be publicly available as it is used during login - apiRouter.use('/auth', await auth(authEnv)); - // Add a simple endpoint to be used when setting a token cookie - apiRouter.use('/cookie', authMiddleware, (_req, res) => { - res.status(200).send(`Coming right up`); - }); - // Only authenticated requests are allowed to the routes below - apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv)); - apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv)); - apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv)); - apiRouter.use(authMiddleware, notFoundHandler()); - - // ... -} -``` - -## New Backend System Setup - -Create `packages/backend/src/authMiddlewareFactory.ts`: - -```typescript -import { HostDiscovery } from '@backstage/backend-app-api'; -import { ServerTokenManager } from '@backstage/backend-common'; -import { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { - DefaultIdentityClient, - getBearerTokenFromAuthorizationHeader, -} from '@backstage/plugin-auth-node'; -import { NextFunction, Request, RequestHandler, Response } from 'express'; -import { decodeJwt } from 'jose'; -import lzstring from 'lz-string'; -import { URL } from 'url'; - -type AuthMiddlewareFactoryOptions = { - config: RootConfigService; - logger: LoggerService; -}; - -export const authMiddlewareFactory = ({ - config, - logger, -}: AuthMiddlewareFactoryOptions): RequestHandler => { - const baseUrl = config.getString('backend.baseUrl'); - const discovery = HostDiscovery.fromConfig(config); - const identity = DefaultIdentityClient.create({ discovery }); - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); - - return async (req: Request, res: Response, next: NextFunction) => { - const fullPath = `${req.baseUrl}${req.path}`; - - // Only apply auth to /api routes & skip auth for the following endpoints - // Add any additional plugin routes you want to whitelist eg. events - const nonAuthWhitelist = ['app', 'auth']; - const nonAuthRegex = new RegExp( - `^\/api\/(${nonAuthWhitelist.join('|')})(?=\/|$)\S*`, - ); - if (!fullPath.startsWith('/api/') || nonAuthRegex.test(fullPath)) { - next(); - return; - } - - try { - // Token cookies are compressed to reduce size - const cookieToken = lzstring.decompressFromEncodedURIComponent( - req.cookies.token, - ); - const token = - getBearerTokenFromAuthorizationHeader(req.headers.authorization) ?? - cookieToken; - - try { - // Attempt to authenticate as a frontend request token - await identity.authenticate(token); - } catch (err) { - // Attempt to authenticate as a backend request token - await tokenManager.authenticate(token); - } - - if (!req.headers.authorization) { - // Authorization header may be forwarded by plugin requests - req.headers.authorization = `Bearer ${token}`; - } - - if (token !== cookieToken) { - try { - const payload = decodeJwt(token); - res.cookie('token', token, { - // Compress token to reduce cookie size - encode: lzstring.compressToEncodedURIComponent, - expires: new Date((payload?.exp ?? 0) * 1000), - secure: baseUrl.startsWith('https://'), - sameSite: 'lax', - domain: new URL(baseUrl).hostname, - path: '/', - httpOnly: true, - }); - } catch { - // Ignore - } - } - next(); - } catch { - res.status(401).send(`Unauthorized`); - } - }; -}; -``` - -Install cookie-parser: - -```bash -# From your Backstage root directory -yarn --cwd packages/backend add cookie-parser @types/cookie-parser -``` - -Create a custom configured `rootHttpRouterService` in `packages/backend/src/customRootHttpRouterService.ts`: - -```typescript -import { rootHttpRouterServiceFactory } from '@backstage/backend-app-api'; -import cookieParser from 'cookie-parser'; -import { authMiddlewareFactory } from './authMiddlewareFactory'; - -export default rootHttpRouterServiceFactory({ - configure: ({ app, config, logger, middleware, routes }) => { - app.use(middleware.helmet()); - app.use(middleware.cors()); - app.use(middleware.compression()); - app.use(cookieParser()); - app.use(middleware.logging()); - - app.use(authMiddlewareFactory({ config, logger })); - - // Simple handler to set auth cookie for user - app.use('/api/cookie', (_, res) => { - res.status(200).send(); - }); - - app.use(routes); - - app.use(middleware.notFound()); - app.use(middleware.error()); - }, -}); -``` - -Update `packages/backend/src/index.ts` to add the custom `rootHttpRouterService` and override the default: - -```typescript -// ... -const backend = createBackend(); - -backend.add(import('./customRootHttpRouterService')); - -// ... -``` - -## Frontend Setup - -Create `packages/app/src/cookieAuth.ts`: - -```typescript -import type { IdentityApi } from '@backstage/core-plugin-api'; - -// Parses supplied JWT token and returns the payload -function parseJwt(token: string): { exp: number } { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map( - c => - // eslint-disable-next-line prefer-template - '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2), - ) - .join(''), - ); - - return JSON.parse(jsonPayload); -} - -// Returns milliseconds until the supplied JWT token expires -function msUntilExpiry(token: string): number { - const payload = parseJwt(token); - const remaining = - new Date(payload.exp * 1000).getTime() - new Date().getTime(); - return remaining; -} - -// Calls the specified url regularly using an auth token to set a token cookie -// to authorize regular HTTP requests when loading techdocs -export async function setTokenCookie(url: string, identityApi: IdentityApi) { - const { token } = await identityApi.getCredentials(); - if (!token) { - return; - } - - await fetch(url, { - mode: 'cors', - credentials: 'include', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - // Call this function again a few minutes before the token expires - const ms = msUntilExpiry(token) - 4 * 60 * 1000; - setTimeout( - () => { - setTokenCookie(url, identityApi); - }, - ms > 0 ? ms : 10000, - ); -} -``` - -```typescript -// required types and packages for example below - -import type { IdentityApi } from '@backstage/core-plugin-api'; -import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; - -// additional packages/app/src/App.tsx from a create-app deployment - -import { setTokenCookie } from './cookieAuth'; - -// ... - -const app = createApp({ - // ... - - components: { - SignInPage: props => { - const discoveryApi = useApi(discoveryApiRef); - return ( - { - setTokenCookie( - await discoveryApi.getBaseUrl('cookie'), - identityApi, - ); - - props.onSignInSuccess(identityApi); - }} - /> - ); - }, - }, - - // ... -}); - -// ... -``` - -**NOTE**: Most Backstage frontend plugins come with the support for the `IdentityApi`. -In case you already have a dozen of internal ones, you may need to update those too. -Assuming you follow the common plugin structure, the changes to your front-end may look like: - -```diff -// plugins/internal-plugin/src/api.ts -- import { createApiRef } from '@backstage/core-plugin-api'; -+ import { createApiRef, IdentityApi } from '@backstage/core-plugin-api'; -import { Config } from '@backstage/config'; -// ... - -type MyApiOptions = { - configApi: Config; -+ identityApi: IdentityApi; - // ... -} - -interface MyInterface { - getData(): Promise; -} - -export class MyApi implements MyInterface { - private configApi: Config; -+ private identityApi: IdentityApi; - // ... - - constructor(options: MyApiOptions) { - this.configApi = options.configApi; -+ this.identityApi = options.identityApi; - } - - async getMyData() { - const backendUrl = this.configApi.getString('backend.baseUrl'); - -+ const { token } = await this.identityApi.getCredentials(); - const requestUrl = `${backendUrl}/api/data/`; -- const response = await fetch(requestUrl); -+ const response = await fetch( - requestUrl, - { headers: { Authorization: `Bearer ${token}` } }, - ); - // ... - } -``` - -and - -```diff -// plugins/internal-plugin/src/plugin.ts - -import { - configApiRef, - createApiFactory, - createPlugin, -+ identityApiRef, -} from '@backstage/core-plugin-api'; -import { myPluginPageRouteRef } from './routeRefs'; -import { MyApi, myApiRef } from './api'; - -export const plugin = createPlugin({ - id: 'my-plugin', - routes: { - mainPage: myPluginPageRouteRef, - }, - apis: [ - createApiFactory({ - api: myApiRef, - deps: { - configApi: configApiRef, -+ identityApi: identityApiRef, - }, -- factory: ({ configApi }) => -- new MyApi({ configApi }), -+ factory: ({ configApi, identityApi }) => -+ new MyApi({ configApi, identityApi }), - }), - ], -}); -``` diff --git a/contrib/docs/tutorials/prometheus-metrics-output.png b/contrib/docs/tutorials/prometheus-metrics-output.png deleted file mode 100644 index f7fac739ac..0000000000 Binary files a/contrib/docs/tutorials/prometheus-metrics-output.png and /dev/null differ diff --git a/contrib/docs/tutorials/prometheus-metrics.md b/contrib/docs/tutorials/prometheus-metrics.md deleted file mode 100644 index 0db97c649e..0000000000 --- a/contrib/docs/tutorials/prometheus-metrics.md +++ /dev/null @@ -1,115 +0,0 @@ -# Prometheus - -> [!NOTE] -> The Prometheus metrics have been marked as deprecated and will be removed at a later point. The recommendation is to use the OpenTelemetry metrics by following the [Setup OpenTelemetry](https://backstage.io/docs/tutorials/setup-opentelemetry) documentation - -## Overview - -This is a small tutorial that goes over how to setup your Backstage instance to output metrics in a format that can be pulled in by Prometheus. - -## How to Setup Prometheus Metrics - -1. First we need to add the needed dependencies to the `package.json` in the `\packages\backend`: - - ```diff - // packages/backend/package.json - "dependencies": { - + "express-prom-bundle": "^7.0.0", - + "prom-client": "^15.0.0", - ``` - -2. Now we want to run `yarn install` from the root of the project to get those dependencies in place -3. Then we need to add a handler for the metrics by creating a file called `metrics.ts` in the `\packages\backend\src` folder -4. Next we add the following content to the `metrics.ts` file: - - ```ts - // packages/backend/src/metrics.ts - import { useHotCleanup } from '@backstage/backend-common'; - import { RequestHandler } from 'express'; - import promBundle from 'express-prom-bundle'; - import prom from 'prom-client'; - import * as url from 'url'; - - const rootRegEx = new RegExp('^/([^/]*)/.*'); - const apiRegEx = new RegExp('^/api/([^/]*)/.*'); - - export function normalizePath(req: any): string { - const path = url.parse(req.originalUrl || req.url).pathname || '/'; - - // Capture /api/ and the plugin name - if (apiRegEx.test(path)) { - return path.replace(apiRegEx, '/api/$1'); - } - - // Only the first path segment at root level - return path.replace(rootRegEx, '/$1'); - } - - /** - * Adds a /metrics endpoint, register default runtime metrics and instrument the router. - */ - export function metricsHandler(): RequestHandler { - // We can only initialize the metrics once and have to clean them up between hot reloads - useHotCleanup(module, () => prom.register.clear()); - - return promBundle({ - includeMethod: true, - includePath: true, - // Using includePath alone is problematic, as it will include path labels with high - // cardinality (e.g. path params). Instead we would have to template them. However, this - // is difficult, as every backend plugin might use different routes. Instead we only take - // the first directory of the path, to have at least an idea how each plugin performs: - normalizePath, - promClient: { collectDefaultMetrics: {} }, - }); - } - ``` - -5. Now we will extend the router configuration with the `metricsHandler`: - - ```diff - +import { metricsHandler } from './metrics'; - - ... - - const service = createServiceBuilder(module) - .loadConfig(config) - .addRouter('', await healthcheck(healthcheckEnv)) - + .addRouter('', metricsHandler()) - .addRouter('/api', apiRouter); - ``` - -6. You now have everything setup, from the `\packages\backend` folder run `yarn start` this will start up the backend -7. Now in a browser load up `http://localhost:7007/metrics`, if everything went smoothly you should see metrics in your browser something like this: - - ![Prometheus Metrics Example Output](prometheus-metrics-output.png) - -## Metrics - -The following sections goes over the included and experimental metrics available once you have completed this tutorial - -## Included - -This tutorials uses the [`express-prom-bundle`](https://github.com/jochen-schweizer/express-prom-bundle) and the [`prom-client`](https://github.com/siimon/prom-client) to make this all work. They both come with some built in metrics: - -- `express-prom-bundle` comes with 2 metrics: - - `up`: this normally will be just 1 - - `http_request_duration_seconds`: http latency histogram/summary labeled with `status_code`, `method` and `path` -- `prom-client` comes with a collection of metrics around memory, CPU, processes, etc. You can see the supported metrics in the `prom-client's` [`lib/metrics`](https://github.com/siimon/prom-client/tree/master/lib/metrics) folder. - -### Experimental - -There are some custom metrics that have been added to Backstage will be output for you, these are currently deemed experimental and may be changed or removed in a future release. Here is a rough list, again subject to changes: - -- `catalog_entities_count`: Total amount of entities in the catalog -- `catalog_registered_locations_count`: Total amount of registered locations in the catalog -- `catalog_relations_count`: Total amount of relations between entities -- `catalog_stitched_entities_count`: Amount of entities stitched -- `catalog_processed_entities_count`: Amount of entities processed -- `catalog_processing_duration_seconds`: Time spent executing the full processing flow -- `catalog_processors_duration_seconds`: Time spent executing catalog processors -- `catalog_processing_queue_delay_seconds`: The amount of delay between being scheduled for processing, and the start of actually being processed -- `scaffolder_task_count`: Tracks successful task runs. -- `scaffolder_task_duration`: a histogram which tracks the duration of a task run -- `scaffolder_step_count`: a count that tracks each step run -- `scaffolder_step_duration`: a histogram which tracks the duration of each step run diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md deleted file mode 100644 index 78b76cd1a2..0000000000 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ /dev/null @@ -1,53 +0,0 @@ -### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin - -ExampleComponent.tsx reference - -```tsx -import { Typography, Grid } from '@material-ui/core'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; -import { - InfoCard, - Header, - Page, - Content, - ContentHeader, - HeaderLabel, - SupportButton, -} from '@backstage/core-components'; -import { ExampleFetchComponent } from '../ExampleFetchComponent'; - -export const ExampleComponent = () => { - const identityApi = useApi(identityApiRef); - const userId = identityApi.getUserId(); - const profile = identityApi.getProfile(); - - return ( - -
- - -
- - - A description of your plugin goes here. - - - - - - {`${profile.displayName} | ${profile.email}`} - - - - - - - - -
- ); -}; -``` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md deleted file mode 100644 index d2c0553e34..0000000000 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ /dev/null @@ -1,103 +0,0 @@ -### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin - -ExampleFetchComponent.tsx reference - -```tsx -import useAsync from 'react-use/lib/useAsync'; -import Alert from '@material-ui/lab/Alert'; -import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api'; -import { Table, TableColumn, Progress } from '@backstage/core-components'; -import { graphql } from '@octokit/graphql'; - -const query = `{ - viewer { - repositories(first: 100) { - totalCount - nodes { - name - createdAt - description - diskUsage - isFork - } - pageInfo { - endCursor - hasNextPage - } - } - } -}`; - -type Node = { - name: string; - createdAt: string; - description: string; - diskUsage: number; - isFork: boolean; -}; - -type Viewer = { - repositories: { - totalCount: number; - nodes: Node[]; - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - }; -}; - -type DenseTableProps = { - viewer: Viewer; -}; - -export const DenseTable = ({ viewer }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Created', field: 'createdAt' }, - { title: 'Description', field: 'description' }, - { title: 'Disk Usage', field: 'diskUsage' }, - { title: 'Fork', field: 'isFork' }, - ]; - - return ( - - ); -}; - -export const ExampleFetchComponent = () => { - const auth = useApi(githubAuthApiRef); - - const { value, loading, error } = useAsync(async (): Promise => { - const token = await auth.getAccessToken(); - - const gqlEndpoint = graphql.defaults({ - // Uncomment baseUrl if using enterprise - // baseUrl: 'https://github.MY-BIZ.com/api', - headers: { - authorization: `token ${token}`, - }, - }); - const { viewer } = await gqlEndpoint(query); - return viewer; - }, []); - - if (loading) return ; - if (error) return {error.message}; - if (value && value.repositories) return ; - - return ( -
- ); -}; -``` diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/README.md b/contrib/kubernetes/basic_kubernetes_example_with_helm/README.md index c18329f59e..e4e4117b9c 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/README.md +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/README.md @@ -1,3 +1,6 @@ # Basic Kubernetes example with Helm +> [!NOTE] +> This documentation is deprecated and will be removed at a future date, please use the well-maintained [Backstage Helm Charts](https://github.com/backstage/charts) for this. + Note that these examples aim to show a minimal setup and do not include best practices for secure Kubernetes deployments. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/) for more information, or resources provided by your own organization. diff --git a/contrib/search/confluence/README.md b/contrib/search/confluence/README.md index 80beb9c169..95d74f6b2b 100644 --- a/contrib/search/confluence/README.md +++ b/contrib/search/confluence/README.md @@ -1,5 +1,8 @@ # Confluence +> [!NOTE] +> This documentation is deprecated and will be removed at a future date. Please use the well-maintained [`@backstage-community/plugin-search-backend-module-confluence-collator` Community Plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/confluence/plugins/search-backend-module-confluence-collator) instead. + These files help you add Confluence as a source to the Backstage Search plugin. To do so, add both files in this directory under the packages/backend/src/plugins/search/ pathway in your Backstage app. Then, add the following code to your packages/app/src/components/search/SearchPage.tsx: diff --git a/docs-ui/src/app/components/badge/components.tsx b/docs-ui/src/app/components/badge/components.tsx new file mode 100644 index 0000000000..a77a6b09d7 --- /dev/null +++ b/docs-ui/src/app/components/badge/components.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Badge } from '../../../../../packages/ui/src/components/Badge/Badge'; +import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; +import { RiBugLine } from '@remixicon/react'; + +export const Default = () => Banana; + +export const WithIcon = () => }>Banana; + +export const Sizes = () => ( + + Banana + Banana + +); diff --git a/docs-ui/src/app/components/badge/page.mdx b/docs-ui/src/app/components/badge/page.mdx new file mode 100644 index 0000000000..fb934c57ad --- /dev/null +++ b/docs-ui/src/app/components/badge/page.mdx @@ -0,0 +1,41 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { Default, WithIcon, Sizes } from './components'; +import { badgePropDefs } from './props-definition'; +import { usage, preview, withIcons, sizes } from './snippets'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { BadgeDefinition } from '../../../utils/definitions'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; + + + +} code={preview} /> + +## Usage + + + +## API reference + +### Badge + + + +## Examples + +### With icons + +} code={withIcons} /> + +### Sizes + +} code={sizes} /> + + + + diff --git a/docs-ui/src/app/components/badge/props-definition.tsx b/docs-ui/src/app/components/badge/props-definition.tsx new file mode 100644 index 0000000000..b193c1cef2 --- /dev/null +++ b/docs-ui/src/app/components/badge/props-definition.tsx @@ -0,0 +1,27 @@ +import { + classNamePropDefs, + childrenPropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +export const badgePropDefs: Record = { + icon: { + type: 'enum', + values: ['ReactNode'], + description: 'Icon displayed before the badge text.', + }, + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + description: ( + <> + Visual size of the badge. Use small for inline or dense + layouts, medium for standalone badges. + + ), + }, + ...childrenPropDefs, + ...classNamePropDefs, +}; diff --git a/docs-ui/src/app/components/badge/snippets.ts b/docs-ui/src/app/components/badge/snippets.ts new file mode 100644 index 0000000000..dc765d2b7d --- /dev/null +++ b/docs-ui/src/app/components/badge/snippets.ts @@ -0,0 +1,12 @@ +export const usage = `import { Badge } from '@backstage/ui'; + +Badge`; + +export const preview = `Banana`; + +export const withIcons = `}>Banana`; + +export const sizes = ` + Banana + Banana +`; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index bd37760ea4..3d958f5b7c 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -17,6 +17,10 @@ export const components: Page[] = [ title: 'Avatar', slug: 'avatar', }, + { + title: 'Badge', + slug: 'badge', + }, { title: 'Box', slug: 'box', diff --git a/docs/conf/user-interface/index.md b/docs/conf/user-interface/index.md index 107fde3d57..48d6c6bcf6 100644 --- a/docs/conf/user-interface/index.md +++ b/docs/conf/user-interface/index.md @@ -634,3 +634,53 @@ export const myTheme = createUnifiedTheme({ ``` + +
+ Missing v5 prefix for MUI 5 class names + +If you are using MUI 5 components in the main app, you may notice that the rendered elements have a `v5-` prefix in front of the MUI class names, but not when you try to use the class name props in code. + +Example: + +```html +
diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 9d5507c012..7b41311ea4 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -200,7 +200,7 @@ export const examplePlugin = createFrontendPlugin({ ## Plugin specific extensions -There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. +There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/index.md), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. ```tsx title="in src/plugin.ts - An example entity content extension" import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; diff --git a/docs/references/glossary.md b/docs/references/glossary.md index 31b7115efa..b4f42a34a6 100644 --- a/docs/references/glossary.md +++ b/docs/references/glossary.md @@ -120,7 +120,7 @@ An executable or [package](#package) file with all of the necessary information 1. A centralized, self-service interface providing developers with all the necessary resources, tools, documentation, and information to effectively build, integrate, deploy, and manage software products within an organization. -2. Backstage is a specific example of a developer portal, designed as a centralized system with a user interface and database to streamline development and maintenance of an organization's software projects. It features a robust [Software Catalog](#software-catalog) that centralizes and organizes access to the organization's services, websites, mobile features, libraries, and other software components. Backstage also includes [Software Templates](#software-templates-aka-scaffolder) that simplify the creation of new projects and components. +2. Backstage is a specific example of a developer portal, designed as a centralized system with a user interface and database to streamline development and maintenance of an organization's software projects. It features a robust [Software Catalog](#software-catalog) that centralizes and organizes access to the organization's services, websites, mobile features, libraries, and other software components. Backstage also includes [Software Templates](#software-templates) that simplify the creation of new projects and components. Backstage is both a developer portal and a plugin-based framework for creating new custom developer portals. @@ -353,7 +353,7 @@ A specific type of dynamic access control associated with a [resource](#resource ## Scaffolder -Another name for [Software Templates](#software-templates-aka-scaffolder). (The term comes from the use of Software Templates as _scaffolds_ for building new components and projects.) +Another name for [Software Templates](#software-templates). (The term comes from the use of Software Templates as _scaffolds_ for building new components and projects.) ## Scope diff --git a/docs/releases/v1.8.0-changelog.md b/docs/releases/v1.8.0-changelog.md index 54447a3960..cc5c26bf8d 100644 --- a/docs/releases/v1.8.0-changelog.md +++ b/docs/releases/v1.8.0-changelog.md @@ -646,7 +646,7 @@ - `step`: The name of the step that was run - `result`: A string describing whether the task ran successfully, failed, or was skipped - You can find a guide for running Prometheus metrics here: + You can find a guide for running Prometheus metrics here: - 5921b5ce49: - The GitLab Project ID for the `publish:gitlab:merge-request` action is now passed through the query parameter `project` in the `repoUrl`. It still allows people to not use the `projectid` and use the `repoUrl` with the `owner` and `repo` query parameters instead. This makes it easier to publish to repositories instead of writing the full path to the project. diff --git a/docs/service_specification.schema.json b/docs/service_specification.schema.json deleted file mode 100644 index a17f67d30a..0000000000 --- a/docs/service_specification.schema.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "backstage.io/v1alpha1", - "type": "object", - "title": "A JSON Schema for Backstage catalog entities.", - "description": "Each descriptor file has a number of entities. This schema matches each of those.", - "examples": [ - { - "apiVersion": "backstage.io/v1alpha1", - "kind": "Component", - "metadata": { - "name": "LoremService", - "description": "Creates Lorems like a pro.", - "labels": { - "product_name": "Random value Generator" - }, - "annnotations": { - "docs": "https://github.com/..../tree/develop/doc" - }, - "teams": [ - { - "name": "Team super great", - "email": "greatTeam@geemel.com" - } - ] - }, - "spec": { - "type": "service", - "lifecycle": "production", - "owner": "tools@example.com" - } - } - ], - "required": ["apiVersion", "kind", "metadata"], - "additionalProperties": false, - "properties": { - "apiVersion": { - "type": "string", - "description": "Version of the specification format for a particular file is written against.", - "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] - }, - "kind": { - "type": "string", - "description": "High level entity type being described, from the Backstage system model.", - "enum": ["Component"] - }, - "metadata": { - "$ref": "#/definitions/metadata" - }, - "spec": { - "$ref": "#/definitions/spec" - } - }, - "definitions": { - "metadata": { - "type": "object", - "description": "Metadata about the entity, i.e. things that aren't directly part of the entity specification itself.", - "required": ["name"], - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z0-9A-Z_.-]{1,63}$", - "description": "The name of the entity. This name is both meant for human eyes to recognize the entity, and for machines and other components to reference the entity" - }, - "description": { - "type": "string", - "description": "A human readable description of the entity, to be shown in Backstage. Should be kept short and informative." - }, - "namespace": { - "type": "string", - "description": "The name of a namespace that the entity belongs to." - }, - "labels": { - "type": "object", - "description": "Labels are optional key/value pairs of that are attached to the entity, and their use is identical to kubernetes object labels.", - "additionalProperties": true, - "patternProperties": { - "^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": { - "type": "string", - "pattern": "^[a-z0-9A-Z_.-]{1,63}$" - } - } - }, - "annnotations": { - "type": "object", - "description": "Arbitrary non-identifying metadata attached to the entity, identical in use to kubernetes object annotations.", - "additionalProperties": true, - "patternProperties": { - "^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": { - "type": "string", - "pattern": "^[a-z0-9A-Z_.-]{1,63}$" - } - } - } - } - }, - "spec": { - "type": "object", - "description": "Actual specification data that describes the entity. TODO: shape depend on `kind`", - "required": ["type", "lifecycle", "owner"], - "additionalProperties": true, - "properties": { - "type": { - "type": "string", - "description": "The type of component.", - "examples": ["service"] - }, - "lifecycle": { - "type": "string", - "description": "The lifecycle step that this component is in.", - "examples": ["production"] - }, - "owner": { - "type": "string", - "description": "The owner of the component.", - "examples": ["tools@example.com"] - } - } - } - } -} diff --git a/docs/tutorials/quickstart-app-plugin--old.md b/docs/tutorials/quickstart-app-plugin--old.md deleted file mode 100644 index 59fa6143b8..0000000000 --- a/docs/tutorials/quickstart-app-plugin--old.md +++ /dev/null @@ -1,315 +0,0 @@ ---- -id: quickstart-app-plugin--old -title: Adding Custom Plugin to Existing Monorepo App (Old Frontend System) -description: Tutorial for adding a custom plugin to an existing Backstage monorepo application ---- - -::::info -This documentation is for Backstage apps that still use the old frontend -system. If your app uses the new frontend system, read the -[current guide](./quickstart-app-plugin.md) instead. -:::: - -###### September 15th 2020 - v0.1.1-alpha.21 - -
- -> This document takes you through setting up a new plugin for your existing -> monorepo with a _GitHub provider already setup_. If you don't have either of -> those, you can clone -> [simple-backstage-app](https://github.com/johnson-jesse/simple-backstage-app) -> which this document builds on. -> -> This document does not cover authoring a plugin for sharing with the Backstage -> community. That will have to be a later discussion. -> -> We start with a skeleton plugin install. And after verifying its -> functionality, extend the Sidebar to make our life easy. Finally, we add -> custom code to display GitHub repository information. -> -> This document assumes you have Node.js 16 active along with Yarn and Python. -> Please note, that at the time of this writing, the current version is -> 0.1.1-alpha.21. This guide can still be used with future versions, just, -> verify as you go. If you run into issues, you can compare your setup with mine -> here > -> [simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin). - -## The Skeleton Plugin - -1. Start by using the built-in creator. From the terminal and root of your - project run: `yarn new` and select `frontend-plugin`. -1. Enter a plugin ID. I used `github-playground` -1. When the process finishes, let's start the backend: - `yarn --cwd packages/backend start` -1. If you see errors starting, refer to - [Auth Configuration](https://backstage.io/docs/auth/) for more information on - environment variables. -1. And now the frontend, from a new terminal window and the root of your - project: `yarn start` -1. As usual, a browser window should popup loading the App. -1. Now manually navigate to our plugin page from your browser: - `http://localhost:3000/github-playground` -1. You should see successful verbiage for this endpoint, - `Welcome to github-playground!` - -## The Shortcut - -Let's add a shortcut. - -1. Open and modify `root: packages > app > src > components > Root.tsx` with the - following: - -```tsx -import GitHubIcon from '@material-ui/icons/GitHub'; -... - -``` - -Simple! The App will reload with your changes automatically. You should now see -a GitHub icon displayed in the sidebar. Clicking that will link to our new -plugin. And now, the API fun begins. - -## The Identity - -Our first modification will be to extract information from the Identity API. - -1. Start by opening - `root: plugins > github-playground > src > components > ExampleComponent > ExampleComponent.tsx` -1. Add two new imports - -```tsx -// Add identityApiRef to the list of imported from core -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; -``` - -3. Adjust the ExampleComponent from inline to block - -_from inline:_ - -```tsx -const ExampleComponent = () => ( ... ) -``` - -_to block:_ - -```tsx -const ExampleComponent = () => { - - return ( - ... - ) -} -``` - -4. Now add our hook and const data before the return statement - -```tsx -// our API hook -const identityApi = useApi(identityApiRef); - -// data to use -const userId = identityApi.getUserId(); -const profile = identityApi.getProfile(); -``` - -5. Finally, update the InfoCard's jsx to use our new data - -```tsx - - - {`${profile.displayName} | ${profile.email}`} - - -``` - -If everything is saved, you should see your name, id, and email on the -github-playground page. Our data accessed is synchronous. So we just grab and -go. - -https://github.com/backstage/backstage/tree/master/contrib - -6. Here is the entire file for reference - [ExampleComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md) - -## The Wipe - -The last file we will touch is ExampleFetchComponent. Because of the number of -changes, let's start by wiping this component clean. - -1. Start by opening - `root: plugins > github-playground > src > components > ExampleFetchComponent > ExampleFetchComponent.tsx` -1. Replace everything in the file with the following: - -```tsx -import useAsync from 'react-use/lib/useAsync'; -import Alert from '@material-ui/lab/Alert'; -import { Table, TableColumn, Progress } from '@backstage/core-components'; -import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api'; -import { graphql } from '@octokit/graphql'; - -export const ExampleFetchComponent = () => { - return
Nothing to see yet
; -}; -``` - -3. Save that and ensure you see no errors. Comment out the unused imports if - your linter gets in the way. - -###### We will add a lot to this file for the sake of ease. Please don't do this in production code! - -## The Graph Model - -GitHub has a GraphQL API available for interacting. Let's start by adding our -basic repository query - -1. Add the query const statement outside ExampleFetchComponent - -```tsx -const query = `{ - viewer { - repositories(first: 100) { - totalCount - nodes { - name - createdAt - description - diskUsage - isFork - } - pageInfo { - endCursor - hasNextPage - } - } - } -}`; -``` - -2. Using this structure as a guide, we will break our query into type parts -3. Add the following outside of ExampleFetchComponent - -```tsx -type Node = { - name: string; - createdAt: string; - description: string; - diskUsage: number; - isFork: boolean; -}; - -type Viewer = { - repositories: { - totalCount: number; - nodes: Node[]; - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - }; -}; -``` - -## The Table Model - -Using Backstage's own component library, let's define a custom table. This -component will get used if we have data to display. - -1. Add the following outside of ExampleFetchComponent - -```tsx -type DenseTableProps = { - viewer: Viewer; -}; - -export const DenseTable = ({ viewer }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Created', field: 'createdAt' }, - { title: 'Description', field: 'description' }, - { title: 'Disk Usage', field: 'diskUsage' }, - { title: 'Fork', field: 'isFork' }, - ]; - - return ( -
- ); -}; -``` - -## The Fetch - -We're ready to flush out our fetch component - -1. Add our api hook inside ExampleFetchComponent - -```tsx -const auth = useApi(githubAuthApiRef); -``` - -2. The access token we need to make our GitHub request and the request itself is - obtained in an asynchronous manner. -3. Add the `useAsync` block inside the ExampleFetchComponent - -```tsx -const { value, loading, error } = useAsync(async (): Promise => { - const token = await auth.getAccessToken(); - - const gqlEndpoint = graphql.defaults({ - // Uncomment baseUrl if using enterprise - // baseUrl: 'https://github.MY-BIZ.com/api', - headers: { - authorization: `token ${token}`, - }, - }); - const { viewer } = await gqlEndpoint(query); - return viewer; -}, []); -``` - -4. The resolved data is conveniently destructured with `value` containing our - Viewer type. `loading` as a boolean, self explanatory. And `error` which is - present only if necessary. So let's use those as the first 3 of 4 multi - return statements. -5. Add the _if return_ blocks below our async block - -```tsx -if (loading) return ; -if (error) return {error.message}; -if (value && value.repositories) return ; -``` - -6. The third line here utilizes our custom table accepting our Viewer type. -7. Finally, we add our _else return_ block to catch any other scenarios. - -```tsx -return ( -
-); -``` - -8. After saving that, and given we don't have any errors, you should see a table - with basic information on your repositories. -9. Here is the entire file for reference - [ExampleFetchComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md) -10. We finished! You should see your own GitHub repository's information - displayed in a basic table. If you run into issues, you can compare the repo - that backs this document, - [simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin) - -## Where to go from here - -> Break apart ExampleFetchComponent into smaller logical parts contained in -> their own files. Rename your components to something other than ExampleXxx. -> -> You might be really proud of a plugin you develop. Consider sharing it with -> the Backstage community by contributing to the [community-plugins repository](https://github.com/backstage/community-plugins). diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md deleted file mode 100644 index 20e2cea453..0000000000 --- a/docs/tutorials/quickstart-app-plugin.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -id: quickstart-app-plugin -title: Adding Custom Plugin to Existing Monorepo App -description: Tutorial for adding a custom plugin to an existing Backstage monorepo application ---- - -::::info -This documentation is written for the new frontend system, which is the default -in new Backstage apps. If your Backstage app still uses the old frontend system, -read the [old frontend system version of this guide](./quickstart-app-plugin--old.md) -instead. -:::: - -> This document takes you through setting up a new plugin for your existing -> monorepo with a _GitHub provider already setup_. -> -> This document does not cover authoring a plugin for sharing with the Backstage -> community. That will have to be a later discussion. -> -> We start with a skeleton plugin install. And after verifying its -> functionality, we add custom code to display GitHub repository information. - -## The Skeleton Plugin - -1. Start by using the built-in creator. From the terminal and root of your - project run: `yarn new` and select `frontend-plugin`. -1. Enter a plugin ID. We'll use `github-playground` for this tutorial. -1. When the process finishes, let's start the backend: - `yarn --cwd packages/backend start` -1. If you see errors starting, refer to - [Auth Configuration](https://backstage.io/docs/auth/) for more information on - environment variables. -1. And now the frontend, from a new terminal window and the root of your - project: `yarn start` -1. As usual, a browser window should popup loading the App. -1. Now manually navigate to the plugin page from your browser: - `http://localhost:3000/github-playground` -1. You should see successful verbiage for this endpoint, - `Welcome to github-playground!` - -With the new frontend system, plugins are auto-discovered when installed as -dependencies of your `packages/app` package. The plugin was already added there -by `yarn new`, so the route and a sidebar item are available without any manual -wiring in `App.tsx` or `Root.tsx`. - -## The Identity - -Our first modification will be to extract information from the Identity API. - -1. Start by opening - `root: plugins > github-playground > src > components > ExampleComponent > ExampleComponent.tsx` -1. Add two new imports - -```tsx -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; -``` - -3. Adjust the ExampleComponent from inline to block - -_from inline:_ - -```tsx -const ExampleComponent = () => ( ... ) -``` - -_to block:_ - -```tsx -const ExampleComponent = () => { - - return ( - ... - ) -} -``` - -4. Now add our hook and const data before the return statement - -```tsx -const identityApi = useApi(identityApiRef); - -const userId = identityApi.getUserId(); -const profile = identityApi.getProfile(); -``` - -5. Finally, update the InfoCard's jsx to use our new data - -```tsx - - - {`${profile.displayName} | ${profile.email}`} - - -``` - -If everything is saved, you should see your name, id, and email on the -github-playground page. Our data accessed is synchronous. So we just grab and -go. - -https://github.com/backstage/backstage/tree/master/contrib - -6. Here is the entire file for reference - [ExampleComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md) - -## The Wipe - -The last file we will touch is ExampleFetchComponent. Because of the number of -changes, let's start by wiping this component clean. - -1. Start by opening - `root: plugins > github-playground > src > components > ExampleFetchComponent > ExampleFetchComponent.tsx` -1. Replace everything in the file with the following: - -```tsx -import useAsync from 'react-use/lib/useAsync'; -import Alert from '@material-ui/lab/Alert'; -import { Table, TableColumn, Progress } from '@backstage/core-components'; -import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api'; -import { graphql } from '@octokit/graphql'; - -export const ExampleFetchComponent = () => { - return
Nothing to see yet
; -}; -``` - -3. Save that and ensure you see no errors. Comment out the unused imports if - your linter gets in the way. - -###### We will add a lot to this file for the sake of ease. Please don't do this in productional code! - -## The Graph Model - -GitHub has a GraphQL API available for interacting. Let's start by adding our -basic repository query - -1. Add the query const statement outside ExampleFetchComponent - -```tsx -const query = `{ - viewer { - repositories(first: 100) { - totalCount - nodes { - name - createdAt - description - diskUsage - isFork - } - pageInfo { - endCursor - hasNextPage - } - } - } -}`; -``` - -2. Using this structure as a guide, we will break our query into type parts -3. Add the following outside of ExampleFetchComponent - -```tsx -type Node = { - name: string; - createdAt: string; - description: string; - diskUsage: number; - isFork: boolean; -}; - -type Viewer = { - repositories: { - totalCount: number; - nodes: Node[]; - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - }; -}; -``` - -## The Table Model - -Using Backstage's own component library, let's define a custom table. This -component will get used if we have data to display. - -1. Add the following outside of ExampleFetchComponent - -```tsx -type DenseTableProps = { - viewer: Viewer; -}; - -export const DenseTable = ({ viewer }: DenseTableProps) => { - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Created', field: 'createdAt' }, - { title: 'Description', field: 'description' }, - { title: 'Disk Usage', field: 'diskUsage' }, - { title: 'Fork', field: 'isFork' }, - ]; - - return ( -
- ); -}; -``` - -## The Fetch - -We're ready to flush out our fetch component - -1. Add our api hook inside ExampleFetchComponent - -```tsx -const auth = useApi(githubAuthApiRef); -``` - -2. The access token we need to make our GitHub request and the request itself is - obtained in an asynchronous manner. -3. Add the `useAsync` block inside the ExampleFetchComponent - -```tsx -const { value, loading, error } = useAsync(async (): Promise => { - const token = await auth.getAccessToken(); - - const gqlEndpoint = graphql.defaults({ - // Uncomment baseUrl if using enterprise - // baseUrl: 'https://github.MY-BIZ.com/api', - headers: { - authorization: `token ${token}`, - }, - }); - const { viewer } = await gqlEndpoint(query); - return viewer; -}, []); -``` - -4. The resolved data is conveniently destructured with `value` containing our - Viewer type. `loading` as a boolean, self explanatory. And `error` which is - present only if necessary. So let's use those as the first 3 of 4 multi - return statements. -5. Add the _if return_ blocks below our async block - -```tsx -if (loading) return ; -if (error) return {error.message}; -if (value && value.repositories) return ; -``` - -6. The third line here utilizes our custom table accepting our Viewer type. -7. Finally, we add our _else return_ block to catch any other scenarios. - -```tsx -return ( -
-); -``` - -8. After saving that, and given we don't have any errors, you should see a table - with basic information on your repositories. -9. Here is the entire file for reference - [ExampleFetchComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md) -10. We finished! You should see your own GitHub repository's information - displayed in a basic table. If you run into issues, you can compare the repo - that backs this document, - [simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin) - -## Where to go from here - -> Break apart ExampleFetchComponent into smaller logical parts contained in -> their own files. Rename your components to something other than ExampleXxx. -> -> You might be really proud of a plugin you develop. Consider sharing it with -> the Backstage community by contributing to the [community-plugins repository](https://github.com/backstage/community-plugins). diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index 09d25dfccf..f93731a427 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -655,7 +655,6 @@ export default { description: 'Technical tutorials and guides.', }, [ - 'tutorials/quickstart-app-plugin', 'tutorials/configuring-plugin-databases', 'tutorials/manual-knex-rollback', 'tutorials/switching-sqlite-postgres', diff --git a/mkdocs.yml b/mkdocs.yml index e01ab2a431..4b32556c96 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -221,7 +221,6 @@ nav: - React Router 6.0 Migration: 'tutorials/react-router-stable-migration.md' - Package Role Migration: 'tutorials/package-role-migration.md' - Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md' - - Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md' - Manual Rollback using Knex: 'tutorials/manual-knex-rollback.md' - Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md' - Using the Backstage Proxy from Within a Plugin: 'tutorials/using-backstage-proxy-within-plugin.md' diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 3d09146b0d..40352c5972 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -585,7 +585,7 @@ export interface Config { /** Database connection configuration, select base database type using the `client` field */ database: { /** Default database client to use */ - client: 'better-sqlite3' | 'sqlite3' | 'pg'; + client: 'better-sqlite3' | 'sqlite3' | 'pg' | 'embedded-postgres'; /** * Base database connection string, or object with individual connection properties * @visibility secret diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts index 1eb2d7d42b..85cbaaa73b 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts @@ -413,6 +413,56 @@ describe('HostDiscovery', () => { ); }); + describe('backend.baseUrl warnings', () => { + const env = process.env as Record; + const originalNodeEnv = env.NODE_ENV; + + afterEach(() => { + if (originalNodeEnv) { + env.NODE_ENV = originalNodeEnv; + } else { + delete env.NODE_ENV; + } + }); + + it('warns when backend.baseUrl is a localhost URL and NODE_ENV is production', () => { + env.NODE_ENV = 'production'; + const logger = mockServices.logger.mock(); + + HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:7007', + listen: { port: 7007, host: 'localhost' }, + }, + }), + { logger }, + ); + + expect(logger.warn).toHaveBeenCalledWith( + `backend.baseUrl is set to a localhost URL and NODE_ENV is 'production'. This is likely a misconfiguration — localhost URLs are not reachable by other services in a deployed environment. Prefer setting it to a routable URL that can be resolved and reached both by your app and by other plugin deployments / services.`, + ); + }); + + it('warns when backend.baseUrl is not a valid URL', () => { + const logger = mockServices.logger.mock(); + + HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'not-a-valid-url', + listen: { port: 7007, host: 'localhost' }, + }, + }), + { logger }, + ); + + expect(logger.warn).toHaveBeenCalledWith( + `backend.baseUrl config value 'not-a-valid-url' does not appear to be a valid URL.`, + ); + }); + }); + it('only accepts SRV URLs in the internal target', async () => { expect(() => HostDiscovery.fromConfig( diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts index 18ed72a085..7e25ba66c4 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts @@ -152,6 +152,27 @@ export class HostDiscovery implements DiscoveryService { }; static fromConfig(config: RootConfigService, options?: HostDiscoveryOptions) { + // The getExternalBaseUrl implementation relies on the backend base URL + // being a valid, non-local URL that others will be able to route to. + const baseUrl = config.getString('backend.baseUrl'); + try { + const { hostname } = new URL(baseUrl); + const isLocalhost = + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '::'; + if (isLocalhost && process.env.NODE_ENV === 'production') { + options?.logger?.warn( + `backend.baseUrl is set to a localhost URL and NODE_ENV is '${process.env.NODE_ENV}'. This is likely a misconfiguration — localhost URLs are not reachable by other services in a deployed environment. Prefer setting it to a routable URL that can be resolved and reached both by your app and by other plugin deployments / services.`, + ); + } + } catch { + options?.logger?.warn( + `backend.baseUrl config value '${baseUrl}' does not appear to be a valid URL.`, + ); + } + const discovery = new HostDiscovery(new SrvResolvers()); discovery.#updateResolvers(config, options?.defaultEndpoints); diff --git a/packages/cli-module-build/package.json b/packages/cli-module-build/package.json index 605df9a542..55d3a2558e 100644 --- a/packages/cli-module-build/package.json +++ b/packages/cli-module-build/package.json @@ -77,6 +77,7 @@ "node-stdlib-browser": "^1.3.1", "npm-packlist": "^5.0.0", "p-queue": "^6.6.2", + "portfinder": "^1.0.32", "postcss": "^8.1.0", "postcss-import": "^16.1.0", "process": "^0.11.10", @@ -106,6 +107,15 @@ "@types/fs-extra": "^11.0.0", "@types/lodash": "^4.14.151", "@types/npm-packlist": "^3.0.0", - "@types/shell-quote": "^1.7.5" + "@types/shell-quote": "^1.7.5", + "embedded-postgres": "18.3.0-beta.16" + }, + "peerDependencies": { + "embedded-postgres": "^18.3.0-beta.16" + }, + "peerDependenciesMeta": { + "embedded-postgres": { + "optional": true + } } } diff --git a/packages/cli-module-build/src/commands/package/start/startBackend.ts b/packages/cli-module-build/src/commands/package/start/startBackend.ts index a36a93b8ff..7b71e52da3 100644 --- a/packages/cli-module-build/src/commands/package/start/startBackend.ts +++ b/packages/cli-module-build/src/commands/package/start/startBackend.ts @@ -23,6 +23,7 @@ import { runBackend } from '../../../lib/runner'; interface StartBackendOptions { targetDir: string; checksEnabled: boolean; + configPaths?: string[]; inspectEnabled?: boolean | string; inspectBrkEnabled?: boolean | string; linkedWorkspace?: string; @@ -33,6 +34,7 @@ export async function startBackend(options: StartBackendOptions) { const waitForExit = await runBackend({ targetDir: options.targetDir, entry: 'src/index', + configPaths: options.configPaths, inspectEnabled: options.inspectEnabled, inspectBrkEnabled: options.inspectBrkEnabled, linkedWorkspace: options.linkedWorkspace, @@ -56,6 +58,7 @@ export async function startBackendPlugin(options: StartBackendOptions) { const waitForExit = await runBackend({ targetDir: options.targetDir, entry: 'dev/index', + configPaths: options.configPaths, inspectEnabled: options.inspectEnabled, inspectBrkEnabled: options.inspectBrkEnabled, require: options.require, diff --git a/packages/cli-module-build/src/lib/runner/runBackend.test.ts b/packages/cli-module-build/src/lib/runner/runBackend.test.ts index 2b97ae352c..b6ac8fd54d 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.test.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.test.ts @@ -49,6 +49,21 @@ jest.mock('ctrlc-windows', () => ({ ctrlc: jest.fn(), })); +const mockToConfig = jest.fn(); + +jest.mock('@backstage/config-loader', () => ({ + ConfigSources: { + default: () => ({}), + toConfig: (...args: any[]) => mockToConfig(...args), + }, +})); + +const mockStartEmbeddedDb = jest.fn(); + +jest.mock('./startEmbeddedDb', () => ({ + startEmbeddedDb: (...args: any[]) => mockStartEmbeddedDb(...args), +})); + describe('runBackend', () => { let originalEnv: NodeJS.ProcessEnv; let originalPlatform: string; @@ -68,6 +83,12 @@ describe('runBackend', () => { // Mock process.once to prevent actual signal handling jest.spyOn(process, 'once').mockReturnValue(process); + + mockToConfig.mockResolvedValue({ + close: jest.fn(), + getOptionalString: () => undefined, + }); + mockStartEmbeddedDb.mockReset(); }); afterEach(() => { @@ -82,92 +103,73 @@ describe('runBackend', () => { }); describe('--no-node-snapshot argument handling', () => { - it('should pass --no-node-snapshot when NODE_OPTIONS is not set', () => { + it('should pass --no-node-snapshot when NODE_OPTIONS is not set', async () => { delete process.env.NODE_OPTIONS; - runBackend({ - entry: 'src/index', - }); + runBackend({ entry: 'src/index' }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toContain('--no-node-snapshot'); }); - it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', () => { + it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', async () => { process.env.NODE_OPTIONS = '--max-old-space-size=4096'; - runBackend({ - entry: 'src/index', - }); + runBackend({ entry: 'src/index' }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toContain('--no-node-snapshot'); }); - it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', () => { + it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', async () => { process.env.NODE_OPTIONS = '--node-snapshot --max-old-space-size=4096'; - runBackend({ - entry: 'src/index', - }); + runBackend({ entry: 'src/index' }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).not.toContain('--no-node-snapshot'); }); - it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', () => { + it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', async () => { process.env.NODE_OPTIONS = '--max-old-space-size=4096 --node-snapshot --inspect'; - runBackend({ - entry: 'src/index', - }); + runBackend({ entry: 'src/index' }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).not.toContain('--no-node-snapshot'); }); - it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', () => { + it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', async () => { process.env.NODE_OPTIONS = '--max-old-space-size=4096 '; - runBackend({ - entry: 'src/index', - }); + runBackend({ entry: 'src/index' }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toContain('--no-node-snapshot'); }); - it('should pass --no-node-snapshot alongside other option args like --inspect', () => { + it('should pass --no-node-snapshot alongside other option args like --inspect', async () => { delete process.env.NODE_OPTIONS; - runBackend({ - entry: 'src/index', - inspectEnabled: true, - }); + runBackend({ entry: 'src/index', inspectEnabled: true }); - // Fast-forward past the debounce delay (100ms) - jest.advanceTimersByTime(100); + await jest.advanceTimersByTimeAsync(100); expect(mockSpawn).toHaveBeenCalled(); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; @@ -175,4 +177,62 @@ describe('runBackend', () => { expect(spawnArgs).toContain('--inspect'); }); }); + + describe('embedded-postgres support', () => { + it('should start embedded DB and inject config when database client is embedded-postgres', async () => { + mockToConfig.mockResolvedValue({ + close: jest.fn(), + getOptionalString: (key: string) => + key === 'backend.database.client' ? 'embedded-postgres' : undefined, + }); + mockStartEmbeddedDb.mockResolvedValue({ + connection: { + host: 'localhost', + user: 'postgres', + password: 'password', + port: 5555, + }, + close: jest.fn(), + }); + + runBackend({ entry: 'src/index' }); + await jest.advanceTimersByTimeAsync(100); + + expect(mockStartEmbeddedDb).toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record< + string, + string + >; + const injected = JSON.parse(spawnEnv.APP_CONFIG_backend_database); + expect(injected).toEqual({ + client: 'pg', + connection: { + host: 'localhost', + user: 'postgres', + password: 'password', + port: 5555, + }, + }); + }); + + it('should not start embedded DB for other database clients', async () => { + mockToConfig.mockResolvedValue({ + close: jest.fn(), + getOptionalString: (key: string) => + key === 'backend.database.client' ? 'better-sqlite3' : undefined, + }); + + runBackend({ entry: 'src/index' }); + await jest.advanceTimersByTimeAsync(100); + + expect(mockStartEmbeddedDb).not.toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record< + string, + string + >; + expect(spawnEnv.APP_CONFIG_backend_database).toBeUndefined(); + }); + }); }); diff --git a/packages/cli-module-build/src/lib/runner/runBackend.ts b/packages/cli-module-build/src/lib/runner/runBackend.ts index 86c249e8be..464ff10cec 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -20,10 +20,15 @@ import { ctrlc } from 'ctrlc-windows'; import { IpcServer, ServerDataStore } from '../ipc'; import debounce from 'lodash/debounce'; import { fileURLToPath } from 'node:url'; -import { isAbsolute as isAbsolutePath } from 'node:path'; +import { + isAbsolute as isAbsolutePath, + resolve as resolvePath, +} from 'node:path'; import { targetPaths } from '@backstage/cli-common'; +import { ConfigSources } from '@backstage/config-loader'; import spawn from 'cross-spawn'; +import { startEmbeddedDb } from './startEmbeddedDb'; const loaderArgs = [ '--enable-source-maps', @@ -45,6 +50,8 @@ export type RunBackendOptions = { require?: string | string[]; /** An external linked workspace to override module resolution towards */ linkedWorkspace?: string; + /** Config file paths from --config flags */ + configPaths?: string[]; }; export async function runBackend(options: RunBackendOptions) { @@ -57,6 +64,19 @@ export async function runBackend(options: RunBackendOptions) { const server = new IpcServer(); ServerDataStore.bind(server); + const extraEnv: Record = {}; + + let embeddedDb: Awaited> | undefined; + + const dbClient = await readDatabaseClient(options.configPaths); + if (dbClient === 'embedded-postgres') { + embeddedDb = await startEmbeddedDb(); + extraEnv.APP_CONFIG_backend_database = JSON.stringify({ + client: 'pg', + connection: embeddedDb.connection, + }); + } + let exiting = false; let firstStart = true; let child: ChildProcess | undefined; @@ -134,6 +154,7 @@ export async function runBackend(options: RunBackendOptions) { cwd: options.targetDir, env: { ...process.env, + ...extraEnv, BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace, BACKSTAGE_CLI_CHANNEL: '1', ESBK_TSCONFIG_PATH: targetPaths.resolveRoot('tsconfig.json'), @@ -186,6 +207,7 @@ export async function runBackend(options: RunBackendOptions) { }); } + await embeddedDb?.close(); resolveExitPromise(); } @@ -195,3 +217,24 @@ export async function runBackend(options: RunBackendOptions) { return () => exitPromise; } + +async function readDatabaseClient( + configPaths?: string[], +): Promise { + const rootDir = targetPaths.rootDir; + const source = ConfigSources.default({ + rootDir, + allowMissingDefaultConfig: true, + argv: (configPaths ?? []).flatMap(p => [ + '--config', + isAbsolutePath(p) ? p : resolvePath(rootDir, p), + ]), + }); + + const config = await ConfigSources.toConfig(source); + try { + return config.getOptionalString('backend.database.client'); + } finally { + config.close(); + } +} diff --git a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts new file mode 100644 index 0000000000..c94a5a7f46 --- /dev/null +++ b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2024 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 os from 'node:os'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'node:path'; +import { getPortPromise } from 'portfinder'; +import { ForwardedError } from '@backstage/errors'; +import chalk from 'chalk'; + +const TEMP_DIR_PREFIX = 'backstage-dev-db-'; +const PID_FILE = 'backstage.pid'; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function cleanStaleDatabases() { + const tmpBase = os.tmpdir(); + const entries = (await fs.readdir(tmpBase)).filter(d => + d.startsWith(TEMP_DIR_PREFIX), + ); + await Promise.all( + entries.map(async d => { + const dir = resolvePath(tmpBase, d); + const raw = await fs + .readFile(resolvePath(dir, PID_FILE), 'utf8') + .catch(() => undefined); + const pid = raw ? Number(raw.trim()) : NaN; + if (!pid || !isProcessAlive(pid)) { + await fs.remove(dir); + } + }), + ); +} + +export async function startEmbeddedDb() { + console.warn( + chalk.yellow( + 'WARNING: Using embedded-postgres for local development is experimental and subject to change', + ), + ); + + const { default: EmbeddedPostgres } = await import('embedded-postgres').catch( + error => { + throw new ForwardedError( + `Failed to load 'embedded-postgres' which is required when using ` + + `'embedded-postgres' as the database client. It must be installed ` + + `as an explicit dependency in your project`, + error, + ); + }, + ); + + await cleanStaleDatabases(); + + const host = 'localhost'; + const user = 'postgres'; + const password = 'password'; + const port = await getPortPromise(); + const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), TEMP_DIR_PREFIX)); + + await fs.writeFile(resolvePath(tmpDir, PID_FILE), String(process.pid)); + + const pg = new EmbeddedPostgres({ + databaseDir: tmpDir, + user, + password, + port, + persistent: false, + onError(messageOrError) { + console.error(`[embedded-postgres]`, messageOrError); + }, + onLog() {}, + }); + + try { + await pg.initialise(); + await pg.start(); + } catch (error) { + await pg.stop().catch(() => {}); + await fs.remove(tmpDir).catch(() => {}); + throw error; + } + + return { + connection: { + host, + user, + password, + port, + }, + async close() { + await pg.stop(); + await fs.remove(tmpDir); + }, + }; +} diff --git a/packages/theme/src/unified/UnifiedThemeProvider.tsx b/packages/theme/src/unified/UnifiedThemeProvider.tsx index 69d843ed3e..f10320393d 100644 --- a/packages/theme/src/unified/UnifiedThemeProvider.tsx +++ b/packages/theme/src/unified/UnifiedThemeProvider.tsx @@ -47,6 +47,9 @@ export interface UnifiedThemeProviderProps { * This call needs to be in the same module as the `UnifiedThemeProvider` to ensure that it doesn't get removed by tree shaking */ ClassNameGenerator.configure(componentName => { + if ((componentName ?? '').startsWith('v5-')) { + return componentName; + } return `v5-${componentName}`; }); diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 093d18e707..a2588e22de 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -318,6 +318,45 @@ export interface AvatarProps extends Omit, 'children' | 'className'>, AvatarOwnProps {} +// @public +export const Badge: ForwardRefExoticComponent< + BadgeProps & RefAttributes +>; + +// @public +export const BadgeDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly classNames: { + readonly root: 'bui-Badge'; + readonly icon: 'bui-BadgeIcon'; + }; + readonly bg: 'consumer'; + readonly propDefs: { + readonly icon: {}; + readonly size: { + readonly dataAttribute: true; + readonly default: 'small'; + }; + readonly children: {}; + readonly className: {}; + }; +}; + +// @public +export type BadgeOwnProps = { + icon?: React.ReactNode; + size?: 'small' | 'medium'; + children?: React.ReactNode; + className?: string; +}; + +// @public +export interface BadgeProps + extends BadgeOwnProps, + Omit, keyof BadgeOwnProps> {} + // @public (undocumented) export interface BgContextValue { // (undocumented) diff --git a/packages/ui/src/components/Badge/Badge.module.css b/packages/ui/src/components/Badge/Badge.module.css new file mode 100644 index 0000000000..ec985161ca --- /dev/null +++ b/packages/ui/src/components/Badge/Badge.module.css @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-Badge { + color: var(--bui-fg-primary); + background-color: var(--bui-bg-neutral-1); + border-radius: var(--bui-radius-2); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: var(--bui-font-weight-regular); + gap: var(--bui-space-1); + + &[data-on-bg='neutral-1'] { + background-color: var(--bui-bg-neutral-2); + } + + &[data-on-bg='neutral-2'] { + background-color: var(--bui-bg-neutral-3); + } + + &[data-on-bg='neutral-3'] { + background-color: var(--bui-bg-neutral-4); + } + } + + .bui-Badge[data-size='small'] { + height: 26px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-1); + } + + .bui-Badge[data-size='medium'] { + height: 32px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-2); + } + + .bui-BadgeIcon { + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 1rem; + height: 1rem; + } + } +} diff --git a/packages/ui/src/components/Badge/Badge.stories.tsx b/packages/ui/src/components/Badge/Badge.stories.tsx new file mode 100644 index 0000000000..9e5604b263 --- /dev/null +++ b/packages/ui/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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 preview from '../../../../../.storybook/preview'; +import { Badge } from '.'; +import { Flex } from '../../'; +import { BUIProvider } from '../../provider'; +import { RiBugLine } from '@remixicon/react'; + +const meta = preview.meta({ + title: 'Backstage UI/Badge', + component: Badge, + decorators: [ + Story => ( + + + + ), + ], +}); + +export const Default = meta.story({ + args: { + children: 'Banana', + }, +}); + +export const Sizes = meta.story({ + render: () => ( + + Banana + Banana + + ), +}); + +export const WithIcon = meta.story({ + render: () => ( + + }> + Banana + + }> + Banana + + + ), +}); diff --git a/packages/ui/src/components/Badge/Badge.tsx b/packages/ui/src/components/Badge/Badge.tsx new file mode 100644 index 0000000000..f5b8e5918b --- /dev/null +++ b/packages/ui/src/components/Badge/Badge.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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 type { BadgeProps } from './types'; +import { forwardRef } from 'react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { BadgeDefinition } from './definition'; + +/** + * A non-interactive badge for labeling or categorizing content. + * + * @public + */ +export const Badge = forwardRef((props, ref) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + BadgeDefinition, + props, + ); + const { classes, children, icon } = ownProps; + + return ( + + {icon && {icon}} + {children} + + ); +}); diff --git a/packages/ui/src/components/Badge/definition.ts b/packages/ui/src/components/Badge/definition.ts new file mode 100644 index 0000000000..cba0daeb8e --- /dev/null +++ b/packages/ui/src/components/Badge/definition.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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 { defineComponent } from '../../hooks/useDefinition'; +import type { BadgeOwnProps } from './types'; +import styles from './Badge.module.css'; + +/** + * Component definition for Badge + * @public + */ +export const BadgeDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-Badge', + icon: 'bui-BadgeIcon', + }, + bg: 'consumer', + propDefs: { + icon: {}, + size: { dataAttribute: true, default: 'small' }, + children: {}, + className: {}, + }, +}); diff --git a/packages/ui/src/components/Badge/index.ts b/packages/ui/src/components/Badge/index.ts new file mode 100644 index 0000000000..0041936187 --- /dev/null +++ b/packages/ui/src/components/Badge/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 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 { Badge } from './Badge'; +export type { BadgeProps, BadgeOwnProps } from './types'; +export { BadgeDefinition } from './definition'; diff --git a/packages/ui/src/components/Badge/types.ts b/packages/ui/src/components/Badge/types.ts new file mode 100644 index 0000000000..e22c0eb345 --- /dev/null +++ b/packages/ui/src/components/Badge/types.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2025 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. + */ + +/** + * Own props for the Badge component. + * + * @public + */ +export type BadgeOwnProps = { + /** + * The icon to display before the badge text. + */ + icon?: React.ReactNode; + /** + * The size of the badge. + */ + size?: 'small' | 'medium'; + children?: React.ReactNode; + className?: string; +}; + +/** + * Props for the Badge component. + * + * @public + */ +export interface BadgeProps + extends BadgeOwnProps, + Omit, keyof BadgeOwnProps> {} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index d90437376e..3cc94e1cde 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -27,6 +27,7 @@ export { } from './components/Accordion/definition'; export { AlertDefinition } from './components/Alert/definition'; export { AvatarDefinition } from './components/Avatar/definition'; +export { BadgeDefinition } from './components/Badge/definition'; export { BoxDefinition } from './components/Box/definition'; export { ButtonDefinition } from './components/Button/definition'; export { ButtonIconDefinition } from './components/ButtonIcon/definition'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index abf4b4accb..464df3a845 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -31,6 +31,7 @@ export * from './components/FullPage'; export * from './components/Accordion'; export * from './components/Alert'; export * from './components/Avatar'; +export * from './components/Badge'; export * from './components/Button'; export * from './components/Card'; export * from './components/Dialog'; diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json index e42357d60c..08c20fd0aa 100644 --- a/plugins/catalog-backend-module-github/package.json +++ b/plugins/catalog-backend-module-github/package.json @@ -63,6 +63,7 @@ "@octokit/auth-callback": "^5.0.0", "@octokit/core": "^5.2.0", "@octokit/graphql": "^7.0.2", + "@octokit/plugin-retry": "^6.0.0", "@octokit/plugin-throttling": "^8.1.3", "@octokit/rest": "^19.0.3", "@octokit/webhooks-types": "^7.6.1", diff --git a/plugins/catalog-backend-module-github/src/lib/github.test.ts b/plugins/catalog-backend-module-github/src/lib/github.test.ts index 554a482422..2b9c35be85 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.test.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.test.ts @@ -41,6 +41,7 @@ import { } from './github'; import { Octokit } from '@octokit/core'; import { throttling } from '@octokit/plugin-throttling'; +import { retry } from '@octokit/plugin-retry'; jest.mock('@octokit/core', () => ({ ...jest.requireActual('@octokit/core'), @@ -1009,9 +1010,9 @@ describe('github', () => { baseUrl, logger, }); - it('should return a graphql client with throttling', async () => { + it('should return a graphql client with throttling and retry', async () => { expect(client).toBeDefined(); - expect(Octokit.plugin).toHaveBeenCalledWith(throttling); + expect(Octokit.plugin).toHaveBeenCalledWith(throttling, retry); }); it('should return a graphql client with the correct options', async () => { diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index 200c7d260d..fc72004d9c 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -30,6 +30,7 @@ import { DeferredEntity } from '@backstage/plugin-catalog-node'; import { Octokit } from '@octokit/core'; import { LoggerService } from '@backstage/backend-plugin-api'; import { throttling } from '@octokit/plugin-throttling'; +import { retry } from '@octokit/plugin-retry'; /** * Configuration for GitHub GraphQL API page sizes. @@ -874,7 +875,7 @@ export const createReplaceEntitiesOperation = }; /** - * Creates a GraphQL Client with Throttling + * Creates a GraphQL Client with Throttling and Retries */ export const createGraphqlClient = (args: { headers: @@ -886,7 +887,7 @@ export const createGraphqlClient = (args: { logger: LoggerService; }): typeof graphql => { const { headers, baseUrl, logger } = args; - const ThrottledOctokit = Octokit.plugin(throttling); + const ThrottledOctokit = Octokit.plugin(throttling, retry); const octokit = new ThrottledOctokit({ throttle: { onRateLimit: (retryAfter, rateLimitData, _, retryCount) => { diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.test.ts index e9e106cce9..dc61d8ecd1 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.test.ts @@ -47,6 +47,7 @@ type PartialDeep = T extends (...args: unknown[]) => unknown jest.mock('../lib/github', () => { return { getOrganizationRepositories: jest.fn(), + createGraphqlClient: jest.fn().mockReturnValue(jest.fn()), }; }); class PersistingTaskRunner implements SchedulerServiceTaskRunner { diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts index e215337d8c..5a830c20b5 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts @@ -32,13 +32,13 @@ import { import { LocationSpec } from '@backstage/plugin-catalog-common'; -import { graphql } from '@octokit/graphql'; import * as uuid from 'uuid'; import { GithubEntityProviderConfig, readProviderConfigs, } from './GithubEntityProviderConfig'; import { + createGraphqlClient, getOrganizationRepositories, getOrganizationRepository, RepositoryResponse, @@ -249,9 +249,10 @@ export class GithubEntityProvider implements EntityProvider, EventSubscriber { url: orgUrl, }); - return graphql.defaults({ - baseUrl: this.integration.apiBaseUrl, + return createGraphqlClient({ headers, + baseUrl: this.integration.apiBaseUrl!, + logger: this.logger, }); } diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index d3bf47874d..9710f6d00c 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -43,10 +43,7 @@ import { UrlReaderService, } from '@backstage/backend-plugin-api'; import { Config, readDurationFromConfig } from '@backstage/config'; -import { - catalogPermissions, - RESOURCE_TYPE_CATALOG_ENTITY, -} from '@backstage/plugin-catalog-common/alpha'; +import { catalogPermissions } from '@backstage/plugin-catalog-common/alpha'; import { CatalogProcessor, CatalogProcessorParser, @@ -55,15 +52,7 @@ import { ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; import { EventsService } from '@backstage/plugin-events-node'; -import { - Permission, - PermissionAuthorizer, - toPermissionEvaluator, -} from '@backstage/plugin-permission-common'; -import { - createConditionTransformer, - createPermissionIntegrationRouter, -} from '@backstage/plugin-permission-node'; +import { createConditionTransformer } from '@backstage/plugin-permission-node'; import { durationToMilliseconds } from '@backstage/types'; import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase'; import { DefaultProcessingDatabase } from '../database/DefaultProcessingDatabase'; @@ -111,7 +100,6 @@ import { DefaultRefreshService } from './DefaultRefreshService'; import { entitiesResponseToObjects } from './response'; import { catalogEntityPermissionResourceRef, - CatalogPermissionRuleInput, CatalogScmEventsService, } from '@backstage/plugin-catalog-node/alpha'; import { filterAndSortProcessors, filterProviders } from './util'; @@ -124,8 +112,8 @@ export type CatalogEnvironment = { database: DatabaseService; config: RootConfigService; reader: UrlReaderService; - permissions: PermissionsService | PermissionAuthorizer; - permissionsRegistry?: PermissionsRegistryService; + permissions: PermissionsService; + permissionsRegistry: PermissionsRegistryService; scheduler: SchedulerService; auth: AuthService; httpAuth: HttpAuthService; @@ -177,8 +165,6 @@ export class CatalogBuilder { }) => Promise | void; private processingInterval: ProcessingIntervalFunction; private locationAnalyzer: LocationAnalyzer | undefined = undefined; - private readonly permissions: Permission[]; - private readonly permissionRules: CatalogPermissionRuleInput[]; private allowedLocationType: string[]; /** @@ -199,8 +185,6 @@ export class CatalogBuilder { this.locationAnalyzers = []; this.processorsReplace = false; this.parser = undefined; - this.permissions = [...catalogPermissions]; - this.permissionRules = Object.values(catalogPermissionRules); this.allowedLocationType = ['url']; this.processingInterval = CatalogBuilder.getDefaultProcessingInterval( @@ -375,33 +359,6 @@ export class CatalogBuilder { return this; } - /** - * Adds additional permissions. See - * {@link @backstage/plugin-permission-node#Permission}. - * - * @param permissions - Additional permissions - */ - addPermissions(...permissions: Array>) { - this.permissions.push(...permissions.flat()); - return this; - } - - /** - * Adds additional permission rules. Permission rules are used to evaluate - * catalog resources against queries. See - * {@link @backstage/plugin-permission-node#PermissionRule}. - * - * @param permissionRules - Additional permission rules - */ - addPermissionRules( - ...permissionRules: Array< - CatalogPermissionRuleInput | Array - > - ) { - this.permissionRules.push(...permissionRules.flat()); - return this; - } - /** * Sets up the allowed location types from being registered via the location service. * @@ -479,16 +436,6 @@ export class CatalogBuilder { enableRelationsCompatibility, }); - let permissionsService: PermissionsService; - if ('authorizeConditional' in permissions) { - permissionsService = permissions as PermissionsService; - } else { - logger.warn( - 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', - ); - permissionsService = toPermissionEvaluator(permissions); - } - const orchestrator = new DefaultCatalogProcessingOrchestrator({ processors, integrations, @@ -500,14 +447,12 @@ export class CatalogBuilder { const entitiesCatalog = new AuthorizedEntitiesCatalog( unauthorizedEntitiesCatalog, - permissionsService, - permissionsRegistry - ? createConditionTransformer( - permissionsRegistry.getPermissionRuleset( - catalogEntityPermissionResourceRef, - ), - ) - : createConditionTransformer(this.permissionRules), + permissions, + createConditionTransformer( + permissionsRegistry.getPermissionRuleset( + catalogEntityPermissionResourceRef, + ), + ), ); const getResources = async (resourceRefs: string[]) => { @@ -519,24 +464,12 @@ export class CatalogBuilder { return entitiesResponseToObjects(items).map(e => e || undefined); }; - let permissionIntegrationRouter: - | ReturnType - | undefined; - if (permissionsRegistry) { - permissionsRegistry.addResourceType({ - resourceRef: catalogEntityPermissionResourceRef, - getResources, - permissions: this.permissions, - rules: this.permissionRules, - }); - } else { - permissionIntegrationRouter = createPermissionIntegrationRouter({ - resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - getResources, - permissions: this.permissions, - rules: this.permissionRules, - }); - } + permissionsRegistry.addResourceType({ + resourceRef: catalogEntityPermissionResourceRef, + getResources, + permissions: [...catalogPermissions], + rules: Object.values(catalogPermissionRules), + }); const scmEventHandlingConfig = readScmEventHandlingConfig(config); const locationStore = new DefaultLocationStore( @@ -589,7 +522,7 @@ export class CatalogBuilder { this.locationAnalyzer ?? new AuthorizedLocationAnalyzer( new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers), - permissionsService, + permissions, ); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { @@ -599,11 +532,11 @@ export class CatalogBuilder { 'catalog.defaultLocationConflictStrategy', ) as 'refresh' | 'reject') || 'reject', }), - permissionsService, + permissions, ); const refreshService = new AuthorizedRefreshService( new DefaultRefreshService({ database: catalogDatabase }), - permissionsService, + permissions, ); const router = await createRouter({ @@ -614,10 +547,9 @@ export class CatalogBuilder { refreshService, logger, config, - permissionIntegrationRouter, auth, httpAuth, - permissionsService, + permissionsService: permissions, auditor, enableRelationsCompatibility, }); diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 915dd80dd9..76fdf4eec5 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -36,13 +36,9 @@ import { import { CatalogModelExtensionPoint, catalogModelExtensionPoint, - CatalogPermissionExtensionPoint, - catalogPermissionExtensionPoint, - CatalogPermissionRuleInput, catalogScmEventsServiceRef, } from '@backstage/plugin-catalog-node/alpha'; import { eventsServiceRef } from '@backstage/plugin-events-node'; -import { Permission } from '@backstage/plugin-permission-common'; import { merge } from 'lodash'; import { CatalogBuilder } from './CatalogBuilder'; import { @@ -66,33 +62,6 @@ class CatalogLocationsExtensionPointImpl } } -class CatalogPermissionExtensionPointImpl - implements CatalogPermissionExtensionPoint -{ - #permissions = new Array(); - #permissionRules = new Array(); - - addPermissions(...permission: Array>): void { - this.#permissions.push(...permission.flat()); - } - - addPermissionRules( - ...rules: Array< - CatalogPermissionRuleInput | Array - > - ): void { - this.#permissionRules.push(...rules.flat()); - } - - get permissions() { - return this.#permissions; - } - - get permissionRules() { - return this.#permissionRules; - } -} - class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint { #fieldValidators: Partial = {}; @@ -189,12 +158,6 @@ export const catalogPlugin = createBackendPlugin({ }, }); - const permissionExtensions = new CatalogPermissionExtensionPointImpl(); - env.registerExtensionPoint( - catalogPermissionExtensionPoint, - permissionExtensions, - ); - const modelExtensions = new CatalogModelExtensionPointImpl(); env.registerExtensionPoint(catalogModelExtensionPoint, modelExtensions); @@ -282,8 +245,6 @@ export const catalogPlugin = createBackendPlugin({ } else { builder.addLocationAnalyzers(...scmLocationAnalyzers); } - builder.addPermissions(...permissionExtensions.permissions); - builder.addPermissionRules(...permissionExtensions.permissionRules); builder.setFieldFormatValidators(modelExtensions.fieldValidators); if (locationTypeExtensions.allowedLocationTypes) { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 9d5944f664..d6c565ab5a 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -31,17 +31,10 @@ import { } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; -import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { - createPermissionIntegrationRouter, - createPermissionRule, -} from '@backstage/plugin-permission-node'; import express from 'express'; import { Server } from 'node:http'; import request from 'supertest'; -import { z } from 'zod/v3'; import { Cursor, EntitiesCatalog } from '../catalog/types'; import { applyDatabaseMigrations } from '../database/migrations'; import { DbLocationsRow } from '../database/tables'; @@ -98,7 +91,6 @@ describe('createRouter readonly disabled', () => { logger: mockServices.logger.mock(), refreshService, config: new ConfigReader(undefined), - permissionIntegrationRouter: express.Router(), auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), locationAnalyzer, @@ -1386,7 +1378,6 @@ describe('createRouter readonly and raw json enabled', () => { readonly: true, }, }), - permissionIntegrationRouter: express.Router(), auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), orchestrator: { process: jest.fn() }, @@ -1558,110 +1549,6 @@ describe('createRouter readonly and raw json enabled', () => { }); }); -describe('NextRouter permissioning', () => { - let entitiesCatalog: jest.Mocked; - let locationService: jest.Mocked; - let app: express.Express; - let refreshService: RefreshService; - const permissionsService = mockServices.permissions(); - - const fakeRule = createPermissionRule({ - name: 'FAKE_RULE', - description: 'fake rule', - resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - paramsSchema: z.object({ - foo: z.string(), - }), - apply: () => true, - toQuery: () => ({ key: '', values: [] }), - }); - - beforeAll(async () => { - entitiesCatalog = { - entities: jest.fn(), - entitiesBatch: jest.fn(), - removeEntityByUid: jest.fn(), - entityAncestry: jest.fn(), - facets: jest.fn(), - queryEntities: jest.fn(), - }; - locationService = { - getLocation: jest.fn(), - createLocation: jest.fn(), - queryLocations: jest.fn(), - listLocations: jest.fn(), - deleteLocation: jest.fn(), - getLocationByEntity: jest.fn(), - }; - refreshService = { refresh: jest.fn() }; - const router = await createRouter({ - entitiesCatalog, - locationService, - logger: mockServices.logger.mock(), - refreshService, - config: new ConfigReader(undefined), - permissionIntegrationRouter: createPermissionIntegrationRouter({ - resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - rules: [fakeRule], - getResources: jest.fn((resourceRefs: string[]) => - Promise.resolve( - resourceRefs.map(resourceRef => ({ id: resourceRef })), - ), - ), - }), - auth: mockServices.auth(), - httpAuth: mockServices.httpAuth(), - orchestrator: { process: jest.fn() }, - permissionsService, - auditor: mockServices.auditor.mock(), - }); - app = express().use(router); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('accepts and evaluates conditions at the apply-conditions endpoint', async () => { - const spideySense: Entity = { - apiVersion: 'a', - kind: 'component', - metadata: { - name: 'spidey-sense', - }, - }; - entitiesCatalog.entities.mockResolvedValueOnce({ - entities: { type: 'object', entities: [spideySense] }, - pageInfo: { hasNextPage: false }, - }); - - const requestBody = { - items: [ - { - id: '123', - resourceType: 'catalog-entity', - resourceRef: 'component:default/spidey-sense', - conditions: { - rule: 'FAKE_RULE', - resourceType: 'catalog-entity', - params: { - foo: 'user:default/spiderman', - }, - }, - }, - ], - }; - const response = await request(app) - .post('/.well-known/backstage/permissions/apply-conditions') - .send(requestBody); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ - items: [{ id: '123', result: AuthorizeResult.ALLOW }], - }); - }); -}); - describe('POST /locations/by-query works end to end', () => { const databases = TestDatabases.create(); diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index c2711a00ed..f996469a41 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -79,7 +79,6 @@ export interface RouterOptions { refreshService?: RefreshService; logger: LoggerService; config: Config; - permissionIntegrationRouter?: express.Router; auth: AuthService; httpAuth: HttpAuthService; permissionsService: PermissionsService; @@ -108,7 +107,6 @@ export async function createRouter( refreshService, config, logger, - permissionIntegrationRouter, permissionsService, auth, httpAuth, @@ -156,10 +154,6 @@ export async function createRouter( }); } - if (permissionIntegrationRouter) { - router.use(permissionIntegrationRouter); - } - if (entitiesCatalog) { router .get('/entities', async (req, res) => { diff --git a/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index 90481caca6..b7cd5f65be 100644 --- a/plugins/catalog-graph/README-alpha.md +++ b/plugins/catalog-graph/README-alpha.md @@ -165,6 +165,7 @@ See below the complete list of available configs: | `maxDepth` | A maximum number of levels of relations to display in the graph. | `number` | yes | `1` | | `unidirectional` | Shows only relations that are from the source to the target entity. | `boolean` | yes | `true` | | `mergeRelations` | Merge the relations line into a single one. | `boolean` | yes | `true` | +| `showArrowHeads` | Show arrowheads on the relation lines | `boolean` | yes | `false` | | `direction` | Render direction of the graph. | `TB` \| `BT` \| `LR` \| `RL` | yes | `'LR'` | | `relationPairs` | A list of [pairs of entity relations](https://backstage.io/docs/features/software-catalog/well-known-relations#relations), used to define which relations are merged together and which the primary relation is. | `[string[], string[]]` | yes | Show all entity [relations](https://backstage.io/docs/features/software-catalog/well-known-relations#relations). | | `zoom` | Controls zoom behavior of graph. | `enabled` \| `disabled` \| `enable-on-click` | yes | `'enabled'` | @@ -265,6 +266,7 @@ See below the complete list of available configs: | `maxDepth` | A maximum number of levels of relations to display in the graph. | `number` | yes | `1` | | `unidirectional` | Shows only relations that are from the source to the target entity. | `boolean` | yes | `true` | | `mergeRelations` | Merge the relations line into a single one. | `boolean` | yes | `true` | +| `showArrowHeads` | Show arrowheads on the relation lines | `boolean` | yes | `false` | | `direction` | Render direction of the graph. | `TB` \| `BT` \| `LR` \| `RL` | yes | `'LR'` | | `relationPairs` | A list of [pairs of entity relations](https://backstage.io/docs/features/software-catalog/well-known-relations#relations), used to define which relations are merged together and which the primary relation is. | `[string[], string[]]` | yes | Show all entity [relations](https://backstage.io/docs/features/software-catalog/well-known-relations#relations). | | `zoom` | Controls zoom behavior of graph. | `enabled` \| `disabled` \| `enable-on-click` | yes | `'enabled'` | diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 8bfbe17e9b..44a9bbfb61 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -88,6 +88,7 @@ const _default: OverridableFrontendPlugin< maxDepth: number | undefined; unidirectional: boolean | undefined; mergeRelations: boolean | undefined; + showArrowHeads: boolean | undefined; direction: 'TB' | 'BT' | 'LR' | 'RL' | undefined; relationPairs: [string, string][] | undefined; zoom: 'disabled' | 'enabled' | 'enable-on-click' | undefined; @@ -103,6 +104,7 @@ const _default: OverridableFrontendPlugin< direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; title?: string | undefined; + showArrowHeads?: boolean | undefined; relations?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; @@ -152,6 +154,7 @@ const _default: OverridableFrontendPlugin< maxDepth: number | undefined; unidirectional: boolean | undefined; mergeRelations: boolean | undefined; + showArrowHeads: boolean | undefined; direction: 'TB' | 'BT' | 'LR' | 'RL' | undefined; showFilters: boolean | undefined; curve: 'curveStepBefore' | 'curveMonotoneX' | undefined; @@ -166,6 +169,7 @@ const _default: OverridableFrontendPlugin< curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined; direction?: 'TB' | 'BT' | 'LR' | 'RL' | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; + showArrowHeads?: boolean | undefined; relations?: string[] | undefined; maxDepth?: number | undefined; rootEntityRefs?: string[] | undefined; diff --git a/plugins/catalog-graph/src/alpha.tsx b/plugins/catalog-graph/src/alpha.tsx index f1b4dbc6d0..9d77c35020 100644 --- a/plugins/catalog-graph/src/alpha.tsx +++ b/plugins/catalog-graph/src/alpha.tsx @@ -37,6 +37,7 @@ const CatalogGraphEntityCard = EntityCardBlueprint.makeWithOverrides({ maxDepth: z => z.number().optional(), unidirectional: z => z.boolean().optional(), mergeRelations: z => z.boolean().optional(), + showArrowHeads: z => z.boolean().optional(), direction: z => z.nativeEnum(Direction).optional(), relationPairs: z => z.array(z.tuple([z.string(), z.string()])).optional(), zoom: z => z.enum(['enabled', 'disabled', 'enable-on-click']).optional(), @@ -66,6 +67,7 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({ maxDepth: z => z.number().optional(), unidirectional: z => z.boolean().optional(), mergeRelations: z => z.boolean().optional(), + showArrowHeads: z => z.boolean().optional(), direction: z => z.nativeEnum(Direction).optional(), showFilters: z => z.boolean().optional(), curve: z => z.enum(['curveStepBefore', 'curveMonotoneX']).optional(), diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx index 20b4074f79..10ba73e2b6 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -68,6 +68,7 @@ export const CatalogGraphCard = ( maxDepth = 1, unidirectional = true, mergeRelations = true, + showArrowHeads, direction = Direction.LEFT_RIGHT, kinds, relations, @@ -147,6 +148,7 @@ export const CatalogGraphCard = ( relationPairs={relationPairs} entityFilter={entityFilter} zoom={zoom} + showArrowHeads={showArrowHeads} /> ); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx index 7e6420da1f..93bdc94860 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -131,7 +131,7 @@ export const CatalogGraphPage = ( }; } & Partial, ) => { - const { relationPairs, initialState, entityFilter } = props; + const { relationPairs, initialState, entityFilter, showArrowHeads } = props; const { t } = useTranslationRef(catalogGraphTranslationRef); const navigate = useNavigate(); const classes = useStyles(); @@ -260,6 +260,7 @@ export const CatalogGraphPage = ( } mergeRelations={mergeRelations} unidirectional={unidirectional} + showArrowHeads={showArrowHeads} onNodeClick={onNodeClick} direction={direction} relationPairs={relationPairs} diff --git a/plugins/catalog-node/report-alpha.api.md b/plugins/catalog-node/report-alpha.api.md index 0598834c67..78922b1141 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -11,10 +11,7 @@ import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; import { EntitiesSearchFilter } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; -import { Permission } from '@backstage/plugin-permission-common'; import { PermissionResourceRef } from '@backstage/plugin-permission-node'; -import { PermissionRule } from '@backstage/plugin-permission-node'; -import { PermissionRuleParams } from '@backstage/plugin-permission-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { Validators } from '@backstage/catalog-model'; @@ -47,26 +44,6 @@ export interface CatalogModelExtensionPoint { // @alpha (undocumented) export const catalogModelExtensionPoint: ExtensionPoint; -// @alpha @deprecated (undocumented) -export interface CatalogPermissionExtensionPoint { - // (undocumented) - addPermissionRules( - ...rules: Array< - CatalogPermissionRuleInput | Array - > - ): void; - // (undocumented) - addPermissions(...permissions: Array>): void; -} - -// @alpha @deprecated (undocumented) -export const catalogPermissionExtensionPoint: ExtensionPoint; - -// @alpha @deprecated (undocumented) -export type CatalogPermissionRuleInput< - TParams extends PermissionRuleParams = PermissionRuleParams, -> = PermissionRule; - // @alpha @deprecated (undocumented) export type CatalogProcessingExtensionPoint = CatalogProcessingExtensionPoint_2; diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts index 8ed55f87ce..3c835fcd97 100644 --- a/plugins/catalog-node/src/alpha.ts +++ b/plugins/catalog-node/src/alpha.ts @@ -100,8 +100,5 @@ export const catalogAnalysisExtensionPoint = _catalogAnalysisExtensionPoint; export type { CatalogModelExtensionPoint } from './extensions'; export { catalogModelExtensionPoint } from './extensions'; -export type { CatalogPermissionRuleInput } from './extensions'; -export type { CatalogPermissionExtensionPoint } from './extensions'; -export { catalogPermissionExtensionPoint } from './extensions'; export * from './scmEvents'; diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index 4fe4bfd679..57150775a4 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -19,17 +19,11 @@ import { Entity, Validators } from '@backstage/catalog-model'; import { CatalogProcessor, CatalogProcessorParser, - EntitiesSearchFilter, EntityProvider, PlaceholderResolver, LocationAnalyzer, ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; -import { - Permission, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; -import { PermissionRule } from '@backstage/plugin-permission-node'; /** * @public @@ -163,33 +157,3 @@ export const catalogModelExtensionPoint = createExtensionPoint({ id: 'catalog.model', }); - -/** - * @alpha - * @deprecated Use the `coreServices.permissionsRegistry` instead. - */ -export type CatalogPermissionRuleInput< - TParams extends PermissionRuleParams = PermissionRuleParams, -> = PermissionRule; - -/** - * @alpha - * @deprecated Use the `coreServices.permissionsRegistry` instead. - */ -export interface CatalogPermissionExtensionPoint { - addPermissions(...permissions: Array>): void; - addPermissionRules( - ...rules: Array< - CatalogPermissionRuleInput | Array - > - ): void; -} - -/** - * @alpha - * @deprecated Use the `coreServices.permissionsRegistry` instead. - */ -export const catalogPermissionExtensionPoint = - createExtensionPoint({ - id: 'catalog.permission', - }); diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 25d3f0f8f2..dc2a306271 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -5651,7 +5651,7 @@ - `step`: The name of the step that was run - `result`: A string describing whether the task ran successfully, failed, or was skipped - You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/prometheus-metrics.md + You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/384b7bac2e/contrib/docs/tutorials/prometheus-metrics.md - 5921b5ce49: - The GitLab Project ID for the `publish:gitlab:merge-request` action is now passed through the query parameter `project` in the `repoUrl`. It still allows people to not use the `projectid` and use the `repoUrl` with the `owner` and `repo` query parameters instead. This makes it easier to publish to repositories instead of writing the full path to the project. - 5025d2e8b6: Adds the ability to pass (an optional) array of strings that will be applied to the newly scaffolded repository as topic labels. @@ -5744,7 +5744,7 @@ - `step`: The name of the step that was run - `result`: A string describing whether the task ran successfully, failed, or was skipped - You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/prometheus-metrics.md + You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/384b7bac2e/contrib/docs/tutorials/prometheus-metrics.md ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ec7bdba97c..3ff1c1b9c2 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -78,7 +78,6 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", - "@opentelemetry/api": "^1.9.0", "@types/luxon": "^3.0.0", "express": "^4.22.0", "fs-extra": "^11.2.0", diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 17997da485..6cef0d3cde 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -63,6 +63,7 @@ import { import { actionsServiceRef, actionsRegistryServiceRef, + metricsServiceRef, } from '@backstage/backend-plugin-api/alpha'; import { createScaffolderActions } from './actions'; @@ -151,6 +152,7 @@ export const scaffolderPlugin = createBackendPlugin({ actionsRegistry: actionsServiceRef, actionsRegistryService: actionsRegistryServiceRef, scaffolderService: scaffolderServiceRef, + metrics: metricsServiceRef, }, async init({ logger, @@ -168,6 +170,7 @@ export const scaffolderPlugin = createBackendPlugin({ actionsRegistry, actionsRegistryService, scaffolderService, + metrics, }) { const log = loggerToWinstonLogger(logger); const integrations = ScmIntegrations.fromConfig(config); @@ -244,6 +247,7 @@ export const scaffolderPlugin = createBackendPlugin({ events, auditor, actionsRegistry, + metrics, }); httpRouter.use(router); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index e01e76064e..1c56248081 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -19,6 +19,7 @@ import { BackstageCredentials, LoggerService, } from '@backstage/backend-plugin-api'; +import type { MetricsService } from '@backstage/backend-plugin-api/alpha'; import type { UserEntity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -81,6 +82,7 @@ export type TemplateTesterCreateOptions = { additionalTemplateGlobals?: Record; permissions?: PermissionEvaluator; config?: Config; + metrics: MetricsService; }; /** diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index c063e38cb2..6b07936e57 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -39,7 +39,10 @@ import { mockCredentials, mockServices, } from '@backstage/backend-test-utils'; -import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { + actionsRegistryServiceMock, + metricsServiceMock, +} from '@backstage/backend-test-utils/alpha'; describe('NunjucksWorkflowRunner', () => { let actionRegistry: TemplateActionRegistry; @@ -249,6 +252,7 @@ describe('NunjucksWorkflowRunner', () => { logger, permissions: mockedPermissionApi, config, + metrics: metricsServiceMock.mock(), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 589ad2e204..a6a2eb2483 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -23,7 +23,6 @@ import { TaskStep, } from '@backstage/plugin-scaffolder-common'; import { JsonArray, JsonObject, JsonValue } from '@backstage/types'; -import { metrics } from '@opentelemetry/api'; import fs from 'fs-extra'; import { validate as validateJsonSchema } from 'jsonschema'; import nunjucks from 'nunjucks'; @@ -42,6 +41,7 @@ import type { LoggerService, PermissionsService, } from '@backstage/backend-plugin-api'; +import type { MetricsService } from '@backstage/backend-plugin-api/alpha'; import { UserEntity } from '@backstage/catalog-model'; import { AuthorizeResult, @@ -78,6 +78,7 @@ type NunjucksWorkflowRunnerOptions = { additionalTemplateGlobals?: Record; permissions?: PermissionsService; config?: Config; + metrics: MetricsService; }; type TemplateContext = { @@ -188,6 +189,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { secrets?: Record; } = { parameters: {}, secrets: {} }; + private readonly tracker: ReturnType; + constructor(options: NunjucksWorkflowRunnerOptions) { this.options = options; this.defaultTemplateFilters = convertFiltersToRecord( @@ -195,10 +198,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { integrations: this.options.integrations, }), ); + this.tracker = scaffoldingTracker(options.metrics); } - private readonly tracker = scaffoldingTracker(); - async getEnvironmentConfig(): Promise<{ parameters: JsonObject; secrets?: TaskSecrets; @@ -700,7 +702,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } } -function scaffoldingTracker() { +function scaffoldingTracker(metrics: MetricsService) { // prom-client metrics are deprecated in favour of OpenTelemetry metrics. const promTaskCount = createCounterMetric({ name: 'scaffolder_task_count', @@ -723,23 +725,22 @@ function scaffoldingTracker() { labelNames: ['template', 'step', 'result'], }); - const meter = metrics.getMeter('default'); - const taskCount = meter.createCounter('scaffolder.task.count', { - description: 'Count of task runs', + const taskCount = metrics.createCounter('scaffolder.task.count', { + description: 'Total number of scaffolder tasks executed', }); - const taskDuration = meter.createHistogram('scaffolder.task.duration', { - description: 'Duration of a task run', - unit: 'seconds', + const taskDuration = metrics.createHistogram('scaffolder.task.duration', { + description: 'Time taken to complete a scaffolder task end-to-end', + unit: 's', }); - const stepCount = meter.createCounter('scaffolder.step.count', { - description: 'Count of step runs', + const stepCount = metrics.createCounter('scaffolder.step.count', { + description: 'Total number of individual scaffolder action steps executed', }); - const stepDuration = meter.createHistogram('scaffolder.step.duration', { - description: 'Duration of a step runs', - unit: 'seconds', + const stepDuration = metrics.createHistogram('scaffolder.step.duration', { + description: 'Time taken to complete a single scaffolder action step', + unit: 's', }); async function taskStart(task: TaskContext) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index 578786cc65..71ed6b9eb4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -36,6 +36,7 @@ import { WorkflowRunner } from './types'; import ObservableImpl from 'zen-observable'; import waitForExpect from 'wait-for-expect'; import { mockServices } from '@backstage/backend-test-utils'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger'; jest.mock('./NunjucksWorkflowRunner'); @@ -93,6 +94,7 @@ describe('TaskWorker', () => { integrations, taskBroker: broker, actionRegistry, + metrics: metricsServiceMock.mock(), }); await broker.dispatch({ @@ -124,6 +126,7 @@ describe('TaskWorker', () => { integrations, taskBroker: broker, actionRegistry, + metrics: metricsServiceMock.mock(), }); const { taskId } = await broker.dispatch({ @@ -174,6 +177,7 @@ describe('TaskWorker', () => { }, }, }), + metrics: metricsServiceMock.mock(), }); await taskWorker.runOneTask({ @@ -261,6 +265,7 @@ describe('Concurrent TaskWorker', () => { taskBroker: broker, actionRegistry, concurrentTasksLimit: expectedConcurrentTasks, + metrics: metricsServiceMock.mock(), }); taskWorker.start(); @@ -307,6 +312,7 @@ describe('Cancellable TaskWorker', () => { integrations, taskBroker, actionRegistry, + metrics: metricsServiceMock.mock(), }); const steps = [...Array(10)].map(n => ({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index b98badd9f6..fdae61a84b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -15,6 +15,7 @@ */ import { AuditorService, LoggerService } from '@backstage/backend-plugin-api'; +import type { MetricsService } from '@backstage/backend-plugin-api/alpha'; import { assertError, InputError, stringifyError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; @@ -78,6 +79,7 @@ export type CreateWorkerOptions = { additionalTemplateGlobals?: Record; permissions?: PermissionEvaluator; gracefulShutdown?: boolean; + metrics: MetricsService; }; /** @@ -123,6 +125,7 @@ export class TaskWorker { additionalTemplateGlobals, permissions, gracefulShutdown, + metrics, } = options; const workflowRunner = new NunjucksWorkflowRunner({ @@ -135,6 +138,7 @@ export class TaskWorker { additionalTemplateGlobals, permissions, config, + metrics, }); return new TaskWorker({ diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index e778645983..9342385e15 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -58,7 +58,10 @@ import { import { createDefaultFilters } from '../lib/templating/filters/createDefaultFilters'; import { createRouter } from './router'; import { DatabaseTaskStore } from '../scaffolder/tasks/DatabaseTaskStore'; -import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { + actionsRegistryServiceMock, + metricsServiceMock, +} from '@backstage/backend-test-utils/alpha'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; function createDatabase(): DatabaseService { @@ -229,6 +232,7 @@ const createTestRouter = async ( createDebugLogAction(), ], actionsRegistry: overrides.actionsRegistry ?? actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), }); router.use(mockErrorHandler()); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 9e84b38e39..a393df253e 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -131,7 +131,10 @@ import { scaffolderTaskRules, scaffolderTemplateRules, } from './rules'; -import { ActionsService } from '@backstage/backend-plugin-api/alpha'; +import { + ActionsService, + MetricsService, +} from '@backstage/backend-plugin-api/alpha'; /** * RouterOptions @@ -165,6 +168,7 @@ export interface RouterOptions { auditor?: AuditorService; autocompleteHandlers?: Record; actionsRegistry: ActionsService; + metrics: MetricsService; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { @@ -256,6 +260,7 @@ export async function createRouter( httpAuth, auditor, actionsRegistry, + metrics, } = options; const concurrentTasksLimit = @@ -344,6 +349,7 @@ export async function createRouter( concurrentTasksLimit, permissions, gracefulShutdown, + metrics, ...templateExtensions, }); @@ -375,6 +381,7 @@ export async function createRouter( workingDirectory, permissions, config, + metrics, ...templateExtensions, }); diff --git a/scripts/verify-links.js b/scripts/verify-links.js index f63182636f..cddd4138cc 100755 --- a/scripts/verify-links.js +++ b/scripts/verify-links.js @@ -19,9 +19,94 @@ const { resolve: resolvePath, join: joinPath, dirname } = require('node:path'); const fs = require('node:fs').promises; -const { existsSync } = require('node:fs'); +const { existsSync, statSync } = require('node:fs'); const IGNORED_DIRS = ['node_modules', 'dist', 'bin', '.git']; +const projectRoot = resolvePath(__dirname, '..'); + +// Zero-width and other invisible Unicode characters that shouldn't appear in URLs +const INVISIBLE_CHAR_PATTERN = + /[\u200B\u200C\u200D\u200E\u200F\uFEFF\u00AD\u2060\u2028\u2029]/; + +// Generates a GitHub/Docusaurus-compatible heading slug. +// Handles explicit {#custom-id} overrides and standard slugification. +function headingToSlug(headingText) { + const explicitId = headingText.match(/\{#([^}]+)\}\s*$/); + if (explicitId) { + return explicitId[1]; + } + + let slug = headingText + .toLowerCase() + // Remove inline code backticks + .replace(/`/g, '') + // Remove markdown bold/italic markers + .replace(/[*_]/g, '') + // Remove markdown links, keep link text + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); + + // Remove HTML tags in a loop to handle nested fragments like ipt> + let previous; + do { + previous = slug; + slug = slug.replace(/<[^>]+>/g, ''); + } while (slug !== previous); + + return ( + slug + // Replace special characters with hyphens (keeping alphanumeric, hyphens, spaces) + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + ); +} + +// Extracts all heading anchors from a markdown file's content, +// handling duplicate headings with -1, -2, etc. suffixes (GitHub/Docusaurus behavior) +function extractHeadingAnchors(content) { + const anchors = new Set(); + const slugCounts = new Map(); + + // Strip fenced code blocks to avoid matching headings inside them + const stripped = content.replace(/^```[^\n]*\n[\s\S]*?^```/gm, ''); + + const headingPattern = /^#{1,6}\s+(.+)$/gm; + for ( + let match = headingPattern.exec(stripped); + match !== null; + match = headingPattern.exec(stripped) + ) { + const baseSlug = headingToSlug(match[1]); + const count = slugCounts.get(baseSlug) || 0; + slugCounts.set(baseSlug, count + 1); + + if (count === 0) { + anchors.add(baseSlug); + } else { + anchors.add(`${baseSlug}-${count}`); + } + } + return anchors; +} + +// Cache for file content and extracted anchors to avoid repeated reads +const anchorCache = new Map(); + +async function getAnchorsForFile(filePath) { + const absPath = resolvePath(projectRoot, filePath); + if (anchorCache.has(absPath)) { + return anchorCache.get(absPath); + } + try { + const content = await fs.readFile(absPath, 'utf8'); + const anchors = extractHeadingAnchors(content); + anchorCache.set(absPath, anchors); + return anchors; + } catch { + anchorCache.set(absPath, null); + return null; + } +} async function listFiles(dir) { const files = await fs.readdir(dir); @@ -40,15 +125,23 @@ async function listFiles(dir) { return paths.flat(); } -const projectRoot = resolvePath(__dirname, '..'); - async function verifyUrl(basePath, absUrl, docPages) { - const url = absUrl - .replace(/#.*$/, '') - .replace( - /https:\/\/github.com\/backstage\/backstage\/(tree|blob)\/master/, - '', + // Check for invisible/zero-width characters in the URL + if (INVISIBLE_CHAR_PATTERN.test(absUrl)) { + return { url: absUrl, basePath, problem: 'invisible-chars' }; + } + + const anchorMatch = absUrl.match(/#(.+)$/); + const anchor = anchorMatch ? anchorMatch[1] : undefined; + const urlWithoutAnchor = absUrl.replace(/#.*$/, ''); + const isGitHubUrl = + /https:\/\/github.com\/backstage\/backstage\/(tree|blob)\/master/.test( + urlWithoutAnchor, ); + const url = urlWithoutAnchor.replace( + /https:\/\/github.com\/backstage\/backstage\/(tree|blob)\/master/, + '', + ); // Avoid having absolute URL links within docs/, so that links work on the site if ( @@ -68,6 +161,15 @@ async function verifyUrl(basePath, absUrl, docPages) { return { url: absUrl, basePath, problem: 'github' }; } + // Same-file anchor reference (e.g. #some-heading) + if (!url && anchor) { + const anchors = await getAnchorsForFile(basePath); + if (anchors && !anchors.has(anchor)) { + return { url: absUrl, basePath, problem: 'bad-anchor' }; + } + return undefined; + } + if (!url) { return undefined; } @@ -132,12 +234,42 @@ async function verifyUrl(basePath, absUrl, docPages) { return { url, basePath, problem: 'missing' }; } + // Flag relative links to directories that are missing /index.md — + // these resolve as existing dirs but aren't valid doc links. + // Only check within docs/ since other directories (like microsite/) + // may legitimately link to directories in READMEs. + if ( + basePath.match(/^docs\//) && + !url.startsWith('/') && + existsSync(path) && + statSync(path).isDirectory() + ) { + return { url: absUrl, basePath, problem: 'directory-link' }; + } + + // Verify anchors in cross-file links, but skip rewritten GitHub URLs + // since their anchors may reference generated content we can't verify locally + if (anchor && path.endsWith('.md') && !isGitHubUrl) { + const targetAnchors = await getAnchorsForFile( + path.startsWith(projectRoot) ? path.slice(projectRoot.length + 1) : path, + ); + if (targetAnchors && !targetAnchors.has(anchor)) { + return { url: absUrl, basePath, problem: 'bad-anchor' }; + } + } + return undefined; } +// Strips fenced code blocks from markdown content so we don't check links inside them +function stripCodeBlocks(content) { + return content.replace(/^```[^\n]*\n[\s\S]*?^```/gm, ''); +} + async function verifyFile(filePath, docPages) { const content = await fs.readFile(filePath, 'utf8'); - const mdLinks = content.match(/\[.+?\]\(.+?\)/g) || []; + const strippedContent = stripCodeBlocks(content); + const mdLinks = strippedContent.match(/\[.+?\]\(.+?\)/g) || []; const badUrls = []; for (const mdLink of mdLinks) { @@ -149,7 +281,7 @@ async function verifyFile(filePath, docPages) { } const multiLineLinks = - content.match(/\[[^\]\n]+?\n[^\]\n]*?(?:\n[^\]\n]*?)?\]\(/g) || []; + strippedContent.match(/\[[^\]\n]+?\n[^\]\n]*?(?:\n[^\]\n]*?)?\]\(/g) || []; badUrls.push( ...multiLineLinks.map(url => ({ url, @@ -279,6 +411,20 @@ async function main() { console.error(`Links are not allowed to span multiple lines:`); console.error(` From: ${basePath}`); console.error(` To: ${url.replace(/\n/g, '\n ')}`); + } else if (problem === 'bad-anchor') { + console.error(`Anchor not found in target document`); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + } else if (problem === 'directory-link') { + console.error( + `Link points to a directory instead of a file, use index.md suffix`, + ); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + } else if (problem === 'invisible-chars') { + console.error(`Link contains invisible or zero-width characters`); + console.error(` From: ${basePath}`); + console.error(` To: ${JSON.stringify(url)}`); } } process.exit(1); diff --git a/yarn.lock b/yarn.lock index 2228ae82c3..821675739b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2900,6 +2900,7 @@ __metadata: cross-spawn: "npm:^7.0.3" css-loader: "npm:^6.5.1" ctrlc-windows: "npm:^2.1.0" + embedded-postgres: "npm:18.3.0-beta.16" esbuild-loader: "npm:^4.0.0" eslint-rspack-plugin: "npm:^4.2.1" eslint-webpack-plugin: "npm:^4.2.0" @@ -2912,6 +2913,7 @@ __metadata: node-stdlib-browser: "npm:^1.3.1" npm-packlist: "npm:^5.0.0" p-queue: "npm:^6.6.2" + portfinder: "npm:^1.0.32" postcss: "npm:^8.1.0" postcss-import: "npm:^16.1.0" process: "npm:^0.11.10" @@ -2934,6 +2936,11 @@ __metadata: webpack-dev-server: "npm:^5.0.0" yml-loader: "npm:^2.1.0" yn: "npm:^4.0.0" + peerDependencies: + embedded-postgres: ^18.3.0-beta.16 + peerDependenciesMeta: + embedded-postgres: + optional: true bin: cli-module-build: bin/backstage-cli-module-build languageName: unknown @@ -4916,6 +4923,7 @@ __metadata: "@octokit/auth-callback": "npm:^5.0.0" "@octokit/core": "npm:^5.2.0" "@octokit/graphql": "npm:^7.0.2" + "@octokit/plugin-retry": "npm:^6.0.0" "@octokit/plugin-throttling": "npm:^8.1.3" "@octokit/rest": "npm:^19.0.3" "@octokit/webhooks-types": "npm:^7.6.1" @@ -6817,7 +6825,6 @@ __metadata: "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" "@backstage/repo-tools": "workspace:^" "@backstage/types": "workspace:^" - "@opentelemetry/api": "npm:^1.9.0" "@types/express": "npm:^4.17.6" "@types/fs-extra": "npm:^11.0.0" "@types/luxon": "npm:^3.0.0" @@ -8556,6 +8563,62 @@ __metadata: languageName: node linkType: hard +"@embedded-postgres/darwin-arm64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/darwin-arm64@npm:18.3.0-beta.16" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@embedded-postgres/darwin-x64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/darwin-x64@npm:18.3.0-beta.16" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@embedded-postgres/linux-arm64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/linux-arm64@npm:18.3.0-beta.16" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@embedded-postgres/linux-arm@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/linux-arm@npm:18.3.0-beta.16" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@embedded-postgres/linux-ia32@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/linux-ia32@npm:18.3.0-beta.16" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@embedded-postgres/linux-ppc64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/linux-ppc64@npm:18.3.0-beta.16" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@embedded-postgres/linux-x64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/linux-x64@npm:18.3.0-beta.16" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@embedded-postgres/windows-x64@npm:^18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "@embedded-postgres/windows-x64@npm:18.3.0-beta.16" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.5.0, @emnapi/core@npm:^1.7.1": version: 1.7.1 resolution: "@emnapi/core@npm:1.7.1" @@ -29365,6 +29428,41 @@ __metadata: languageName: node linkType: hard +"embedded-postgres@npm:18.3.0-beta.16": + version: 18.3.0-beta.16 + resolution: "embedded-postgres@npm:18.3.0-beta.16" + dependencies: + "@embedded-postgres/darwin-arm64": "npm:^18.3.0-beta.16" + "@embedded-postgres/darwin-x64": "npm:^18.3.0-beta.16" + "@embedded-postgres/linux-arm": "npm:^18.3.0-beta.16" + "@embedded-postgres/linux-arm64": "npm:^18.3.0-beta.16" + "@embedded-postgres/linux-ia32": "npm:^18.3.0-beta.16" + "@embedded-postgres/linux-ppc64": "npm:^18.3.0-beta.16" + "@embedded-postgres/linux-x64": "npm:^18.3.0-beta.16" + "@embedded-postgres/windows-x64": "npm:^18.3.0-beta.16" + async-exit-hook: "npm:^2.0.1" + pg: "npm:^8.7.3" + dependenciesMeta: + "@embedded-postgres/darwin-arm64": + optional: true + "@embedded-postgres/darwin-x64": + optional: true + "@embedded-postgres/linux-arm": + optional: true + "@embedded-postgres/linux-arm64": + optional: true + "@embedded-postgres/linux-ia32": + optional: true + "@embedded-postgres/linux-ppc64": + optional: true + "@embedded-postgres/linux-x64": + optional: true + "@embedded-postgres/windows-x64": + optional: true + checksum: 10/13ebdec978559d8d5496df521ec6d6a717a6a3e234a7daa1d3d85e8d050626cde927e5d1d382c70eec219afa721d3c28c26a39023de0a5919feb535470860b47 + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -41564,7 +41662,7 @@ __metadata: languageName: node linkType: hard -"pg@npm:^8.11.3, pg@npm:^8.9.0": +"pg@npm:^8.11.3, pg@npm:^8.7.3, pg@npm:^8.9.0": version: 8.20.0 resolution: "pg@npm:8.20.0" dependencies: