Merge branch 'backstage:master' into master
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Added a configuration to permit backend plugin module failures on startup:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
...
|
||||
startup:
|
||||
plugins:
|
||||
plugin-x:
|
||||
modules:
|
||||
module-y:
|
||||
onPluginModuleBootFailure: continue
|
||||
```
|
||||
|
||||
This configuration permits `plugin-x` with `module-y` to fail on startup. Omitting the
|
||||
`onPluginModuleBootFailure` configuration matches the previous behavior, wherein any
|
||||
individual plugin module failure is forwarded to the plugin and aborts backend startup.
|
||||
|
||||
The default can also be changed, so that continuing on failure is the default
|
||||
unless otherwise specified:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
startup:
|
||||
default:
|
||||
onPluginModuleBootFailure: continue
|
||||
plugins:
|
||||
catalog:
|
||||
modules:
|
||||
github:
|
||||
onPluginModuleBootFailure: abort
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Internal refactor of opaque type handling.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added `info` object to the context of the alpha CLI.
|
||||
@@ -453,7 +453,7 @@ const config: Config = {
|
||||
},
|
||||
{
|
||||
label: 'Adopting',
|
||||
to: 'https://backstage.spotify.com',
|
||||
to: 'https://backstage.io/docs/getting-started/',
|
||||
},
|
||||
{
|
||||
label: 'Subscribe to our newsletter',
|
||||
@@ -468,18 +468,6 @@ const config: Config = {
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: 'Open Source @ Spotify',
|
||||
to: 'https://spotify.github.io/',
|
||||
},
|
||||
{
|
||||
label: 'Spotify Engineering Blog',
|
||||
to: 'https://engineering.atspotify.com/',
|
||||
},
|
||||
{
|
||||
label: 'Spotify for Developers',
|
||||
to: 'https://developer.spotify.com/',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
to: 'https://github.com/backstage/',
|
||||
|
||||
Vendored
+21
@@ -34,6 +34,14 @@ export interface Config {
|
||||
* `onPluginBootFailure: abort` to be required.
|
||||
*/
|
||||
onPluginBootFailure?: 'continue' | 'abort';
|
||||
/**
|
||||
* The default value for `onPluginModuleBootFailure` if not specified for a particular plugin module.
|
||||
* This defaults to 'abort', which means `onPluginModuleBootFailure: continue` must be specified
|
||||
* for backend startup to continue on plugin module boot failure. This can also be set to
|
||||
* 'continue', which flips the logic for individual plugin modules so that they must be set to
|
||||
* `onPluginModuleBootFailure: abort` to be required.
|
||||
*/
|
||||
onPluginModuleBootFailure?: 'continue' | 'abort';
|
||||
};
|
||||
plugins?: {
|
||||
[pluginId: string]: {
|
||||
@@ -46,6 +54,19 @@ export interface Config {
|
||||
* setting).
|
||||
*/
|
||||
onPluginBootFailure?: 'continue' | 'abort';
|
||||
modules?: {
|
||||
[moduleId: string]: {
|
||||
/**
|
||||
* Used to control backend startup behavior when this plugin module fails to boot up. Setting
|
||||
* this to `continue` allows the backend to continue starting up, even if this plugin
|
||||
* module fails. This can enable leaving a crashing plugin installed, but still permit backend
|
||||
* startup, which may help troubleshoot data-dependent issues. Plugin module failures for plugin modules
|
||||
* set to `abort` are fatal (this is the default unless overridden by the `default`
|
||||
* setting).
|
||||
*/
|
||||
onPluginModuleBootFailure?: 'continue' | 'abort';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -541,7 +541,7 @@ describe('BackendInitializer', () => {
|
||||
});
|
||||
|
||||
it('should forward errors when plugins fail to start', async () => {
|
||||
const init = new BackendInitializer([]);
|
||||
const init = new BackendInitializer(baseFactories);
|
||||
init.add(
|
||||
createBackendPlugin({
|
||||
pluginId: 'test',
|
||||
@@ -562,8 +562,7 @@ describe('BackendInitializer', () => {
|
||||
|
||||
it('should permit startup errors for plugins with onPluginBootFailure: continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
mockServices.rootLifecycle.factory(),
|
||||
mockServices.rootLogger.factory(),
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
@@ -590,8 +589,7 @@ describe('BackendInitializer', () => {
|
||||
|
||||
it('should permit startup errors if the default onPluginBootFailure is continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
mockServices.rootLifecycle.factory(),
|
||||
mockServices.rootLogger.factory(),
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
@@ -618,8 +616,7 @@ describe('BackendInitializer', () => {
|
||||
|
||||
it('should forward errors for plugins explicitly marked to abort when the default is continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
mockServices.rootLifecycle.factory(),
|
||||
mockServices.rootLogger.factory(),
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
@@ -649,6 +646,130 @@ describe('BackendInitializer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward errors when plugin modules fail to start', async () => {
|
||||
const init = new BackendInitializer(baseFactories);
|
||||
init.add(testPlugin);
|
||||
init.add(
|
||||
createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).rejects.toThrow(
|
||||
"Module 'mod' for plugin 'test' startup failed; caused by Error: NOPE",
|
||||
);
|
||||
});
|
||||
|
||||
it('should permit startup errors for plugin modules with onPluginModuleBootFailure: continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
startup: {
|
||||
plugins: {
|
||||
test: {
|
||||
modules: { mod: { onPluginModuleBootFailure: 'continue' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
init.add(testPlugin);
|
||||
init.add(
|
||||
createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should permit startup errors if the default onPluginModuleBootFailure is continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
startup: { default: { onPluginModuleBootFailure: 'continue' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
init.add(testPlugin);
|
||||
init.add(
|
||||
createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should forward errors for plugin modules explicitly marked to abort when the default is continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
startup: {
|
||||
default: { onPluginModuleBootFailure: 'continue' },
|
||||
plugins: {
|
||||
test: {
|
||||
modules: { mod: { onPluginModuleBootFailure: 'abort' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
init.add(testPlugin);
|
||||
init.add(
|
||||
createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).rejects.toThrow(
|
||||
"Module 'mod' for plugin 'test' startup failed; caused by Error: NOPE",
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward errors when multiple plugins fail to start', async () => {
|
||||
const init = new BackendInitializer([]);
|
||||
init.add(
|
||||
|
||||
@@ -365,17 +365,38 @@ export class BackendInitializer {
|
||||
}
|
||||
await tree.parallelTopologicalTraversal(
|
||||
async ({ moduleId, moduleInit }) => {
|
||||
const moduleDeps = await this.#getInitDeps(
|
||||
moduleInit.init.deps,
|
||||
pluginId,
|
||||
moduleId,
|
||||
);
|
||||
await moduleInit.init.func(moduleDeps).catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Module '${moduleId}' for plugin '${pluginId}' startup failed`,
|
||||
error,
|
||||
const isModuleBootFailurePermitted =
|
||||
this.#getPluginModuleBootFailurePredicate(
|
||||
pluginId,
|
||||
moduleId,
|
||||
rootConfig,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const moduleDeps = await this.#getInitDeps(
|
||||
moduleInit.init.deps,
|
||||
pluginId,
|
||||
moduleId,
|
||||
);
|
||||
await moduleInit.init.func(moduleDeps).catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Module '${moduleId}' for plugin '${pluginId}' startup failed`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
assertError(error);
|
||||
if (isModuleBootFailurePermitted) {
|
||||
initLogger.onPermittedPluginModuleFailure(
|
||||
pluginId,
|
||||
moduleId,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
initLogger.onPluginModuleFailed(pluginId, moduleId, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -649,6 +670,24 @@ export class BackendInitializer {
|
||||
|
||||
return pluginStartupBootFailureValue === 'continue';
|
||||
}
|
||||
|
||||
#getPluginModuleBootFailurePredicate(
|
||||
pluginId: string,
|
||||
moduleId: string,
|
||||
config?: Config,
|
||||
): boolean {
|
||||
const defaultStartupBootFailureValue =
|
||||
config?.getOptionalString(
|
||||
'backend.startup.default.onPluginModuleBootFailure',
|
||||
) ?? 'abort';
|
||||
|
||||
const pluginModuleStartupBootFailureValue =
|
||||
config?.getOptionalString(
|
||||
`backend.startup.plugins.${pluginId}.modules.${moduleId}.onPluginModuleBootFailure`,
|
||||
) ?? defaultStartupBootFailureValue;
|
||||
|
||||
return pluginModuleStartupBootFailureValue === 'continue';
|
||||
}
|
||||
}
|
||||
|
||||
function toInternalBackendFeature(
|
||||
|
||||
@@ -29,6 +29,12 @@ export function createInitializationLogger(
|
||||
onPluginStarted(pluginId: string): void;
|
||||
onPluginFailed(pluginId: string, error: Error): void;
|
||||
onPermittedPluginFailure(pluginId: string, error: Error): void;
|
||||
onPluginModuleFailed(pluginId: string, moduleId: string, error: Error): void;
|
||||
onPermittedPluginModuleFailure(
|
||||
pluginId: string,
|
||||
moduleId: string,
|
||||
error: Error,
|
||||
): void;
|
||||
onAllStarted(): void;
|
||||
} {
|
||||
const logger = rootLogger?.child({ type: 'initialization' });
|
||||
@@ -87,6 +93,26 @@ export function createInitializationLogger(
|
||||
error,
|
||||
);
|
||||
},
|
||||
onPluginModuleFailed(pluginId: string, moduleId: string, error: Error) {
|
||||
const status =
|
||||
starting.size > 0
|
||||
? `, waiting for ${starting.size} other plugins to finish before shutting down the process`
|
||||
: '';
|
||||
logger?.error(
|
||||
`Module ${moduleId} in Plugin '${pluginId}' threw an error during startup${status}.`,
|
||||
error,
|
||||
);
|
||||
},
|
||||
onPermittedPluginModuleFailure(
|
||||
pluginId: string,
|
||||
moduleId: string,
|
||||
error: Error,
|
||||
) {
|
||||
logger?.error(
|
||||
`Module ${moduleId} in Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin module so startup will continue.`,
|
||||
error,
|
||||
);
|
||||
},
|
||||
onAllStarted() {
|
||||
logger?.info(`Plugin initialization complete${getInitStatus()}`);
|
||||
|
||||
|
||||
@@ -56,12 +56,11 @@ Commands:
|
||||
### `backstage-cli-alpha config docs`
|
||||
|
||||
```
|
||||
Usage: <none>
|
||||
Usage: backstage-cli config docs [options]
|
||||
|
||||
Options:
|
||||
--help
|
||||
--package
|
||||
--version
|
||||
--package <name>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-alpha config schema`
|
||||
|
||||
@@ -40,22 +40,21 @@ export default createCliPlugin({
|
||||
reg.addCommand({
|
||||
path: ['config', 'docs'],
|
||||
description: 'Browse the configuration reference documentation',
|
||||
execute: async ({ args }) => {
|
||||
const argv = await yargs
|
||||
.options({
|
||||
package: { type: 'string' },
|
||||
})
|
||||
.help()
|
||||
.parse(args);
|
||||
const m =
|
||||
(await require('./commands/docs')) as typeof import('./commands/docs');
|
||||
await m.default(argv);
|
||||
execute: async ({ args, info }) => {
|
||||
await new Command(info.usage)
|
||||
.option(
|
||||
'--package <name>',
|
||||
'Only include the schema that applies to the given package',
|
||||
)
|
||||
.description(info.description)
|
||||
.action(lazy(() => import('./commands/docs'), 'default'))
|
||||
.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['config:print'],
|
||||
description: 'Print the app configuration for the current package',
|
||||
execute: async ({ args }) => {
|
||||
execute: async ({ args, info }) => {
|
||||
const argv = await yargs
|
||||
.options({
|
||||
package: { type: 'string' },
|
||||
@@ -63,8 +62,9 @@ export default createCliPlugin({
|
||||
frontend: { type: 'boolean' },
|
||||
'with-secrets': { type: 'boolean' },
|
||||
format: { type: 'string' },
|
||||
config: { type: 'string', array: true },
|
||||
config: { type: 'string', array: true, default: [] },
|
||||
})
|
||||
.usage('$0', info.description)
|
||||
.help()
|
||||
.parse(args);
|
||||
const m =
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { CommandGraph } from './CommandGraph';
|
||||
import { CliFeature, InternalCliFeature, InternalCliPlugin } from './types';
|
||||
import { CliFeature, OpaqueCliPlugin } from './types';
|
||||
import { CommandRegistry } from './CommandRegistry';
|
||||
import { Command } from 'commander';
|
||||
import { version } from '../lib/version';
|
||||
@@ -42,10 +42,11 @@ export class CliInitializer {
|
||||
}
|
||||
|
||||
async #register(feature: CliFeature) {
|
||||
if (isCliPlugin(feature)) {
|
||||
await feature.init(this.commandRegistry);
|
||||
if (OpaqueCliPlugin.isType(feature)) {
|
||||
const internal = OpaqueCliPlugin.toInternal(feature);
|
||||
await internal.init(this.commandRegistry);
|
||||
} else {
|
||||
throw new Error(`Unsupported feature type: ${feature.$$type}`);
|
||||
throw new Error(`Unsupported feature type: ${(feature as any).$$type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +63,11 @@ export class CliInitializer {
|
||||
async run() {
|
||||
await this.#doInit();
|
||||
|
||||
const programName = 'backstage-cli';
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.name('backstage-cli')
|
||||
.name(programName)
|
||||
.version(version)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true);
|
||||
@@ -117,6 +120,10 @@ export class CliInitializer {
|
||||
}
|
||||
await node.command.execute({
|
||||
args: [...positionalArgs, ...args.unknown],
|
||||
info: {
|
||||
usage: [programName, ...node.command.path].join(' '),
|
||||
description: node.command.description,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
@@ -146,26 +153,6 @@ export class CliInitializer {
|
||||
}
|
||||
}
|
||||
|
||||
function toInternalCliFeature(feature: CliFeature): InternalCliFeature {
|
||||
if (feature.$$type !== '@backstage/CliFeature') {
|
||||
throw new Error(`Invalid CliFeature, bad type '${feature.$$type}'`);
|
||||
}
|
||||
const internal = feature as InternalCliFeature;
|
||||
if (internal.version !== 'v1') {
|
||||
throw new Error(`Invalid CliFeature, bad version '${internal.version}'`);
|
||||
}
|
||||
return internal;
|
||||
}
|
||||
|
||||
function isCliPlugin(feature: CliFeature): feature is InternalCliPlugin {
|
||||
const internal = toInternalCliFeature(feature);
|
||||
if (internal.featureType === 'plugin') {
|
||||
return true;
|
||||
}
|
||||
// Backwards compatibility for v1 registrations that use duck typing
|
||||
return 'plugin' in internal;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function unwrapFeature(
|
||||
feature: CliFeature | { default: CliFeature },
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Single re-export to avoid doing this import in multiple places, but still
|
||||
// avoid duplicate declarations because this one is a bit tricky.
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
export { describeParentCallSite } from '../../../frontend-plugin-api/src/routing/describeParentCallSite';
|
||||
@@ -14,19 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CommandRegistry } from './CommandRegistry';
|
||||
import { InternalCliPlugin } from './types';
|
||||
import { describeParentCallSite } from './describeParentCallSite';
|
||||
import { BackstageCommand, CliPlugin, OpaqueCliPlugin } from './types';
|
||||
|
||||
export function createCliPlugin(options: {
|
||||
pluginId: string;
|
||||
init: (registry: CommandRegistry) => Promise<void>;
|
||||
}): InternalCliPlugin {
|
||||
return {
|
||||
id: options.pluginId,
|
||||
init: (registry: {
|
||||
addCommand: (command: BackstageCommand) => void;
|
||||
}) => Promise<void>;
|
||||
}): CliPlugin {
|
||||
return OpaqueCliPlugin.createInstance('v1', {
|
||||
pluginId: options.pluginId,
|
||||
init: options.init,
|
||||
$$type: '@backstage/CliFeature',
|
||||
version: 'v1',
|
||||
featureType: 'plugin',
|
||||
description: 'A Backstage CLI plugin',
|
||||
};
|
||||
description: `created at '${describeParentCallSite()}'`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,35 +13,44 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { CommandRegistry } from './CommandRegistry';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export interface BackstageCommand {
|
||||
path: string[];
|
||||
description: string;
|
||||
deprecated?: boolean;
|
||||
execute: (options: { args: string[] }) => Promise<void>;
|
||||
execute: (context: {
|
||||
args: string[];
|
||||
info: {
|
||||
/**
|
||||
* The usage string of the current command, for example: "backstage-cli repo test"
|
||||
*/
|
||||
usage: string;
|
||||
/**
|
||||
* The description provided for the command
|
||||
*/
|
||||
description: string;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CliFeature {
|
||||
$$type: '@backstage/CliFeature';
|
||||
}
|
||||
export type CliFeature = CliPlugin;
|
||||
|
||||
export interface CliPlugin {
|
||||
id: string;
|
||||
init: (registry: CommandRegistry) => Promise<void>;
|
||||
$$type: '@backstage/CliFeature';
|
||||
readonly pluginId: string;
|
||||
readonly $$type: '@backstage/CliPlugin';
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface InternalCliPlugin extends CliFeature {
|
||||
version: 'v1';
|
||||
featureType: 'plugin';
|
||||
description: string;
|
||||
id: string;
|
||||
init: (registry: CommandRegistry) => Promise<void>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type InternalCliFeature = InternalCliPlugin;
|
||||
export const OpaqueCliPlugin = OpaqueType.create<{
|
||||
public: CliPlugin;
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
readonly description: string;
|
||||
init: (registry: {
|
||||
addCommand: (command: BackstageCommand) => void;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/CliPlugin',
|
||||
versions: ['v1'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user