Merge branch 'master' into stack-overflow-search-highlight-fix

This commit is contained in:
Tommy Le
2024-02-13 13:26:13 +01:00
49 changed files with 818 additions and 75 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Fix a bug with S3 Fetch that caused all objects to be flattened within a single folder on the local file system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/theme': patch
---
Fixed missing extra variables like `applyDarkStyles` in Mui V5 theme after calling `createUnifiedThemeFromV4` function
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Allow the `createConfigSecretEnumerator` to take an optional `schema` argument with an already-loaded global configuration schema.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-github': patch
---
Added `validateLocationsExist` to the config
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-app-backend': patch
'@backstage/plugin-app-node': patch
---
Allow the `app-backend` plugin to use a global configuration schema provided externally through an extension.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-signals-react': patch
'@backstage/plugin-signals-node': patch
'@backstage/plugin-signals': patch
---
Allow defining signal type to publish and receive
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added `experimentalExtraAllowedOrigins` to config
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-dynatrace': patch
---
Fixed Dynatrace plugin proxy configuration
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/backend-dynamic-feature-service': minor
---
Implement the discovery of additional individual configuration schemas for dynamic plugins, and provide:
- an alternate implementation of the root logger service that takes them into account,
- an extension to the App backend plugin to set a global configuration schema that takes them into account.
@@ -14,7 +14,7 @@ creation-date: 2024-01-28
<!-- Before merging the initial BEP PR, create a feature issue and update the below link. You can wait with this step until the BEP is ready to be merged. -->
[**Discussion Issue**](https://github.com/backstage/backstage/issues/NNNNN)
[**Discussion Issue**](https://github.com/backstage/backstage/issues/22605)
- [Summary](#summary)
- [Motivation](#motivation)
+155 -2
View File
@@ -9,8 +9,159 @@ This guide explains how to deploy Backstage to [Flightcontrol](https://www.fligh
Before you begin, make sure you have a [Flightcontrol account](https://app.flightcontrol.dev/signup?ref=backstage) and a [Github account](https://github.com/login) to follow this guide.
# Creating a Dockerfile and .dockerignore
Once your Flightcontrol account is setup, you will need to prepare a `Dockerfile` and `.dockerignore` to deploy Backstage to your target AWS environment. The standard `Dockerfile` included in the Backstage repository assumes that the `skeleton.tar.gz` and `bundle.tar.gz` already exist. When integrating Flightcontrol directly with a GitHub repository, changes to files within this repository will automatically start a Flightcontrol deployment, unless you have configured deployments via a webhook. Regardless of which method you have chosen, when Flightcontrol starts a deployment process it will pull all files directly from the repository, and not from a GitHub runner or other location. As Flightcontrol does not contain CI steps this results in the zip files having to be committed to the repository in order to be available for the `Dockerfile` to copy.
A simple work around for this is to use a `Dockerfile` with a [multi-stage build](https://docs.docker.com/build/building/multi-stage/). Using this approach we can create our zip files in the first build stage, and copy them into the second stage ready for deployment.
The following two code snippets demonstrate this method. The first block of code is an example of a multi-stage build approach and should be added to a file called `Dockerfile.flightcontrol` in the `packages/backend` directory.
```dockerfile filename="Dockerfile.flightcontrol"
# This dockerfile builds an image for the backend package.
# It should be executed with the root of the repo as docker context.
#
# Before building this image, be sure to have run the following commands in the repo root:
#
# yarn install
# yarn tsc
# yarn build:backend
#
# Once the commands have been run, you can build the image using `yarn build-image`
FROM node:18-bookworm-slim as build
USER node
WORKDIR /app
COPY --chown=node:node . .
RUN yarn install --frozen-lockfile
# tsc outputs type definitions to dist-types/ in the repo root, which are then consumed by the build
RUN yarn tsc
# Build the backend, which bundles it all up into the packages/backend/dist folder.
# The configuration files here should match the one you use inside the Dockerfile below.
RUN yarn build:backend --config ../../app-config.yaml
FROM node:18-bookworm-slim
# Install isolate-vm dependencies, these are needed by the @backstage/plugin-scaffolder-backend.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends python3 g++ build-essential && \
yarn config set python /usr/bin/python3
# Install sqlite3 dependencies. You can skip this if you don't use sqlite3 in the image,
# in which case you should also move better-sqlite3 to "devDependencies" in package.json.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends libsqlite3-dev
# From here on we use the least-privileged `node` user to run the backend.
USER node
# This should create the app dir as `node`.
# If it is instead created as `root` then the `tar` command below will fail: `can't create directory 'packages/': Permission denied`.
# If this occurs, then ensure BuildKit is enabled (`DOCKER_BUILDKIT=1`) so the app dir is correctly created as `node`.
WORKDIR /app
# This switches many Node.js dependencies to production mode.
ENV NODE_ENV production
# Copy repo skeleton first, to avoid unnecessary docker cache invalidation.
# The skeleton contains the package.json of each package in the monorepo,
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
COPY --from=build app/yarn.lock app/package.json app/packages/backend/dist/skeleton.tar.gz ./
RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
yarn install --frozen-lockfile --production --network-timeout 300000
# Then copy the rest of the backend bundle, along with any other files we might want.
COPY --from=build app/packages/backend/dist/bundle.tar.gz app/app-config*.yaml ./
RUN tar xzf bundle.tar.gz && rm bundle.tar.gz
CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"]
```
In order to prevent the default `.dockerignore` file from preventing the copying of the relevant files into the first stage of our build, we need to override it with a custom one. This can also be added to the `packages/backend` directory, it should be called `Dockerfile.flightcontrol.dockerignore` and contain the following.
```ssh filename="Dockerfile.flightcontrol.dockerignore"
.git
.yarn/cache
.yarn/install-state.gz
node_modules
packages/*/node_modules
*.local.yaml
```
With these two new files, you will now have the necessary steps in place to build the zip files, copy them into the second stage of the build, and deploy them via Flightcontrol.
# Creating a custom app-config.production file
Our next steps before switching to the Flightcontrol dashboard is to create a custom app-config.production file. The purposes of this is to update how Backstage will connect to the RDS hosted Postgres DB. The default `app-config.production` file uses the `host`, `port`, `user` and `password` environment variables.
When creating a database via the Flightcontrol dashboard, we are given an environment variable with the single connection string. Therefore we need an `app-config.production` file which uses this variable.
Within your Backstage project create a new file called `app-config.production.flightcontrol.yaml`. Add the following configuration to it:
```yaml filename="app-config.production.flightcontrol.yaml"
app:
# Should be the same as backend.baseUrl when using the `app-backend` plugin.
baseUrl: http://localhost:7007
backend:
# Note that the baseUrl should be the URL that the browser and other clients
# should use when communicating with the backend, i.e. it needs to be
# reachable not just from within the backend host, but from all of your
# callers. When its value is "http://localhost:7007", it's strictly private
# and can't be reached by others.
baseUrl: http://localhost:7007
# The listener can also be expressed as a single <host>:<port> string. In this case we bind to
# all interfaces, the most permissive setting. The right value depends on your specific deployment.
listen: ':7007'
# config options: https://node-postgres.com/api/client
database:
client: pg
pluginDivisionMode: 'schema'
connection:
connectionString: ${POSTGRES_CON_STRING}
ssl:
require: true
rejectUnauthorized: false
# Flightcontrol provides a single DB connection string
# environment variable which can be renamed to POSTGRES_CON_STRING.
# the following default values are therefore not used:
# host: ${POSTGRES_HOST}
# port: ${POSTGRES_PORT}
# user: ${POSTGRES_USER}
# password: ${POSTGRES_PASSWORD}
# https://node-postgres.com/features/ssl
# you can set the sslmode configuration option via the `PGSSLMODE` environment variable
# see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require)
# ssl:
# ca: # if you have a CA file and want to verify it you can uncomment this section
# $file: <file-path>/ca/server.crt
catalog:
# Overrides the default list locations from app-config.yaml as these contain example data.
# See https://backstage.io/docs/features/software-catalog/#adding-components-to-the-catalog for more details
# on how to get entities into the catalog.
locations: []
```
Make a note of the environment variable used in the `connectionString`. In our example above this is `POSTGRES_CON_STRING`. You will need this later when you have deployed the database via the Flightcontrol console, and wish to connect to it from Backstage.
# Deployment Via Dashboard
Ensure that custom `Dockerfile.flightcontrol`, `Dockerfile.flightcontrol.dockerignore` and `app-config.production.flightcontrol.yaml` have been committed to your repository before following the next steps.
Login into the Flightcontrol Dashboard and complete each of the following:
1. Create a new project from the Flightcontrol Dashboard
2. Select the GitHub repo for your Backstage project
@@ -25,7 +176,7 @@ Before you begin, make sure you have a [Flightcontrol account](https://app.fligh
| Health Check Path | /catalog |
| Port | 7007 |
5. Click `Create Project` and complete any required steps (like linking your AWS account).
5. Click `Create Project` and complete any required steps (like linking your AWS account). Remember to point the project to your custom files.
# Deployment via Code
@@ -33,7 +184,7 @@ Before you begin, make sure you have a [Flightcontrol account](https://app.fligh
2. Select the GitHub repo for your Backstage project
3. Select the `flightcontrol.json` Config Type.
3. Select the `flightcontrol.json` Config Type. Update the example JSON below where applicable to your custom Dockerfile.
```json filename="flightcontrol.json"
{
@@ -72,6 +223,8 @@ Before you begin, make sure you have a [Flightcontrol account](https://app.fligh
If you need a database or Redis for your Backstage plugins, you can easily add those to your Flightcontrol deployment. For more information, see [the flightcontrol docs](https://www.flightcontrol.dev/docs/guides/flightcontrol/using-code?ref=backstage#redis).
When creating a Postgres RDS database you will need to update the connection string variable name to the one included in the `app-config.production.flightcontrol.yaml`. As noted above in our example we called this `POSTGRES_CON_STRING`.
## Troubleshooting
- [Flightcontrol Documentation](https://www.flightcontrol.dev/docs?ref=backstage)
+1 -1
View File
@@ -384,7 +384,7 @@ $ yarn build-image --tag backstage:1.0.0
There is no special wiring needed to access the PostgreSQL service. Since it's
running on the same cluster, Kubernetes will inject `POSTGRES_SERVICE_HOST` and
`POSTGRES_SERVICE_PORT` environment variables into our Backstage container.
These can be used in the Backstage `app-config.yaml` along with the secrets:
These can be used in the Backstage `app-config.yaml` along with the secrets. Apply this to app-config.production.yaml as well if you have one:
```yaml
backend:
+2
View File
@@ -9,6 +9,7 @@ import type { AppConfig } from '@backstage/config';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CacheClient } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { ConfigSchema } from '@backstage/config-loader';
import { CorsOptions } from 'cors';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { ErrorRequestHandler } from 'express';
@@ -64,6 +65,7 @@ export const cacheServiceFactory: () => ServiceFactory<CacheClient, 'plugin'>;
export function createConfigSecretEnumerator(options: {
logger: LoggerService;
dir?: string;
schema?: ConfigSchema;
}): Promise<(config: Config) => Iterable<string>>;
// @public
@@ -23,6 +23,7 @@ import {
loadConfig,
ConfigTarget,
LoadConfigOptionsRemote,
ConfigSchema,
} from '@backstage/config-loader';
import { ConfigReader } from '@backstage/config';
import type { Config, AppConfig } from '@backstage/config';
@@ -34,12 +35,15 @@ import { isValidUrl } from '../lib/urls';
export async function createConfigSecretEnumerator(options: {
logger: LoggerService;
dir?: string;
schema?: ConfigSchema;
}): Promise<(config: Config) => Iterable<string>> {
const { logger, dir = process.cwd() } = options;
const { packages } = await getPackages(dir);
const schema = await loadConfigSchema({
dependencies: packages.map(p => p.packageJson.name),
});
const schema =
options.schema ??
(await loadConfigSchema({
dependencies: packages.map(p => p.packageJson.name).filter(() => false),
}));
return (config: Config) => {
const [secretsData] = schema.process(
@@ -46,6 +46,7 @@ import {
import { AbortController } from '@aws-sdk/abort-controller';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
import { Readable } from 'stream';
import { relative } from 'path/posix';
export const DEFAULT_REGION = 'us-east-1';
@@ -345,7 +346,7 @@ export class AwsS3UrlReader implements UrlReader {
responses.push({
data: s3ObjectData,
path: String(allObjects[i]),
path: relative(path, String(allObjects[i])),
lastModifiedAt: response?.LastModified ?? undefined,
});
}
@@ -19,6 +19,7 @@ import path from 'path';
import { FromReadableArrayOptions } from '../types';
import { ReadableArrayResponse } from './ReadableArrayResponse';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { relative } from 'path/posix';
const name1 = 'file1.yaml';
const file1 = fs.readFileSync(
@@ -74,9 +75,12 @@ describe('ReadableArrayResponse', () => {
});
it('should extract entire archive into directory', async () => {
const relativePath1 = relative(sourceDir.path, path1);
const relativePath2 = relative(sourceDir.path, path2);
const arr: FromReadableArrayOptions = [
{ data: createReadStream(path1), path: path1 },
{ data: createReadStream(path2), path: path2 },
{ data: createReadStream(path1), path: relativePath1 },
{ data: createReadStream(path2), path: relativePath2 },
];
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
@@ -15,7 +15,7 @@
*/
import concatStream from 'concat-stream';
import platformPath, { basename } from 'path';
import platformPath, { dirname } from 'path';
import getRawBody from 'raw-body';
import fs from 'fs-extra';
@@ -96,12 +96,9 @@ export class ReadableArrayResponse implements ReadTreeResponse {
for (let i = 0; i < this.stream.length; i++) {
if (!this.stream[i].path.endsWith('/')) {
await pipeline(
this.stream[i].data,
fs.createWriteStream(
platformPath.join(dir, basename(this.stream[i].path)),
),
);
const filePath = platformPath.join(dir, this.stream[i].path);
await fs.mkdir(dirname(filePath), { recursive: true });
await pipeline(this.stream[i].data, fs.createWriteStream(filePath));
}
}
@@ -7,6 +7,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api';
import { BackstagePackageJson } from '@backstage/cli-node';
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { ConfigSchema } from '@backstage/config-loader';
import { EventBroker } from '@backstage/plugin-events-node';
import { EventsBackend } from '@backstage/plugin-events-backend';
import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha';
@@ -23,6 +24,7 @@ import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { RootLoggerService } from '@backstage/backend-plugin-api';
import { Router } from 'express';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
@@ -115,6 +117,33 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory<
'root'
>;
// @public (undocumented)
export const dynamicPluginsFrontendSchemas: () => BackendFeature;
// @public (undocumented)
export const dynamicPluginsRootLoggerServiceFactory: () => ServiceFactory<
RootLoggerService,
'root'
>;
// @public (undocumented)
export interface DynamicPluginsSchemasOptions {
schemaLocator?: (pluginPackage: ScannedPluginPackage) => string;
}
// @public (undocumented)
export interface DynamicPluginsSchemasService {
// (undocumented)
addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{
schema: ConfigSchema;
}>;
}
// @public (undocumented)
export const dynamicPluginsSchemasServiceFactory: (
options?: DynamicPluginsSchemasOptions | undefined,
) => ServiceFactory<DynamicPluginsSchemasService, 'root'>;
// @public (undocumented)
export const dynamicPluginsServiceFactory: (
options?: DynamicPluginsFactoryOptions | undefined,
@@ -5,9 +5,7 @@
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
"access": "public"
},
"repository": {
"type": "git",
@@ -17,6 +15,23 @@
"backstage": {
"role": "node-library"
},
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"package.json": [
"package.json"
]
}
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/backend-dynamic-feature-service"
},
"keywords": [
"backstage"
],
@@ -31,13 +46,16 @@
"start": "backstage-cli package start"
},
"dependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/cli-node": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-app-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-backend": "workspace:^",
"@backstage/plugin-events-backend": "workspace:^",
@@ -48,20 +66,21 @@
"@backstage/plugin-search-backend-node": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@types/express": "^4.17.6",
"chokidar": "^3.5.3",
"express": "^4.17.1",
"fs-extra": "10.1.0",
"lodash": "^4.17.21",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config-loader": "workspace:^",
"wait-for-expect": "^3.0.2"
},
"files": [
"dist"
"dist",
"config.d.ts"
]
}
@@ -17,3 +17,4 @@
export * from './loader';
export * from './scanner';
export * from './manager';
export * from './schemas';
@@ -0,0 +1,53 @@
/*
* 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 {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { dynamicPluginsSchemasServiceRef } from './schemas';
import {
configSchemaExtensionPoint,
loadCompiledConfigSchema,
} from '@backstage/plugin-app-node';
import { resolvePackagePath } from '@backstage/backend-common';
/** @public */
export const dynamicPluginsFrontendSchemas = createBackendModule({
pluginId: 'app',
moduleId: 'core.dynamicplugins.frontendSchemas',
register(reg) {
reg.registerInit({
deps: {
config: coreServices.rootConfig,
schemas: dynamicPluginsSchemasServiceRef,
configSchemaExtension: configSchemaExtensionPoint,
},
async init({ config, schemas, configSchemaExtension }) {
const appPackageName =
config.getOptionalString('app.packageName') ?? 'app';
const appDistDir = resolvePackagePath(appPackageName, 'dist');
const compiledConfigSchema = await loadCompiledConfigSchema(appDistDir);
if (compiledConfigSchema) {
configSchemaExtension.setConfigSchema(
(await schemas.addDynamicPluginsSchemas(compiledConfigSchema))
.schema,
);
}
},
});
},
});
@@ -0,0 +1,24 @@
/*
* 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.
*/
export {
dynamicPluginsSchemasServiceFactory,
type DynamicPluginsSchemasService,
type DynamicPluginsSchemasOptions,
} from './schemas';
export { dynamicPluginsFrontendSchemas } from './appBackendModule';
export { dynamicPluginsRootLoggerServiceFactory } from './rootLoggerServiceFactory';
@@ -0,0 +1,63 @@
/*
* 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 {
createServiceFactory,
coreServices,
} from '@backstage/backend-plugin-api';
import { WinstonLogger } from '@backstage/backend-app-api';
import { transports, format } from 'winston';
import { createConfigSecretEnumerator } from '@backstage/backend-app-api';
import { loadConfigSchema } from '@backstage/config-loader';
import { getPackages } from '@manypkg/get-packages';
import { dynamicPluginsSchemasServiceRef } from './schemas';
/** @public */
export const dynamicPluginsRootLoggerServiceFactory = createServiceFactory({
service: coreServices.rootLogger,
deps: {
config: coreServices.rootConfig,
schemas: dynamicPluginsSchemasServiceRef,
},
async factory({ config, schemas }) {
const logger = WinstonLogger.create({
meta: {
service: 'backstage',
},
level: process.env.LOG_LEVEL || 'info',
format:
process.env.NODE_ENV === 'production'
? format.json()
: WinstonLogger.colorFormat(),
transports: [new transports.Console()],
});
const configSchema = await loadConfigSchema({
dependencies: (
await getPackages(process.cwd())
).packages.map(p => p.packageJson.name),
});
const secretEnumerator = await createConfigSecretEnumerator({
logger,
schema: (await schemas.addDynamicPluginsSchemas(configSchema)).schema,
});
logger.addRedactions(secretEnumerator(config));
config.subscribe?.(() => logger.addRedactions(secretEnumerator(config)));
return logger;
},
});
@@ -0,0 +1,187 @@
/*
* 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 { ScannedPluginPackage } from '../scanner/types';
import {
coreServices,
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { findPaths } from '@backstage/cli-common';
import fs from 'fs-extra';
import * as path from 'path';
import * as url from 'url';
import { isEmpty } from 'lodash';
import { LoggerService } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { PluginScanner } from '../scanner/plugin-scanner';
import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader';
/**
*
* @public
* */
export interface DynamicPluginsSchemasService {
addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{
schema: ConfigSchema;
}>;
}
/**
* A service that provides the config schemas of scanned dynamic plugins.
*
* @public
*/
export const dynamicPluginsSchemasServiceRef =
createServiceRef<DynamicPluginsSchemasService>({
id: 'core.dynamicplugins.schemas',
scope: 'root',
});
/**
* @public
*/
export interface DynamicPluginsSchemasOptions {
/**
* Function that returns the path to the Json schema file for a given scanned plugin package.
* The path is either absolute, or relative to the plugin package root directory.
*
* Default behavior is to look for the `dist/configSchema.json` relative path.
*
* @param pluginPackage - The scanned plugin package.
* @returns the absolute or plugin-relative path to the Json schema file.
*/
schemaLocator?: (pluginPackage: ScannedPluginPackage) => string;
}
/**
* @public
*/
export const dynamicPluginsSchemasServiceFactory = createServiceFactory(
(options?: DynamicPluginsSchemasOptions) => ({
service: dynamicPluginsSchemasServiceRef,
deps: {
config: coreServices.rootConfig,
},
factory({ config }) {
let additionalSchemas: { [context: string]: JsonObject } | undefined;
return {
async addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{
schema: ConfigSchema;
}> {
if (!additionalSchemas) {
const logger = {
...console,
child() {
return this;
},
};
const scanner = PluginScanner.create({
config,
logger,
// eslint-disable-next-line no-restricted-syntax
backstageRoot: findPaths(__dirname).targetRoot,
preferAlpha: true,
});
const { packages } = await scanner.scanRoot();
additionalSchemas = await gatherDynamicPluginsSchemas(
packages,
logger,
options?.schemaLocator,
);
}
const serialized = configSchema.serialize();
if (serialized?.backstageConfigSchemaVersion !== 1) {
throw new Error(
'Serialized configuration schema is invalid or has an invalid version number',
);
}
const schemas = serialized.schemas as {
value: JsonObject;
path: string;
}[];
schemas.push(
...Object.keys(additionalSchemas).map(context => {
return {
path: context,
value: additionalSchemas![context],
};
}),
);
serialized.schemas = schemas;
return {
schema: await loadConfigSchema({
serialized,
}),
};
},
};
},
}),
);
/** @internal */
async function gatherDynamicPluginsSchemas(
packages: ScannedPluginPackage[],
logger: LoggerService,
schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () =>
path.join('dist', 'configSchema.json'),
): Promise<{ [context: string]: JsonObject }> {
const allSchemas: { [context: string]: JsonObject } = {};
for (const pluginPackage of packages) {
let schemaLocation = schemaLocator(pluginPackage);
if (!path.isAbsolute(schemaLocation)) {
let pluginLocation = url.fileURLToPath(pluginPackage.location);
if (path.basename(pluginLocation) === 'alpha') {
pluginLocation = path.dirname(pluginLocation);
}
schemaLocation = path.resolve(pluginLocation, schemaLocation);
}
if (!(await fs.pathExists(schemaLocation))) {
continue;
}
const serialized = await fs.readJson(schemaLocation);
if (!serialized) {
continue;
}
if (isEmpty(serialized)) {
continue;
}
if (!serialized?.$schema || serialized?.type !== 'object') {
logger.error(
`Serialized configuration schema is invalid for plugin ${pluginPackage.manifest.name}`,
);
continue;
}
allSchemas[schemaLocation] = serialized;
}
return allSchemas;
}
+1 -1
View File
@@ -93,5 +93,5 @@ export function createUnifiedThemeFromV4(
): UnifiedTheme {
const v5Theme = adaptV4Theme(options as DeprecatedThemeOptions);
const v4Theme = createTheme(options);
return new UnifiedThemeHolder(v4Theme, v5Theme);
return new UnifiedThemeHolder(v4Theme, createV5Theme(v5Theme));
}
+2
View File
@@ -4,6 +4,7 @@
```ts
import { Config } from '@backstage/config';
import { ConfigSchema } from '@backstage/config-loader';
import express from 'express';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
@@ -20,6 +21,7 @@ export interface RouterOptions {
disableConfigInjection?: boolean;
// (undocumented)
logger: Logger;
schema?: ConfigSchema;
staticFallbackHandler?: express.Handler;
}
```
+11 -2
View File
@@ -19,7 +19,11 @@ import { resolve as resolvePath } from 'path';
import { Logger } from 'winston';
import { AppConfig, Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader';
import {
ConfigSchema,
loadConfigSchema,
readEnvConfig,
} from '@backstage/config-loader';
type InjectOptions = {
appConfigs: AppConfig[];
@@ -74,6 +78,7 @@ type ReadOptions = {
env: { [name: string]: string | undefined };
appDistDir: string;
config: Config;
schema?: ConfigSchema;
};
/**
@@ -90,7 +95,11 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const serializedSchema = await fs.readJson(schemaPath);
try {
const schema = await loadConfigSchema({ serialized: serializedSchema });
const schema =
options.schema ||
(await loadConfigSchema({
serialized: serializedSchema,
}));
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
+19 -1
View File
@@ -21,7 +21,11 @@ import {
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import { loggerToWinstonLogger } from '@backstage/backend-common';
import { staticFallbackHandlerExtensionPoint } from '@backstage/plugin-app-node';
import {
configSchemaExtensionPoint,
staticFallbackHandlerExtensionPoint,
} from '@backstage/plugin-app-node';
import { ConfigSchema } from '@backstage/config-loader';
/**
* The App plugin is responsible for serving the frontend app bundle and static assets.
@@ -31,6 +35,7 @@ export const appPlugin = createBackendPlugin({
pluginId: 'app',
register(env) {
let staticFallbackHandler: express.Handler | undefined;
let schema: ConfigSchema | undefined;
env.registerExtensionPoint(staticFallbackHandlerExtensionPoint, {
setStaticFallbackHandler(handler) {
@@ -43,6 +48,17 @@ export const appPlugin = createBackendPlugin({
},
});
env.registerExtensionPoint(configSchemaExtensionPoint, {
setConfigSchema(configSchema) {
if (schema) {
throw new Error(
'Attempted to set config schema for the app-backend twice',
);
}
schema = configSchema;
},
});
env.registerInit({
deps: {
logger: coreServices.logger,
@@ -53,6 +69,7 @@ export const appPlugin = createBackendPlugin({
async init({ logger, config, database, httpRouter }) {
const appPackageName =
config.getOptionalString('app.packageName') ?? 'app';
const winstonLogger = loggerToWinstonLogger(logger);
const router = await createRouter({
@@ -61,6 +78,7 @@ export const appPlugin = createBackendPlugin({
database,
appPackageName,
staticFallbackHandler,
schema,
});
httpRouter.use(router);
},
@@ -37,6 +37,7 @@ import {
CACHE_CONTROL_NO_CACHE,
CACHE_CONTROL_REVALIDATE_CACHE,
} from '../lib/headers';
import { ConfigSchema } from '@backstage/config-loader';
// express uses mime v1 while we only have types for mime v2
type Mime = { lookup(arg0: string): string };
@@ -85,6 +86,13 @@ export interface RouterOptions {
* This also disables configuration injection though `APP_CONFIG_` environment variables.
*/
disableConfigInjection?: boolean;
/**
*
* Provides a ConfigSchema.
*
*/
schema?: ConfigSchema;
}
/** @public */
@@ -121,6 +129,7 @@ export async function createRouter(
config,
appDistDir,
env: process.env,
schema: options.schema,
});
injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir });
+14
View File
@@ -3,9 +3,23 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ConfigSchema } from '@backstage/config-loader';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { Handler } from 'express';
// @public
export interface ConfigSchemaExtensionPoint {
setConfigSchema(configSchema: ConfigSchema): void;
}
// @public
export const configSchemaExtensionPoint: ExtensionPoint<ConfigSchemaExtensionPoint>;
// @public
export function loadCompiledConfigSchema(
appDistDir: string,
): Promise<ConfigSchema | undefined>;
// @public
export interface StaticFallbackHandlerExtensionPoint {
setStaticFallbackHandler(handler: Handler): void;
+3 -1
View File
@@ -34,7 +34,9 @@
],
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@types/express": "^4.17.6",
"express": "^4.17.1"
"express": "^4.17.1",
"fs-extra": "10.1.0"
}
}
+23
View File
@@ -15,6 +15,7 @@
*/
import { createExtensionPoint } from '@backstage/backend-plugin-api';
import { ConfigSchema } from '@backstage/config-loader';
import { Handler } from 'express';
/**
@@ -50,3 +51,25 @@ export const staticFallbackHandlerExtensionPoint =
createExtensionPoint<StaticFallbackHandlerExtensionPoint>({
id: 'app.staticFallbackHandler',
});
/**
* The interface for {@link configSchemaExtensionPoint}.
*
* @public
*/
export interface ConfigSchemaExtensionPoint {
/**
* Sets the config schema. This can only be done once.
*/
setConfigSchema(configSchema: ConfigSchema): void;
}
/**
* An extension point the exposes the ability to override the config schema used by the frontend application.
*
* @public
*/
export const configSchemaExtensionPoint =
createExtensionPoint<ConfigSchemaExtensionPoint>({
id: 'app.configSchema',
});
+4
View File
@@ -23,4 +23,8 @@
export {
staticFallbackHandlerExtensionPoint,
type StaticFallbackHandlerExtensionPoint,
configSchemaExtensionPoint,
type ConfigSchemaExtensionPoint,
} from './extensions';
export { loadCompiledConfigSchema } from './schema';
+39
View File
@@ -0,0 +1,39 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader';
/**
* Loads the config schema that is embedded in the frontend build.
*
* @public
*/
export async function loadCompiledConfigSchema(
appDistDir: string,
): Promise<ConfigSchema | undefined> {
const schemaPath = resolvePath(appDistDir, '.config-schema.json');
if (await fs.pathExists(schemaPath)) {
const serializedSchema = await fs.readJson(schemaPath);
return await loadConfigSchema({
serialized: serializedSchema,
});
}
return undefined;
}
+4
View File
@@ -191,5 +191,9 @@ export interface Config {
*/
backstageTokenExpiration?: HumanDuration;
};
/**
* Additional app origins to allow for authenticating
*/
experimentalExtraAllowedOrigins?: string[];
};
}
+5
View File
@@ -135,6 +135,11 @@ export interface Config {
* Default: `/catalog-info.yaml`.
*/
catalogPath?: string;
/**
* (Optional) Whether to validate locations that exist before emitting them.
* Default: `false`.
*/
validateLocationsExist?: boolean;
/**
* (Optional) Filter configuration.
*/
+4 -2
View File
@@ -418,8 +418,10 @@ By default, only packages with names starting with `@backstage` and `@internal`
devTools:
info:
packagePrefixes:
- @roadiehq/backstage-
- @spotify/backstage-
# Note that you MUST have quotes around these. The YAML won't be valid
# if you don't, because of the leading at-symbols.
- '@roadiehq/backstage-'
- '@spotify/backstage-'
```
### External Dependencies Configuration
+5 -4
View File
@@ -62,10 +62,11 @@ This plugin requires a proxy endpoint for Dynatrace configured in `app-config.ya
```yaml
proxy:
'/dynatrace':
target: 'https://example.dynatrace.com/api/v2'
headers:
Authorization: 'Api-Token ${DYNATRACE_ACCESS_TOKEN}'
endpoints:
'/dynatrace':
target: 'https://example.dynatrace.com/api/v2'
headers:
Authorization: 'Api-Token ${DYNATRACE_ACCESS_TOKEN}'
```
It also requires a `baseUrl` for rendering links to problems in the table like so:
+8 -4
View File
@@ -11,19 +11,23 @@ import { ServiceRef } from '@backstage/backend-plugin-api';
export class DefaultSignalService implements SignalService {
// (undocumented)
static create(options: SignalServiceOptions): DefaultSignalService;
publish(signal: SignalPayload): Promise<void>;
publish<TMessage extends JsonObject = JsonObject>(
signal: SignalPayload<TMessage>,
): Promise<void>;
}
// @public (undocumented)
export type SignalPayload = {
export type SignalPayload<TMessage extends JsonObject = JsonObject> = {
recipients: string[] | string | null;
channel: string;
message: JsonObject;
message: TMessage;
};
// @public (undocumented)
export interface SignalService {
publish(signal: SignalPayload): Promise<void>;
publish<TMessage extends JsonObject = JsonObject>(
signal: SignalPayload<TMessage>,
): Promise<void>;
}
// @public (undocumented)
@@ -16,6 +16,7 @@
import { EventBroker } from '@backstage/plugin-events-node';
import { SignalPayload, SignalServiceOptions } from './types';
import { SignalService } from './SignalService';
import { JsonObject } from '@backstage/types';
/** @public */
export class DefaultSignalService implements SignalService {
@@ -31,12 +32,12 @@ export class DefaultSignalService implements SignalService {
}
/**
* Publishes a message to user refs to specific topic
* @param recipients - string or array of user ref strings to publish message to
* @param topic - message topic
* @param message - message to publish
* Publishes a signal to user refs to specific topic
* @param signal - Signal to publish
*/
async publish(signal: SignalPayload) {
async publish<TMessage extends JsonObject = JsonObject>(
signal: SignalPayload<TMessage>,
) {
await this.eventBroker?.publish({
topic: 'signals',
eventPayload: signal,
+6 -2
View File
@@ -14,11 +14,15 @@
* limitations under the License.
*/
import { SignalPayload } from './types';
import { JsonObject } from '@backstage/types';
/** @public */
export interface SignalService {
/**
* Publishes a message to user refs to specific topic
* Publishes a signal to user refs to specific topic
* @param signal - Signal to publish
*/
publish(signal: SignalPayload): Promise<void>;
publish<TMessage extends JsonObject = JsonObject>(
signal: SignalPayload<TMessage>,
): Promise<void>;
}
+2 -2
View File
@@ -24,8 +24,8 @@ export type SignalServiceOptions = {
};
/** @public */
export type SignalPayload = {
export type SignalPayload<TMessage extends JsonObject = JsonObject> = {
recipients: string[] | string | null;
channel: string;
message: JsonObject;
message: TMessage;
};
+6 -4
View File
@@ -9,9 +9,9 @@ import { JsonObject } from '@backstage/types';
// @public (undocumented)
export interface SignalApi {
// (undocumented)
subscribe(
subscribe<TMessage extends JsonObject = JsonObject>(
channel: string,
onMessage: (message: JsonObject) => void,
onMessage: (message: TMessage) => void,
): SignalSubscriber;
}
@@ -25,8 +25,10 @@ export interface SignalSubscriber {
}
// @public (undocumented)
export const useSignal: (channel: string) => {
lastSignal: JsonObject | null;
export const useSignal: <TMessage extends JsonObject = JsonObject>(
channel: string,
) => {
lastSignal: TMessage | null;
isSignalsAvailable: boolean;
};
+2 -2
View File
@@ -28,8 +28,8 @@ export interface SignalSubscriber {
/** @public */
export interface SignalApi {
subscribe(
subscribe<TMessage extends JsonObject = JsonObject>(
channel: string,
onMessage: (message: JsonObject) => void,
onMessage: (message: TMessage) => void,
): SignalSubscriber;
}
+10 -5
View File
@@ -19,18 +19,23 @@ import { JsonObject } from '@backstage/types';
import { useEffect, useMemo, useState } from 'react';
/** @public */
export const useSignal = (channel: string) => {
export const useSignal = <TMessage extends JsonObject = JsonObject>(
channel: string,
): { lastSignal: TMessage | null; isSignalsAvailable: boolean } => {
const apiHolder = useApiHolder();
// Use apiHolder instead useApi in case signalApi is not available in the
// backstage instance this is used
const signals = apiHolder.get(signalApiRef);
const [lastSignal, setLastSignal] = useState<JsonObject | null>(null);
const [lastSignal, setLastSignal] = useState<TMessage | null>(null);
useEffect(() => {
let unsub: null | (() => void) = null;
if (signals) {
const { unsubscribe } = signals.subscribe(channel, (msg: JsonObject) => {
setLastSignal(msg);
});
const { unsubscribe } = signals.subscribe<TMessage>(
channel,
(msg: TMessage) => {
setLastSignal(msg);
},
);
unsub = unsubscribe;
}
return () => {
+4 -5
View File
@@ -8,6 +8,7 @@ import { DiscoveryApi } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { SignalApi } from '@backstage/plugin-signals-react';
import { SignalSubscriber } from '@backstage/plugin-signals-react';
// @public (undocumented)
export class SignalClient implements SignalApi {
@@ -23,12 +24,10 @@ export class SignalClient implements SignalApi {
// (undocumented)
static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number;
// (undocumented)
subscribe(
subscribe<TMessage extends JsonObject = JsonObject>(
channel: string,
onMessage: (message: JsonObject) => void,
): {
unsubscribe: () => void;
};
onMessage: (message: TMessage) => void,
): SignalSubscriber;
}
// @public (undocumented)
+14 -12
View File
@@ -13,14 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SignalApi } from '@backstage/plugin-signals-react';
import { SignalApi, SignalSubscriber } from '@backstage/plugin-signals-react';
import { JsonObject } from '@backstage/types';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { v4 as uuid } from 'uuid';
type Subscription = {
channel: string;
callback: (message: JsonObject) => void;
callback: (message: any) => void;
};
const WS_CLOSE_NORMAL = 1000;
@@ -62,16 +62,16 @@ export class SignalClient implements SignalApi {
private reconnectTimeout: number,
) {}
subscribe(
subscribe<TMessage extends JsonObject = JsonObject>(
channel: string,
onMessage: (message: JsonObject) => void,
): { unsubscribe: () => void } {
onMessage: (message: TMessage) => void,
): SignalSubscriber {
const subscriptionId = uuid();
const exists = [...this.subscriptions.values()].find(
sub => sub.channel === channel,
);
this.subscriptions.set(subscriptionId, {
channel: channel,
channel,
callback: onMessage,
});
@@ -178,12 +178,14 @@ export class SignalClient implements SignalApi {
private handleMessage(data: MessageEvent) {
try {
const json = JSON.parse(data.data) as JsonObject;
if (json.channel) {
for (const sub of this.subscriptions.values()) {
if (sub.channel === json.channel) {
sub.callback(json.message as JsonObject);
}
const json = JSON.parse(data.data);
if (!json.channel) {
return;
}
for (const sub of this.subscriptions.values()) {
if (sub.channel === json.channel) {
sub.callback(json.message);
}
}
} catch (e) {
+1
View File
@@ -90,6 +90,7 @@ const roleRules = [
'@backstage/backend-common',
'@backstage/backend-defaults',
'@backstage/backend-test-utils',
'@backstage/backend-dynamic-feature-service',
],
message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`,
},
+5
View File
@@ -3383,6 +3383,7 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-app-node": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-backend": "workspace:^"
"@backstage/plugin-events-backend": "workspace:^"
@@ -3393,9 +3394,11 @@ __metadata:
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/types": "workspace:^"
"@manypkg/get-packages": ^1.1.3
"@types/express": ^4.17.6
chokidar: ^3.5.3
express: ^4.17.1
fs-extra: 10.1.0
lodash: ^4.17.21
wait-for-expect: ^3.0.2
winston: ^3.2.1
@@ -4647,8 +4650,10 @@ __metadata:
dependencies:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@types/express": ^4.17.6
express: ^4.17.1
fs-extra: 10.1.0
languageName: unknown
linkType: soft