Merge branch 'backstage:master' into feature/catalog-export
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# @backstage/app-defaults
|
||||
|
||||
## 1.7.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.18.10
|
||||
- @backstage/core-plugin-api@1.12.6
|
||||
- @backstage/core-app-api@1.20.1
|
||||
- @backstage/plugin-permission-react@0.5.1
|
||||
|
||||
## 1.7.8-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/app-defaults",
|
||||
"version": "1.7.8-next.0",
|
||||
"version": "1.7.8",
|
||||
"description": "Provides the default wiring of a Backstage App",
|
||||
"backstage": {
|
||||
"role": "web-library"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# app-example-plugin
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.18.10
|
||||
- @backstage/frontend-plugin-api@0.17.0
|
||||
|
||||
## 0.0.35-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-example-plugin",
|
||||
"version": "0.0.35-next.0",
|
||||
"version": "0.0.35",
|
||||
"description": "Backstage internal example plugin",
|
||||
"backstage": {
|
||||
"role": "frontend-plugin",
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
# example-app-legacy
|
||||
|
||||
## 0.2.121
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.9.0
|
||||
- @backstage/core-components@0.18.10
|
||||
- @backstage/ui@0.15.0
|
||||
- @backstage/plugin-notifications@0.5.17
|
||||
- @backstage/plugin-scaffolder-react@2.0.0
|
||||
- @backstage/plugin-scaffolder@1.37.0
|
||||
- @backstage/frontend-app-api@0.16.3
|
||||
- @backstage/plugin-devtools@0.1.39
|
||||
- @backstage/plugin-user-settings@0.9.3
|
||||
- @backstage/plugin-catalog@2.0.5
|
||||
- @backstage/core-plugin-api@1.12.6
|
||||
- @backstage/plugin-home@0.9.6
|
||||
- @backstage/plugin-techdocs@1.17.6
|
||||
- @backstage/plugin-api-docs@0.14.1
|
||||
- @backstage/plugin-catalog-graph@0.6.4
|
||||
- @backstage/plugin-catalog-import@0.13.13
|
||||
- @backstage/plugin-catalog-react@3.0.0
|
||||
- @backstage/plugin-org@0.7.4
|
||||
- @backstage/plugin-signals@0.0.31
|
||||
- @backstage/cli@0.36.2
|
||||
- @backstage/plugin-search@1.7.4
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.31
|
||||
- @backstage/plugin-techdocs-react@1.3.11
|
||||
- @backstage/plugin-search-react@1.11.4
|
||||
- @backstage/plugin-kubernetes@0.12.19
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.37
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.36
|
||||
- @backstage/app-defaults@1.7.8
|
||||
- @backstage/config@1.3.8
|
||||
- @backstage/core-app-api@1.20.1
|
||||
- @backstage/integration-react@1.2.18
|
||||
- @backstage/plugin-auth-react@0.1.27
|
||||
- @backstage/plugin-catalog-common@1.1.10
|
||||
- @backstage/plugin-home-react@0.1.38
|
||||
- @backstage/plugin-mui-to-bui@0.2.7
|
||||
- @backstage/plugin-permission-react@0.5.1
|
||||
- @backstage/plugin-search-common@1.2.24
|
||||
|
||||
## 0.2.121-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-scaffolder-react@1.21.0-next.1
|
||||
- @backstage/plugin-scaffolder@1.37.0-next.2
|
||||
- @backstage/plugin-catalog@2.0.5-next.1
|
||||
- @backstage/ui@0.15.0-next.3
|
||||
- @backstage/plugin-catalog-react@2.1.5-next.1
|
||||
|
||||
## 0.2.121-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app-legacy",
|
||||
"version": "0.2.121-next.0",
|
||||
"version": "0.2.121",
|
||||
"backstage": {
|
||||
"role": "frontend"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# example-app
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.9.0
|
||||
- @backstage/core-components@0.18.10
|
||||
- @backstage/ui@0.15.0
|
||||
- @backstage/plugin-notifications@0.5.17
|
||||
- @backstage/plugin-app@0.4.6
|
||||
- @backstage/plugin-scaffolder-react@2.0.0
|
||||
- @backstage/plugin-scaffolder@1.37.0
|
||||
- @backstage/plugin-app-visualizer@0.2.4
|
||||
- @backstage/frontend-app-api@0.16.3
|
||||
- @backstage/plugin-devtools@0.1.39
|
||||
- @backstage/plugin-user-settings@0.9.3
|
||||
- @backstage/plugin-catalog@2.0.5
|
||||
- @backstage/frontend-defaults@0.5.2
|
||||
- @backstage/frontend-plugin-api@0.17.0
|
||||
- @backstage/core-plugin-api@1.12.6
|
||||
- @backstage/plugin-home@0.9.6
|
||||
- @backstage/plugin-techdocs@1.17.6
|
||||
- @backstage/plugin-api-docs@0.14.1
|
||||
- @backstage/plugin-auth@0.1.8
|
||||
- @backstage/plugin-catalog-graph@0.6.4
|
||||
- @backstage/plugin-catalog-import@0.13.13
|
||||
- @backstage/plugin-catalog-react@3.0.0
|
||||
- @backstage/plugin-org@0.7.4
|
||||
- @backstage/plugin-signals@0.0.31
|
||||
- @backstage/cli@0.36.2
|
||||
- @backstage/core-compat-api@0.5.11
|
||||
- @backstage/plugin-search@1.7.4
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.31
|
||||
- @backstage/plugin-techdocs-react@1.3.11
|
||||
- @backstage/plugin-search-react@1.11.4
|
||||
- @backstage/plugin-app-react@0.2.3
|
||||
- @backstage/plugin-kubernetes@0.12.19
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.37
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.36
|
||||
- @backstage/app-defaults@1.7.8
|
||||
- @backstage/config@1.3.8
|
||||
- @backstage/core-app-api@1.20.1
|
||||
- @backstage/integration-react@1.2.18
|
||||
- @backstage/plugin-auth-react@0.1.27
|
||||
- @backstage/plugin-catalog-common@1.1.10
|
||||
- @backstage/plugin-home-react@0.1.38
|
||||
- @backstage/plugin-permission-react@0.5.1
|
||||
- @backstage/plugin-search-common@1.2.24
|
||||
|
||||
## 0.0.35-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-app@0.4.6-next.2
|
||||
- @backstage/plugin-scaffolder-react@1.21.0-next.1
|
||||
- @backstage/plugin-scaffolder@1.37.0-next.2
|
||||
- @backstage/plugin-catalog@2.0.5-next.1
|
||||
- @backstage/ui@0.15.0-next.3
|
||||
- @backstage/plugin-auth@0.1.8-next.2
|
||||
- @backstage/plugin-catalog-react@2.1.5-next.1
|
||||
|
||||
## 0.0.35-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.0.35-next.0",
|
||||
"version": "0.0.35",
|
||||
"backstage": {
|
||||
"role": "frontend"
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ import { convertLegacyPageExtension } from '@backstage/core-compat-api';
|
||||
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { pluginInfoResolver } from './pluginInfoResolver';
|
||||
import { appModuleNav } from './modules/appModuleNav';
|
||||
import { appModuleScaffolder } from './modules/appModuleScaffolder';
|
||||
import catalogPlugin from '@backstage/plugin-catalog/alpha';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
|
||||
@@ -140,6 +141,7 @@ const app = createApp({
|
||||
kubernetesPlugin,
|
||||
notFoundErrorPageModule,
|
||||
appModuleNav,
|
||||
appModuleScaffolder,
|
||||
customHomePageModule,
|
||||
...collectedLegacyPlugins,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.templateCard {
|
||||
height: 212px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.templateName {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
|
||||
.templateDescription {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
.templateBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--bui-space-3);
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2026 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 { useMemo } from 'react';
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { useAnalytics } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
EntityRefLink,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Tag,
|
||||
TagGroup,
|
||||
Text,
|
||||
} from '@backstage/ui';
|
||||
import type { TemplateCardComponentProps } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import styles from './BuiTemplateCard.module.css';
|
||||
|
||||
const MAX_TAGS = 4;
|
||||
|
||||
export function BuiTemplateCard(props: TemplateCardComponentProps) {
|
||||
const { template, onSelected } = props;
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
spec: { type },
|
||||
metadata: { tags, description, name, title },
|
||||
} = template;
|
||||
|
||||
const visibleTags = useMemo(
|
||||
() =>
|
||||
Array.from(new Set([type, ...(tags ?? [])].filter(Boolean))).slice(
|
||||
0,
|
||||
MAX_TAGS,
|
||||
),
|
||||
[type, tags],
|
||||
);
|
||||
|
||||
const owner = getEntityRelations(template, RELATION_OWNED_BY)[0];
|
||||
|
||||
const handleRun = () => {
|
||||
analytics.captureEvent('click', 'Template has been opened');
|
||||
onSelected?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={styles.templateCard}>
|
||||
<CardHeader>
|
||||
<Text
|
||||
as="h3"
|
||||
variant="body-medium"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
className={styles.templateName}
|
||||
>
|
||||
{title ?? name}
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<Box px="3" className={styles.templateBody}>
|
||||
{description && (
|
||||
<Text
|
||||
as="p"
|
||||
variant="body-small"
|
||||
color="secondary"
|
||||
className={styles.templateDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{visibleTags.length > 0 && (
|
||||
<TagGroup>
|
||||
{visibleTags.map(t => (
|
||||
<Tag key={t}>{t!}</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
)}
|
||||
</Box>
|
||||
<CardFooter>
|
||||
<Flex justify="between" align="end">
|
||||
<Button size="small" variant="secondary" onPress={handleRun}>
|
||||
Run
|
||||
</Button>
|
||||
{owner && (
|
||||
<Flex gap="0" direction="column" align="end">
|
||||
<Text variant="body-x-small" color="secondary">
|
||||
Created by
|
||||
</Text>
|
||||
<Text variant="body-x-small" color="primary">
|
||||
<EntityRefLink entityRef={owner} hideIcon />
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 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 { createFrontendModule } from '@backstage/frontend-plugin-api';
|
||||
import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
|
||||
import { TemplateCard } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export const appModuleScaffolder = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
SwappableComponentBlueprint.make({
|
||||
name: 'scaffolder-template-card',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
component: TemplateCard,
|
||||
loader: () =>
|
||||
import('./BuiTemplateCard').then(m => m.BuiTemplateCard),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
# @backstage/backend-app-api
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3595c97: Added `ExtensionPointFactoryMiddleware` type and `createExtensionPointFactoryMiddleware` helper to reimplement extension point outputs at backend creation time.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
- @backstage/config@1.3.8
|
||||
|
||||
## 1.7.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-app-api",
|
||||
"version": "1.7.0-next.0",
|
||||
"version": "1.7.0",
|
||||
"description": "Core API used by Backstage backend apps",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# @backstage/backend-defaults
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 90b572e: Adds an alpha `TracingService` to provide a unified interface for emitting trace spans across Backstage plugins.
|
||||
- 97d3bd4: Fixed a race condition in `CachedUserInfoService` where a failed request could incorrectly evict a newer cache entry for the same token. The error handler now verifies the map entry is still the same promise before deleting it.
|
||||
- 3595c97: Exported `defaultServiceFactories` to allow use with `createSpecializedBackend` for advanced configuration like `extensionPointFactoryMiddleware`.
|
||||
- 89d3248: Fixed scheduler `sleep` firing immediately for durations longer than ~24.8 days, caused by Node.js `setTimeout` overflowing its 32-bit millisecond limit.
|
||||
- d00a44b: Fixed Valkey cluster mode to use `iovalkey`'s `Cluster` class instead of
|
||||
`createCluster` from `@keyv/redis`. The previous implementation passed a
|
||||
`@redis/client` `RedisCluster` instance to `@keyv/valkey`, which expects an
|
||||
`iovalkey` `Cluster` instance. This caused the cluster client to not be
|
||||
recognized correctly, as the two libraries have incompatible object models.
|
||||
- 2f0519c: Added a new `CachedUserInfoService` decorator that wraps `DefaultUserInfoService` with a 5-second TTL cache and in-flight request coalescing. The decorator is wired in via `userInfoServiceFactory` using a shared root-level cache. Repeated `getUserInfo()` calls for the same user token within the TTL window return the cached result without making an HTTP call to the auth backend. Note that custom `UserInfoService` implementations registered via their own factory will not benefit from this cache automatically.
|
||||
- 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
|
||||
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
|
||||
- 6209065: Added `context` and `propagation` to the alpha `TracingService`. Plugins can bridge OpenTelemetry context across async boundaries via `tracing.propagation.extract(tracing.context.active(), carrier)` followed by `tracing.context.with(ctx, fn)`, and read propagated baggage via `tracing.propagation.getActiveBaggage()` or `tracing.propagation.getBaggage(ctx)`.
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/integration-aws-node@0.2.0
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
- @backstage/backend-app-api@1.7.0
|
||||
- @backstage/cli-node@0.3.2
|
||||
- @backstage/integration@2.0.2
|
||||
- @backstage/plugin-permission-node@0.11.0
|
||||
- @backstage/plugin-auth-node@0.7.1
|
||||
- @backstage/plugin-permission-common@0.9.9
|
||||
- @backstage/config@1.3.8
|
||||
- @backstage/config-loader@1.10.11
|
||||
- @backstage/plugin-events-node@0.4.22
|
||||
|
||||
## 0.17.1-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 90b572e: Adds an alpha `TracingService` to provide a unified interface for emitting trace spans across Backstage plugins.
|
||||
- Updated dependencies
|
||||
- @backstage/integration-aws-node@0.2.0-next.1
|
||||
- @backstage/backend-plugin-api@1.9.1-next.1
|
||||
|
||||
## 0.17.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
|
||||
- Updated dependencies
|
||||
- @backstage/cli-node@0.3.2-next.1
|
||||
- @backstage/plugin-auth-node@0.7.1-next.1
|
||||
- @backstage/plugin-permission-common@0.9.9-next.1
|
||||
|
||||
## 0.17.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+46
@@ -1186,6 +1186,52 @@ export interface Config {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracing-related backend configuration. Honored by Backstage backend
|
||||
* plugins that emit OpenTelemetry trace spans.
|
||||
*/
|
||||
tracing?: {
|
||||
/**
|
||||
* Opt-in capture of attributes that may identify users or contain
|
||||
* sensitive data on backend trace spans.
|
||||
*/
|
||||
capture?: {
|
||||
/**
|
||||
* When true, backend plugins emitting trace spans for authenticated
|
||||
* requests SHOULD include the authenticated principal's identity as
|
||||
* `enduser.id` (the user entity ref for a user principal, or the
|
||||
* service subject for a service principal). Defaults to false.
|
||||
*/
|
||||
endUser?: boolean;
|
||||
};
|
||||
/**
|
||||
* Plugin-specific tracing configuration. Each plugin can override
|
||||
* tracer instrumentation scope metadata.
|
||||
*/
|
||||
plugin?: {
|
||||
[pluginId: string]: {
|
||||
/**
|
||||
* Tracer configuration for this plugin.
|
||||
*/
|
||||
tracer?: {
|
||||
/**
|
||||
* Custom tracer name. If not set, defaults to
|
||||
* backstage-plugin-{pluginId}.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Version for the tracer.
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Schema URL for the tracer.
|
||||
*/
|
||||
schemaUrl?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to configure the default RootLoggerService.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-defaults",
|
||||
"version": "0.17.1-next.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Backend defaults used by Backstage backend apps",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -171,6 +171,7 @@
|
||||
"git-url-parse": "^15.0.0",
|
||||
"helmet": "^6.0.0",
|
||||
"infinispan": "^0.12.0",
|
||||
"iovalkey": "^0.3.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"jose": "^5.0.0",
|
||||
"keyv": "^5.2.1",
|
||||
@@ -192,7 +193,6 @@
|
||||
"selfsigned": "^2.0.0",
|
||||
"tar": "^7.5.6",
|
||||
"triple-beam": "^1.4.1",
|
||||
"uuid": "^11.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"winston-transport": "^4.5.0",
|
||||
"yauzl": "^3.2.1",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ActionsService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { TracingService } from '@backstage/backend-plugin-api/alpha';
|
||||
|
||||
// @public (undocumented)
|
||||
export const actionsRegistryServiceFactory: ServiceFactory<
|
||||
@@ -37,5 +38,12 @@ export const rootSystemMetadataServiceFactory: ServiceFactory<
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @alpha
|
||||
export const tracingServiceFactory: ServiceFactory<
|
||||
TracingService,
|
||||
'plugin',
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
actionsRegistryServiceFactory,
|
||||
actionsServiceFactory,
|
||||
metricsServiceFactory,
|
||||
tracingServiceFactory,
|
||||
} from '@backstage/backend-defaults/alpha';
|
||||
import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory';
|
||||
|
||||
@@ -70,6 +71,7 @@ export const defaultServiceFactories: ServiceFactory[] = [
|
||||
actionsRegistryServiceFactory,
|
||||
actionsServiceFactory,
|
||||
metricsServiceFactory,
|
||||
tracingServiceFactory,
|
||||
|
||||
// Unexported alpha services kept around for compatibility reasons
|
||||
instanceMetadataServiceFactory,
|
||||
|
||||
+3
-2
@@ -556,13 +556,14 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
input: {
|
||||
name: 'test',
|
||||
},
|
||||
credentials: {
|
||||
credentials: expect.objectContaining({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: 'user:default/mock',
|
||||
},
|
||||
},
|
||||
}),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
SpanKind,
|
||||
SpanStatusCode,
|
||||
context,
|
||||
propagation,
|
||||
trace,
|
||||
} from '@opentelemetry/api';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
import { DefaultTracingService } from './DefaultTracingService';
|
||||
|
||||
type MockSpan = {
|
||||
setAttribute: jest.Mock;
|
||||
setStatus: jest.Mock;
|
||||
recordException: jest.Mock;
|
||||
end: jest.Mock;
|
||||
};
|
||||
|
||||
type MockTracer = {
|
||||
startActiveSpan: jest.Mock;
|
||||
};
|
||||
|
||||
function createMockTracingPrimitives() {
|
||||
const span: MockSpan = {
|
||||
setAttribute: jest.fn(),
|
||||
setStatus: jest.fn(),
|
||||
recordException: jest.fn(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
const tracer: MockTracer = {
|
||||
startActiveSpan: jest.fn(async (_name, _options, fn) => fn(span)),
|
||||
};
|
||||
const getTracer = jest.fn(() => tracer);
|
||||
return { span, tracer, getTracer };
|
||||
}
|
||||
|
||||
describe('DefaultTracingService', () => {
|
||||
let mocks: ReturnType<typeof createMockTracingPrimitives>;
|
||||
let getTracerProviderSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = createMockTracingPrimitives();
|
||||
getTracerProviderSpy = jest
|
||||
.spyOn(trace, 'getTracerProvider')
|
||||
.mockReturnValue({ getTracer: mocks.getTracer } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createService(opts?: {
|
||||
captureEndUser?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
schemaUrl?: string;
|
||||
pluginId?: string;
|
||||
}) {
|
||||
return DefaultTracingService.create({
|
||||
name: opts?.name ?? 'backstage-plugin-test',
|
||||
version: opts?.version,
|
||||
schemaUrl: opts?.schemaUrl,
|
||||
pluginId: opts?.pluginId ?? 'test',
|
||||
captureEndUser: opts?.captureEndUser ?? false,
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
});
|
||||
}
|
||||
|
||||
it('configures the tracer with name, version, and schemaUrl', () => {
|
||||
createService({
|
||||
name: 'tracer-x',
|
||||
version: '1.2.3',
|
||||
schemaUrl: 'https://example.com/schema',
|
||||
});
|
||||
expect(getTracerProviderSpy).toHaveBeenCalled();
|
||||
expect(mocks.getTracer).toHaveBeenCalledWith('tracer-x', '1.2.3', {
|
||||
schemaUrl: 'https://example.com/schema',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes name, kind, and caller attributes through to the tracer', async () => {
|
||||
const service = createService();
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ kind: 'server', attributes: { foo: 'bar' } },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(mocks.tracer.startActiveSpan).toHaveBeenCalledWith(
|
||||
'op',
|
||||
expect.objectContaining({
|
||||
kind: SpanKind.SERVER,
|
||||
attributes: expect.objectContaining({ foo: 'bar' }),
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-attaches backstage.plugin.id matching the calling plugin', async () => {
|
||||
const service = createService({ pluginId: 'my-plugin' });
|
||||
await service.startActiveSpan('op', async () => undefined);
|
||||
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['backstage.plugin.id']).toBe('my-plugin');
|
||||
});
|
||||
|
||||
it('lets caller-supplied attributes override backstage.plugin.id at start time', async () => {
|
||||
const service = createService({ pluginId: 'my-plugin' });
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ attributes: { 'backstage.plugin.id': 'other-plugin' } },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['backstage.plugin.id']).toBe('other-plugin');
|
||||
});
|
||||
|
||||
it('omits kind when none is supplied (OTel default applies)', async () => {
|
||||
const service = createService();
|
||||
await service.startActiveSpan('op', async () => undefined);
|
||||
|
||||
const [, options] = mocks.tracer.startActiveSpan.mock.calls[0];
|
||||
expect(options.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it('translates each Backstage span kind into the matching OTel SpanKind', async () => {
|
||||
const service = createService();
|
||||
const cases: Array<[string, SpanKind]> = [
|
||||
['internal', SpanKind.INTERNAL],
|
||||
['server', SpanKind.SERVER],
|
||||
['client', SpanKind.CLIENT],
|
||||
['producer', SpanKind.PRODUCER],
|
||||
['consumer', SpanKind.CONSUMER],
|
||||
];
|
||||
for (const [kind, expected] of cases) {
|
||||
mocks.tracer.startActiveSpan.mockClear();
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ kind: kind as any },
|
||||
async () => undefined,
|
||||
);
|
||||
expect(mocks.tracer.startActiveSpan.mock.calls[0][1].kind).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds backstage.principal.type but not enduser.id when capture is off', async () => {
|
||||
const service = createService({ captureEndUser: false });
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ credentials: mockCredentials.user('user:default/alice') },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['backstage.principal.type']).toBe('user');
|
||||
expect(attrs).not.toHaveProperty('enduser.id');
|
||||
});
|
||||
|
||||
it('adds enduser.id from a user principal when capture is on', async () => {
|
||||
const service = createService({ captureEndUser: true });
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ credentials: mockCredentials.user('user:default/alice') },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['enduser.id']).toBe('user:default/alice');
|
||||
});
|
||||
|
||||
it('adds enduser.id from a service principal subject when capture is on', async () => {
|
||||
const service = createService({ captureEndUser: true });
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ credentials: mockCredentials.service('plugin:test') },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['enduser.id']).toBe('plugin:test');
|
||||
expect(attrs['backstage.principal.type']).toBe('service');
|
||||
});
|
||||
|
||||
it('extracts credentials from a request via httpAuth when credentials are not supplied', async () => {
|
||||
const httpAuth = mockServices.httpAuth();
|
||||
const credSpy = jest.spyOn(httpAuth, 'credentials');
|
||||
const service = DefaultTracingService.create({
|
||||
name: 'backstage-plugin-test',
|
||||
pluginId: 'test',
|
||||
captureEndUser: true,
|
||||
httpAuth,
|
||||
});
|
||||
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{ request: { headers: {} } as any },
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(credSpy).toHaveBeenCalledTimes(1);
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['enduser.id']).toBe('user:default/mock');
|
||||
});
|
||||
|
||||
it('prefers credentials over request when both are supplied (no httpAuth call)', async () => {
|
||||
const httpAuth = mockServices.httpAuth();
|
||||
const credSpy = jest.spyOn(httpAuth, 'credentials');
|
||||
const service = DefaultTracingService.create({
|
||||
name: 'backstage-plugin-test',
|
||||
pluginId: 'test',
|
||||
captureEndUser: true,
|
||||
httpAuth,
|
||||
});
|
||||
|
||||
await service.startActiveSpan(
|
||||
'op',
|
||||
{
|
||||
credentials: mockCredentials.user('user:default/explicit'),
|
||||
request: { headers: {} } as any,
|
||||
},
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(credSpy).not.toHaveBeenCalled();
|
||||
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
|
||||
expect(attrs['enduser.id']).toBe('user:default/explicit');
|
||||
});
|
||||
|
||||
it('translates Backstage span status codes to OTel SpanStatusCode on the underlying span', async () => {
|
||||
const service = createService();
|
||||
await service.startActiveSpan('op', async span => {
|
||||
span.setStatus({ code: 'ok' });
|
||||
});
|
||||
expect(mocks.span.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.OK,
|
||||
message: undefined,
|
||||
});
|
||||
|
||||
mocks.span.setStatus.mockClear();
|
||||
await service.startActiveSpan('op', async span => {
|
||||
span.setStatus({ code: 'error', message: 'boom' });
|
||||
});
|
||||
expect(mocks.span.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: 'boom',
|
||||
});
|
||||
});
|
||||
|
||||
it('records exceptions, sets error.type, sets ERROR status, and ends the span on throw', async () => {
|
||||
const service = createService();
|
||||
const boom = new Error('Boom');
|
||||
boom.name = 'CustomError';
|
||||
|
||||
await expect(
|
||||
service.startActiveSpan('op', async () => {
|
||||
throw boom;
|
||||
}),
|
||||
).rejects.toThrow('Boom');
|
||||
|
||||
expect(mocks.span.recordException).toHaveBeenCalledWith(boom);
|
||||
expect(mocks.span.setAttribute).toHaveBeenCalledWith(
|
||||
'error.type',
|
||||
'CustomError',
|
||||
);
|
||||
expect(mocks.span.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: 'Boom',
|
||||
});
|
||||
expect(mocks.span.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends the span and returns the value on success', async () => {
|
||||
const service = createService();
|
||||
const value = await service.startActiveSpan('op', () => 42);
|
||||
expect(value).toBe(42);
|
||||
expect(mocks.span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('context', () => {
|
||||
describe('active', () => {
|
||||
it('returns the OTel active context as an opaque handle', () => {
|
||||
const fakeCtx = { __ctx: 'active' };
|
||||
jest.spyOn(context, 'active').mockReturnValue(fakeCtx as any);
|
||||
const service = createService();
|
||||
expect(service.context.active()).toBe(fakeCtx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with', () => {
|
||||
it('delegates to OTel context.with on the supplied handle and returns the fn result', async () => {
|
||||
const fakeCtx = { __ctx: 'extracted' } as any;
|
||||
const withSpy = jest
|
||||
.spyOn(context, 'with')
|
||||
.mockImplementation((_ctx, fn) => (fn as any)());
|
||||
|
||||
const service = createService();
|
||||
const result = await service.context.with(fakeCtx, () => 99);
|
||||
|
||||
expect(withSpy).toHaveBeenCalledWith(fakeCtx, expect.any(Function));
|
||||
expect(result).toBe(99);
|
||||
});
|
||||
|
||||
it('awaits an async fn and returns its resolved value', async () => {
|
||||
jest
|
||||
.spyOn(context, 'with')
|
||||
.mockImplementation((_ctx, fn) => (fn as any)());
|
||||
|
||||
const service = createService();
|
||||
const result = await service.context.with(
|
||||
{} as any,
|
||||
async () => 'async-val',
|
||||
);
|
||||
expect(result).toBe('async-val');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('propagation', () => {
|
||||
describe('extract', () => {
|
||||
it('forwards the supplied context and headers to OTel propagation.extract', () => {
|
||||
const baseCtx = { __ctx: 'base' } as any;
|
||||
const extractedCtx = { __ctx: 'extracted' } as any;
|
||||
const extractSpy = jest
|
||||
.spyOn(propagation, 'extract')
|
||||
.mockReturnValue(extractedCtx);
|
||||
|
||||
const service = createService();
|
||||
const headers = { traceparent: '00-abc-def-01' };
|
||||
const result = service.propagation.extract(baseCtx, headers);
|
||||
|
||||
expect(extractSpy).toHaveBeenCalledWith(baseCtx, headers);
|
||||
expect(result).toBe(extractedCtx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveBaggage', () => {
|
||||
it('returns a read-only baggage wrapping the active context baggage', () => {
|
||||
const mockBaggage = {
|
||||
getAllEntries: jest.fn(() => [
|
||||
['gen_ai.conversation.id', { value: 'conv-1' }],
|
||||
['gen_ai.agent.id', { value: 'agent-2' }],
|
||||
]),
|
||||
getEntry: jest.fn(),
|
||||
setEntry: jest.fn(),
|
||||
removeEntry: jest.fn(),
|
||||
removeEntries: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
jest
|
||||
.spyOn(propagation, 'getActiveBaggage')
|
||||
.mockReturnValue(mockBaggage as any);
|
||||
|
||||
const service = createService();
|
||||
const baggage = service.propagation.getActiveBaggage();
|
||||
|
||||
expect(baggage).toBeDefined();
|
||||
expect(baggage!.getAllEntries()).toEqual([
|
||||
['gen_ai.conversation.id', { value: 'conv-1' }],
|
||||
['gen_ai.agent.id', { value: 'agent-2' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaggage', () => {
|
||||
it('returns baggage from the supplied context', () => {
|
||||
const ctx = { __ctx: 'has-baggage' } as any;
|
||||
const mockBaggage = {
|
||||
getAllEntries: jest.fn(() => [['k', { value: 'ctx-val' }]]),
|
||||
getEntry: jest.fn(),
|
||||
setEntry: jest.fn(),
|
||||
removeEntry: jest.fn(),
|
||||
removeEntries: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
const getBaggageSpy = jest
|
||||
.spyOn(propagation, 'getBaggage')
|
||||
.mockReturnValue(mockBaggage as any);
|
||||
|
||||
const service = createService();
|
||||
const baggage = service.propagation.getBaggage(ctx);
|
||||
|
||||
expect(getBaggageSpy).toHaveBeenCalledWith(ctx);
|
||||
expect(baggage!.getAllEntries()).toEqual([['k', { value: 'ctx-val' }]]);
|
||||
});
|
||||
|
||||
it('returns undefined when the context has no baggage', () => {
|
||||
jest.spyOn(propagation, 'getBaggage').mockReturnValue(undefined);
|
||||
const service = createService();
|
||||
expect(service.propagation.getBaggage({} as any)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
Context,
|
||||
SpanKind,
|
||||
SpanStatusCode,
|
||||
Tracer,
|
||||
context as otelContext,
|
||||
propagation as otelPropagation,
|
||||
trace,
|
||||
} from '@opentelemetry/api';
|
||||
import {
|
||||
BackstageCredentials,
|
||||
HttpAuthService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
TracingService,
|
||||
TracingServiceAttributes,
|
||||
TracingServiceBaggage,
|
||||
TracingServiceContext,
|
||||
TracingServiceContextAPI,
|
||||
TracingServicePropagationAPI,
|
||||
TracingServiceSpan,
|
||||
TracingServiceSpanKind,
|
||||
TracingServiceSpanOptions,
|
||||
TracingServiceSpanStatus,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
|
||||
/**
|
||||
* Options for creating a {@link DefaultTracingService}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface DefaultTracingServiceOptions {
|
||||
name: string;
|
||||
version?: string;
|
||||
schemaUrl?: string;
|
||||
pluginId: string;
|
||||
captureEndUser: boolean;
|
||||
httpAuth: HttpAuthService;
|
||||
}
|
||||
|
||||
// `TracingServiceContext` is an opaque handle for an OTel `Context`. Internally
|
||||
// the value *is* the OTel context; we just narrow the type so consumers can't
|
||||
// poke at it directly.
|
||||
function toOtelContext(ctx: TracingServiceContext): Context {
|
||||
return ctx as unknown as Context;
|
||||
}
|
||||
function fromOtelContext(ctx: Context): TracingServiceContext {
|
||||
return ctx as unknown as TracingServiceContext;
|
||||
}
|
||||
|
||||
function wrapOtelBaggage(
|
||||
baggage: ReturnType<typeof otelPropagation.getActiveBaggage>,
|
||||
): TracingServiceBaggage | undefined {
|
||||
if (!baggage) return undefined;
|
||||
return {
|
||||
getAllEntries: () =>
|
||||
baggage
|
||||
.getAllEntries()
|
||||
.map(([key, entry]) => [key, { value: entry.value }]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of the {@link TracingService} interface.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export class DefaultTracingService implements TracingService {
|
||||
private readonly tracer: Tracer;
|
||||
private readonly pluginId: string;
|
||||
private readonly captureEndUser: boolean;
|
||||
private readonly httpAuth: HttpAuthService;
|
||||
|
||||
readonly context: TracingServiceContextAPI = {
|
||||
active: () => fromOtelContext(otelContext.active()),
|
||||
// `otelContext.with` is synchronous: it activates `ctx`, invokes `fn`,
|
||||
// then restores the previous active context before this call returns.
|
||||
// When `fn` is async, the AsyncLocalStorage context manager installed
|
||||
// by the OTel SDK is what keeps `ctx` active across the callback's
|
||||
// `await`s. If no context manager is registered (e.g. in a test that
|
||||
// does not wire up the OTel SDK) the `await` continuations will run
|
||||
// outside `ctx`.
|
||||
with: async <T>(
|
||||
ctx: TracingServiceContext,
|
||||
fn: () => T | Promise<T>,
|
||||
): Promise<T> => otelContext.with(toOtelContext(ctx), fn),
|
||||
};
|
||||
|
||||
readonly propagation: TracingServicePropagationAPI = {
|
||||
extract: (
|
||||
ctx: TracingServiceContext,
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): TracingServiceContext =>
|
||||
fromOtelContext(otelPropagation.extract(toOtelContext(ctx), carrier)),
|
||||
getBaggage: (ctx: TracingServiceContext) =>
|
||||
wrapOtelBaggage(otelPropagation.getBaggage(toOtelContext(ctx))),
|
||||
getActiveBaggage: () => wrapOtelBaggage(otelPropagation.getActiveBaggage()),
|
||||
};
|
||||
|
||||
private constructor(opts: DefaultTracingServiceOptions) {
|
||||
this.tracer = trace
|
||||
.getTracerProvider()
|
||||
.getTracer(opts.name, opts.version, { schemaUrl: opts.schemaUrl });
|
||||
this.pluginId = opts.pluginId;
|
||||
this.captureEndUser = opts.captureEndUser;
|
||||
this.httpAuth = opts.httpAuth;
|
||||
}
|
||||
|
||||
static create(opts: DefaultTracingServiceOptions): TracingService {
|
||||
return new DefaultTracingService(opts);
|
||||
}
|
||||
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
async startActiveSpan<T>(
|
||||
name: string,
|
||||
optionsOrFn:
|
||||
| TracingServiceSpanOptions
|
||||
| ((span: TracingServiceSpan) => T | Promise<T>),
|
||||
maybeFn?: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const [options, fn]: [
|
||||
TracingServiceSpanOptions,
|
||||
(span: TracingServiceSpan) => T | Promise<T>,
|
||||
] =
|
||||
typeof optionsOrFn === 'function'
|
||||
? [{}, optionsOrFn]
|
||||
: [optionsOrFn, maybeFn!];
|
||||
|
||||
let credentials = options.credentials;
|
||||
if (!credentials && options.request) {
|
||||
credentials = await this.httpAuth.credentials(options.request);
|
||||
}
|
||||
|
||||
const principalAttributes = this.getPrincipalAttributes(credentials);
|
||||
const attributes: TracingServiceAttributes = {
|
||||
'backstage.plugin.id': this.pluginId,
|
||||
...options.attributes,
|
||||
...principalAttributes,
|
||||
};
|
||||
|
||||
return this.tracer.startActiveSpan(
|
||||
name,
|
||||
{ kind: toSpanKind(options.kind), attributes },
|
||||
async span => {
|
||||
try {
|
||||
const wrapped: TracingServiceSpan = {
|
||||
setAttribute(key, value) {
|
||||
span.setAttribute(key, value);
|
||||
},
|
||||
setStatus(status) {
|
||||
span.setStatus({
|
||||
code: toSpanStatusCode(status.code),
|
||||
message: status.message,
|
||||
});
|
||||
},
|
||||
};
|
||||
const result = await fn(wrapped);
|
||||
span.end();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
span.recordException(error);
|
||||
span.setAttribute('error.type', error.name || 'Error');
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message || String(error),
|
||||
});
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private getPrincipalAttributes(
|
||||
credentials: BackstageCredentials | undefined,
|
||||
): TracingServiceAttributes {
|
||||
if (!credentials) return {};
|
||||
const principal = credentials.principal as
|
||||
| { type?: string; userEntityRef?: string; subject?: string }
|
||||
| undefined;
|
||||
if (!principal?.type) return {};
|
||||
const attrs: TracingServiceAttributes = {
|
||||
'backstage.principal.type': principal.type,
|
||||
};
|
||||
if (!this.captureEndUser) return attrs;
|
||||
if (principal.type === 'user' && principal.userEntityRef) {
|
||||
attrs['enduser.id'] = principal.userEntityRef;
|
||||
} else if (principal.type === 'service' && principal.subject) {
|
||||
attrs['enduser.id'] = principal.subject;
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
}
|
||||
|
||||
function toSpanKind(
|
||||
kind: TracingServiceSpanKind | undefined,
|
||||
): SpanKind | undefined {
|
||||
switch (kind) {
|
||||
case 'internal':
|
||||
return SpanKind.INTERNAL;
|
||||
case 'server':
|
||||
return SpanKind.SERVER;
|
||||
case 'client':
|
||||
return SpanKind.CLIENT;
|
||||
case 'producer':
|
||||
return SpanKind.PRODUCER;
|
||||
case 'consumer':
|
||||
return SpanKind.CONSUMER;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toSpanStatusCode(
|
||||
code: TracingServiceSpanStatus['code'],
|
||||
): SpanStatusCode {
|
||||
switch (code) {
|
||||
case 'ok':
|
||||
return SpanStatusCode.OK;
|
||||
case 'error':
|
||||
return SpanStatusCode.ERROR;
|
||||
default:
|
||||
return SpanStatusCode.UNSET;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2026 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 { tracingServiceFactory } from './tracingServiceFactory';
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
mockServices,
|
||||
ServiceFactoryTester,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { tracingServiceFactory } from './tracingServiceFactory';
|
||||
import { DefaultTracingService } from './DefaultTracingService';
|
||||
|
||||
describe('tracingServiceFactory', () => {
|
||||
let createSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
createSpy = jest.spyOn(DefaultTracingService, 'create');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const defaultDeps = [
|
||||
mockServices.rootConfig.factory(),
|
||||
mockServices.httpAuth.factory(),
|
||||
tracingServiceFactory,
|
||||
];
|
||||
|
||||
it('uses backstage-plugin-{pluginId} as the tracer name when no config is set', async () => {
|
||||
await ServiceFactoryTester.from(tracingServiceFactory, {
|
||||
dependencies: defaultDeps,
|
||||
}).getSubject('my-plugin');
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backstage-plugin-my-plugin',
|
||||
version: undefined,
|
||||
schemaUrl: undefined,
|
||||
pluginId: 'my-plugin',
|
||||
captureEndUser: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a custom tracer name from config', async () => {
|
||||
await ServiceFactoryTester.from(tracingServiceFactory, {
|
||||
dependencies: [
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
tracing: {
|
||||
plugin: {
|
||||
'my-plugin': {
|
||||
tracer: { name: 'custom-tracer-name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mockServices.httpAuth.factory(),
|
||||
tracingServiceFactory,
|
||||
],
|
||||
}).getSubject('my-plugin');
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'custom-tracer-name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts version and schemaUrl from config', async () => {
|
||||
await ServiceFactoryTester.from(tracingServiceFactory, {
|
||||
dependencies: [
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
tracing: {
|
||||
plugin: {
|
||||
'my-plugin': {
|
||||
tracer: {
|
||||
name: 'my-plugin-tracer',
|
||||
version: '1.2.3',
|
||||
schemaUrl: 'https://example.com/schema',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mockServices.httpAuth.factory(),
|
||||
tracingServiceFactory,
|
||||
],
|
||||
}).getSubject('my-plugin');
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'my-plugin-tracer',
|
||||
version: '1.2.3',
|
||||
schemaUrl: 'https://example.com/schema',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads backend.tracing.capture.endUser into the service', async () => {
|
||||
await ServiceFactoryTester.from(tracingServiceFactory, {
|
||||
dependencies: [
|
||||
mockServices.rootConfig.factory({
|
||||
data: { backend: { tracing: { capture: { endUser: true } } } },
|
||||
}),
|
||||
mockServices.httpAuth.factory(),
|
||||
tracingServiceFactory,
|
||||
],
|
||||
}).getSubject('my-plugin');
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ captureEndUser: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 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 { tracingServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { DefaultTracingService } from './DefaultTracingService';
|
||||
|
||||
/**
|
||||
* Service factory for emitting plugin-scoped trace spans.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const tracingServiceFactory = createServiceFactory({
|
||||
service: tracingServiceRef,
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
pluginMetadata: coreServices.pluginMetadata,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
},
|
||||
factory: ({ config, pluginMetadata, httpAuth }) => {
|
||||
const pluginId = pluginMetadata.getId();
|
||||
|
||||
const tracerConfig = config.getOptionalConfig(
|
||||
`backend.tracing.plugin.${pluginId}.tracer`,
|
||||
);
|
||||
const scopeName = `backstage-plugin-${pluginId}`;
|
||||
const name = tracerConfig?.getOptionalString('name') ?? scopeName;
|
||||
const version = tracerConfig?.getOptionalString('version');
|
||||
const schemaUrl = tracerConfig?.getOptionalString('schemaUrl');
|
||||
|
||||
const captureEndUser =
|
||||
config.getOptionalBoolean('backend.tracing.capture.endUser') ?? false;
|
||||
|
||||
return DefaultTracingService.create({
|
||||
name,
|
||||
version,
|
||||
schemaUrl,
|
||||
pluginId,
|
||||
captureEndUser,
|
||||
httpAuth,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -18,3 +18,4 @@ export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
|
||||
export { actionsServiceFactory } from './entrypoints/actions';
|
||||
export { metricsServiceFactory } from './entrypoints/metrics';
|
||||
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
|
||||
export { tracingServiceFactory } from './entrypoints/tracing';
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ConfigReader } from '@backstage/config';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { jwksTokenHandler } from './jwks';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { mockCredentials } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createCredentialsWithNonePrincipal,
|
||||
createCredentialsWithServicePrincipal,
|
||||
createCredentialsWithUserPrincipal,
|
||||
toInternalBackstageCredentials,
|
||||
} from './helpers';
|
||||
|
||||
describe('credentials', () => {
|
||||
@@ -82,6 +84,51 @@ describe('credentials', () => {
|
||||
).not.toMatch(/my-token/);
|
||||
});
|
||||
|
||||
it('should convert mock credentials to internal form', () => {
|
||||
expect(
|
||||
toInternalBackstageCredentials(mockCredentials.none()),
|
||||
).toMatchObject({ version: 'v1', principal: { type: 'none' } });
|
||||
|
||||
expect(
|
||||
toInternalBackstageCredentials(mockCredentials.user()),
|
||||
).toMatchObject({
|
||||
version: 'v1',
|
||||
token: mockCredentials.user.token(),
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(
|
||||
toInternalBackstageCredentials(
|
||||
mockCredentials.user('user:default/other'),
|
||||
),
|
||||
).toMatchObject({
|
||||
version: 'v1',
|
||||
token: mockCredentials.user.token('user:default/other'),
|
||||
principal: { type: 'user', userEntityRef: 'user:default/other' },
|
||||
});
|
||||
|
||||
expect(
|
||||
toInternalBackstageCredentials(mockCredentials.limitedUser()),
|
||||
).toMatchObject({
|
||||
version: 'v1',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(
|
||||
toInternalBackstageCredentials(mockCredentials.service()),
|
||||
).toMatchObject({
|
||||
version: 'v1',
|
||||
principal: { type: 'service', subject: 'external:test-service' },
|
||||
});
|
||||
|
||||
expect(
|
||||
toInternalBackstageCredentials(mockCredentials.service('plugin:other')),
|
||||
).toMatchObject({
|
||||
version: 'v1',
|
||||
principal: { type: 'service', subject: 'plugin:other' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should have a serializable form both as strings and as JSON', () => {
|
||||
const simpleService = createCredentialsWithServicePrincipal('my-service');
|
||||
expect(String(simpleService)).toMatchInlineSnapshot(
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
import { DatabaseService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
|
||||
import { JWK, exportJWK, generateKeyPair } from 'jose';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { DatabaseKeyStore } from './DatabaseKeyStore';
|
||||
import { InternalKey, KeyPayload, KeyStore } from './types';
|
||||
import { PluginKeySource } from './types';
|
||||
|
||||
+53
-56
@@ -24,13 +24,13 @@ import { ConfigReader } from '@backstage/config';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('createPluginKeySource', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const mockDir = createMockDirectory();
|
||||
const databases = TestDatabases.create();
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for implicit database (no config), %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'createPluginKeySource, %p',
|
||||
databaseId => {
|
||||
it('works for implicit database (no config)', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const getClient = jest.fn(async () => knex);
|
||||
@@ -61,12 +61,9 @@ describe('createPluginKeySource', () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for explicit database, %p',
|
||||
async databaseId => {
|
||||
it('works for explicit database', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const getClient = jest.fn(async () => knex);
|
||||
@@ -99,11 +96,12 @@ describe('createPluginKeySource', () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('works for static', async () => {
|
||||
const privateKey = `
|
||||
it('works for static', async () => {
|
||||
const privateKey = `
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY
|
||||
Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm
|
||||
@@ -111,60 +109,59 @@ describe('createPluginKeySource', () => {
|
||||
-----END PRIVATE KEY-----
|
||||
`.trim();
|
||||
|
||||
const publicKey = `
|
||||
const publicKey = `
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92
|
||||
ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw==
|
||||
-----END PUBLIC KEY-----
|
||||
`.trim();
|
||||
|
||||
mockDir.setContent({
|
||||
'public.pem': publicKey,
|
||||
'private.pem': privateKey,
|
||||
});
|
||||
const publicKeyPath = mockDir.resolve('public.pem');
|
||||
const privateKeyPath = mockDir.resolve('private.pem');
|
||||
mockDir.setContent({
|
||||
'public.pem': publicKey,
|
||||
'private.pem': privateKey,
|
||||
});
|
||||
const publicKeyPath = mockDir.resolve('public.pem');
|
||||
const privateKeyPath = mockDir.resolve('private.pem');
|
||||
|
||||
const getClient = jest.fn();
|
||||
const getClient = jest.fn();
|
||||
|
||||
const source = await createPluginKeySource({
|
||||
config: new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
pluginKeyStore: {
|
||||
type: 'static',
|
||||
static: {
|
||||
keys: [
|
||||
{
|
||||
publicKeyFile: publicKeyPath,
|
||||
privateKeyFile: privateKeyPath,
|
||||
keyId: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
const source = await createPluginKeySource({
|
||||
config: new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
pluginKeyStore: {
|
||||
type: 'static',
|
||||
static: {
|
||||
keys: [
|
||||
{
|
||||
publicKeyFile: publicKeyPath,
|
||||
privateKeyFile: privateKeyPath,
|
||||
keyId: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
database: mockServices.database.mock({ getClient }),
|
||||
logger: mockServices.logger.mock(),
|
||||
keyDuration: { seconds: 10 },
|
||||
});
|
||||
},
|
||||
}),
|
||||
database: mockServices.database.mock({ getClient }),
|
||||
logger: mockServices.logger.mock(),
|
||||
keyDuration: { seconds: 10 },
|
||||
});
|
||||
|
||||
expect(getClient).not.toHaveBeenCalled();
|
||||
expect(getClient).not.toHaveBeenCalled();
|
||||
|
||||
const keys = await source.listKeys();
|
||||
expect(keys.keys.length).toEqual(1);
|
||||
expect(keys.keys[0].key).toMatchObject({
|
||||
kid: '1',
|
||||
alg: 'ES256',
|
||||
});
|
||||
const keys = await source.listKeys();
|
||||
expect(keys.keys.length).toEqual(1);
|
||||
expect(keys.keys[0].key).toMatchObject({
|
||||
kid: '1',
|
||||
alg: 'ES256',
|
||||
});
|
||||
|
||||
const pk = await source.getPrivateSigningKey();
|
||||
expect(pk).toMatchObject({
|
||||
kid: '1',
|
||||
alg: 'ES256',
|
||||
d: expect.any(String),
|
||||
});
|
||||
const pk = await source.getPrivateSigningKey();
|
||||
expect(pk).toMatchObject({
|
||||
kid: '1',
|
||||
alg: 'ES256',
|
||||
d: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mockServices, TestCaches } from '@backstage/backend-test-utils';
|
||||
import KeyvRedis, { createCluster } from '@keyv/redis';
|
||||
import KeyvValkey from '@keyv/valkey';
|
||||
import KeyvMemcache from '@keyv/memcache';
|
||||
import { Cluster as ValkeyCluster } from 'iovalkey';
|
||||
import { CacheManager } from './CacheManager';
|
||||
|
||||
// This test is in a separate file because the main test file uses other mocking
|
||||
@@ -41,7 +42,13 @@ jest.mock('@keyv/valkey', () => {
|
||||
...Actual,
|
||||
__esModule: true,
|
||||
default: jest.fn((...args: any[]) => new DefaultConstructor(...args)),
|
||||
createCluster: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('iovalkey', () => {
|
||||
const Actual = jest.requireActual('iovalkey');
|
||||
return {
|
||||
...Actual,
|
||||
Cluster: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@keyv/memcache', () => {
|
||||
@@ -54,14 +61,16 @@ jest.mock('@keyv/memcache', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('CacheManager integration', () => {
|
||||
const caches = TestCaches.create();
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
const caches = TestCaches.create();
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'only creates one underlying connection per plugin, %p',
|
||||
async cacheId => {
|
||||
describe.each(caches.eachSupportedId())(
|
||||
'CacheManager integration, %p',
|
||||
cacheId => {
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it('only creates one underlying connection per plugin', async () => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
const manager = CacheManager.fromConfig(
|
||||
@@ -85,12 +94,9 @@ describe('CacheManager integration', () => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(KeyvValkey).toHaveBeenCalledTimes(3);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'interacts correctly with store, %p',
|
||||
async cacheId => {
|
||||
it('interacts correctly with store', async () => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
const manager = CacheManager.fromConfig(
|
||||
@@ -114,12 +120,9 @@ describe('CacheManager integration', () => {
|
||||
await expect(plugin1.get('a')).resolves.toBe('plugin1');
|
||||
await expect(plugin2a.get('a')).resolves.toBe('plugin2b');
|
||||
await expect(plugin2b.get('a')).resolves.toBe('plugin2b');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'supports both milliseconds and human durations throughout, %p',
|
||||
async cacheId => {
|
||||
it('supports both milliseconds and human durations throughout', async () => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
for (const defaultTtl of [200, { milliseconds: 200 }]) {
|
||||
@@ -175,42 +178,44 @@ describe('CacheManager integration', () => {
|
||||
await expect(defaultClient.get('e')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('f')).resolves.toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects invalid defaultTtl', () => {
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
},
|
||||
it('rejects invalid defaultTtl', () => {
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
defaultTtl: 'hello',
|
||||
},
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
defaultTtl: 'hello',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/Invalid duration 'hello' in config/);
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/Invalid duration 'hello' in config/);
|
||||
});
|
||||
|
||||
describe('CacheManager store options', () => {
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it('uses default options when no store-specific config exists', () => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
@@ -307,6 +312,9 @@ describe('CacheManager store options', () => {
|
||||
});
|
||||
|
||||
it('accepts client config for clustered mode', () => {
|
||||
const clusterInstance = { fake: 'cluster' };
|
||||
(createCluster as jest.Mock).mockReturnValue(clusterInstance);
|
||||
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
@@ -329,11 +337,112 @@ describe('CacheManager store options', () => {
|
||||
);
|
||||
manager.forPlugin('p1');
|
||||
|
||||
expect(KeyvRedis).toHaveBeenCalledWith(expect.anything(), {
|
||||
expect(KeyvRedis).toHaveBeenCalledWith(clusterInstance, {
|
||||
keyPrefixSeparator: '!',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses iovalkey Cluster for valkey cluster mode', () => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'valkey',
|
||||
connection: 'redis://localhost:6379',
|
||||
valkey: {
|
||||
cluster: {
|
||||
rootNodes: [
|
||||
{ host: 'localhost', port: 6379 },
|
||||
{ host: 'localhost', port: 6380 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
manager.forPlugin('p1');
|
||||
|
||||
expect(ValkeyCluster).toHaveBeenCalledWith(
|
||||
[
|
||||
{ host: 'localhost', port: 6379 },
|
||||
{ host: 'localhost', port: 6380 },
|
||||
],
|
||||
{
|
||||
redisOptions: undefined,
|
||||
scaleReads: undefined,
|
||||
maxRedirections: undefined,
|
||||
lazyConnect: undefined,
|
||||
},
|
||||
);
|
||||
expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), {
|
||||
keyPrefix: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes valkey cluster options from config', () => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'valkey',
|
||||
connection: 'redis://localhost:6379',
|
||||
valkey: {
|
||||
client: { keyPrefix: 'my-app:' },
|
||||
cluster: {
|
||||
rootNodes: [{ host: 'localhost', port: 6379 }],
|
||||
useReplicas: true,
|
||||
maxCommandRedirections: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
manager.forPlugin('p1');
|
||||
|
||||
expect(ValkeyCluster).toHaveBeenCalledWith(
|
||||
[{ host: 'localhost', port: 6379 }],
|
||||
{
|
||||
redisOptions: undefined,
|
||||
scaleReads: 'slave',
|
||||
maxRedirections: 5,
|
||||
lazyConnect: undefined,
|
||||
},
|
||||
);
|
||||
expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), {
|
||||
keyPrefix: 'my-app:',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to non-clustered valkey when cluster config is missing root nodes', () => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'valkey',
|
||||
connection: 'redis://localhost:6379',
|
||||
valkey: {
|
||||
cluster: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
manager.forPlugin('p1');
|
||||
|
||||
expect(ValkeyCluster).not.toHaveBeenCalled();
|
||||
expect(KeyvValkey).toHaveBeenCalledWith('redis://localhost:6379', {
|
||||
keyPrefix: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly applies namespace configuration to redis and valkey stores', () => {
|
||||
const testCases = [
|
||||
{
|
||||
|
||||
+16
-13
@@ -240,14 +240,16 @@ export class CacheManager {
|
||||
|
||||
valkeyOptions.cluster = {
|
||||
rootNodes: clusterConfig.get('rootNodes'),
|
||||
defaults: clusterConfig.getOptional('defaults'),
|
||||
minimizeConnections: clusterConfig.getOptionalBoolean(
|
||||
'minimizeConnections',
|
||||
),
|
||||
useReplicas: clusterConfig.getOptionalBoolean('useReplicas'),
|
||||
maxCommandRedirections: clusterConfig.getOptionalNumber(
|
||||
'maxCommandRedirections',
|
||||
),
|
||||
options: {
|
||||
redisOptions: clusterConfig.getOptional('defaults'),
|
||||
scaleReads: clusterConfig.getOptionalBoolean('useReplicas')
|
||||
? 'slave'
|
||||
: undefined,
|
||||
maxRedirections: clusterConfig.getOptionalNumber(
|
||||
'maxCommandRedirections',
|
||||
),
|
||||
lazyConnect: clusterConfig.getOptionalBoolean('minimizeConnections'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,9 +370,7 @@ export class CacheManager {
|
||||
|
||||
private createValkeyStoreFactory(): StoreFactory {
|
||||
const KeyvValkey = require('@keyv/valkey').default;
|
||||
// `@keyv/valkey` doesn't export a `createCluster` function, but is compatible with the one from `@keyv/redis`
|
||||
// See https://keyv.org/docs/storage-adapters/valkey
|
||||
const { createCluster } = require('@keyv/redis');
|
||||
const { Cluster } = require('iovalkey');
|
||||
const stores: Record<string, typeof KeyvValkey> = {};
|
||||
|
||||
return (pluginId, defaultTtl) => {
|
||||
@@ -382,8 +382,11 @@ export class CacheManager {
|
||||
if (!stores[pluginId]) {
|
||||
const valkeyOptions = this.storeOptions?.client;
|
||||
if (this.storeOptions?.cluster) {
|
||||
// Create a Valkey cluster (Redis cluster under the hood)
|
||||
const cluster = createCluster(this.storeOptions?.cluster);
|
||||
// Create an iovalkey Cluster instance, which is the type that @keyv/valkey expects
|
||||
const cluster = new Cluster(
|
||||
this.storeOptions.cluster.rootNodes,
|
||||
this.storeOptions.cluster.options,
|
||||
);
|
||||
stores[pluginId] = new KeyvValkey(cluster, valkeyOptions);
|
||||
} else {
|
||||
// Create a regular Valkey connection
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
|
||||
import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis';
|
||||
import { KeyvValkeyOptions } from '@keyv/valkey';
|
||||
import { ClusterNode, ClusterOptions } from 'iovalkey';
|
||||
|
||||
/**
|
||||
* Options for Redis cache store.
|
||||
@@ -38,7 +39,10 @@ export type RedisCacheStoreOptions = {
|
||||
export type ValkeyCacheStoreOptions = {
|
||||
type: 'valkey';
|
||||
client?: KeyvValkeyOptions;
|
||||
cluster?: RedisClusterOptions;
|
||||
cluster?: {
|
||||
rootNodes: Array<ClusterNode>;
|
||||
options?: ClusterOptions;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface HostDiscoveryOptions {
|
||||
* These endpoints have lower priority than any that are defined in
|
||||
* app-config, but higher priority than the fallback ones.
|
||||
*
|
||||
* This parameter is usedful for example if you want to provide a shared
|
||||
* This parameter is useful for example if you want to provide a shared
|
||||
* library of core services to your plugin developers, which is set up for the
|
||||
* default behaviors in your org. This alleviates the need for replicating any
|
||||
* given set of endpoint config in every backend that you deploy.
|
||||
|
||||
@@ -41,68 +41,65 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('migrations', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20250411000000_last_run.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20250411000000_last_run.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20250411000000_last_run.js');
|
||||
await migrateUntilBefore(knex, '20250411000000_last_run.js');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
})
|
||||
.into('backstage_backend_tasks__tasks');
|
||||
await knex
|
||||
.insert({
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
})
|
||||
.into('backstage_backend_tasks__tasks');
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex
|
||||
.table('backstage_backend_tasks__tasks')
|
||||
.update({ last_run_error_json: 'error' })
|
||||
.where({ id: 'i' });
|
||||
await knex
|
||||
.table('backstage_backend_tasks__tasks')
|
||||
.update({ last_run_error_json: 'error' })
|
||||
.where({ id: 'i' });
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
last_run_ended_at: null,
|
||||
last_run_error_json: 'error',
|
||||
},
|
||||
]);
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
last_run_ended_at: null,
|
||||
last_run_error_json: 'error',
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'i',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
+49
-54
@@ -24,9 +24,10 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TaskScheduler', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
describe.each(databases.eachSupportedId())('TaskScheduler, %p', databaseId => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const rootLifecycle = mockServices.rootLifecycle.mock();
|
||||
const httpRouter = mockServices.httpRouter.mock();
|
||||
const pluginMetadata = {
|
||||
@@ -35,63 +36,57 @@ describe('TaskScheduler', () => {
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
const metrics = metricsServiceMock.mock();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can return a working v1 plugin impl, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const database = mockServices.database({ knex });
|
||||
it('can return a working v1 plugin impl', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const database = mockServices.database({ knex });
|
||||
|
||||
const manager = DefaultSchedulerService.create({
|
||||
database,
|
||||
logger,
|
||||
metrics,
|
||||
rootLifecycle,
|
||||
httpRouter,
|
||||
pluginMetadata,
|
||||
});
|
||||
const fn = jest.fn();
|
||||
const manager = DefaultSchedulerService.create({
|
||||
database,
|
||||
logger,
|
||||
metrics,
|
||||
rootLifecycle,
|
||||
httpRouter,
|
||||
pluginMetadata,
|
||||
});
|
||||
const fn = jest.fn();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can return a working v2 plugin impl, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const database = mockServices.database({ knex });
|
||||
it('can return a working v2 plugin impl', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const database = mockServices.database({ knex });
|
||||
|
||||
const manager = DefaultSchedulerService.create({
|
||||
database,
|
||||
logger,
|
||||
metrics,
|
||||
rootLifecycle,
|
||||
httpRouter,
|
||||
pluginMetadata,
|
||||
});
|
||||
const fn = jest.fn();
|
||||
const manager = DefaultSchedulerService.create({
|
||||
database,
|
||||
logger,
|
||||
metrics,
|
||||
rootLifecycle,
|
||||
httpRouter,
|
||||
pluginMetadata,
|
||||
});
|
||||
const fn = jest.fn();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+220
-246
@@ -31,48 +31,50 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('PluginTaskManagerImpl', () => {
|
||||
const addShutdownHook = jest.fn();
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Make sure all databases are running before mocking timers, in case of testcontainers
|
||||
await Promise.all(
|
||||
databases.eachSupportedId().map(([id]) => databases.init(id)),
|
||||
);
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'PluginTaskManagerImpl, %p',
|
||||
databaseId => {
|
||||
const addShutdownHook = jest.fn();
|
||||
|
||||
jest.useFakeTimers();
|
||||
}, 60_000);
|
||||
beforeAll(async () => {
|
||||
// Make sure the database is running before mocking timers, in case of testcontainers
|
||||
await databases.init(databaseId);
|
||||
jest.useFakeTimers();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
async function init(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
const manager = new PluginTaskSchedulerImpl(
|
||||
'myplugin',
|
||||
async () => knex,
|
||||
mockServices.logger.mock(),
|
||||
metricsServiceMock.mock(),
|
||||
{
|
||||
addShutdownHook,
|
||||
addBeforeShutdownHook: jest.fn(),
|
||||
addStartupHook: jest.fn(),
|
||||
},
|
||||
);
|
||||
return { knex, manager };
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with global scope', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the v1 happy path, %p',
|
||||
async databaseId => {
|
||||
async function init(id: TestDatabaseId) {
|
||||
const knex = await databases.init(id);
|
||||
await migrateBackendTasks(knex);
|
||||
const manager = new PluginTaskSchedulerImpl(
|
||||
'myplugin',
|
||||
async () => knex,
|
||||
mockServices.logger.mock(),
|
||||
metricsServiceMock.mock(),
|
||||
{
|
||||
addShutdownHook,
|
||||
addBeforeShutdownHook: jest.fn(),
|
||||
addStartupHook: jest.fn(),
|
||||
},
|
||||
);
|
||||
return { knex, manager };
|
||||
}
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with global scope', () => {
|
||||
it('can run the v1 happy path', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -87,12 +89,9 @@ describe('PluginTaskManagerImpl', () => {
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the v2 happy path, %p',
|
||||
async databaseId => {
|
||||
it('can run the v2 happy path', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -107,12 +106,9 @@ describe('PluginTaskManagerImpl', () => {
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'aborts the task if shutdown hook is invoked, %p',
|
||||
async databaseId => {
|
||||
it('aborts the task if shutdown hook is invoked', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -134,14 +130,11 @@ describe('PluginTaskManagerImpl', () => {
|
||||
// Should be aborted after the shutdown hook is invoked
|
||||
await shutdownHook();
|
||||
expect(abortSignal.aborted).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerTask with global scope', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can manually trigger a task, %p',
|
||||
async databaseId => {
|
||||
describe('triggerTask with global scope', () => {
|
||||
it('can manually trigger a task', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -160,12 +153,9 @@ describe('PluginTaskManagerImpl', () => {
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cant trigger a non-existent task, %p',
|
||||
async databaseId => {
|
||||
it('cant trigger a non-existent task', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -180,12 +170,9 @@ describe('PluginTaskManagerImpl', () => {
|
||||
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cant trigger a running task, %p',
|
||||
async databaseId => {
|
||||
it('cant trigger a running task', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const promise = createDeferred();
|
||||
@@ -205,140 +192,137 @@ describe('PluginTaskManagerImpl', () => {
|
||||
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with local scope', () => {
|
||||
it('can run the v1 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: { milliseconds: 5000 },
|
||||
frequency: { milliseconds: 5000 },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with local scope', () => {
|
||||
it('can run the v1 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
it('can run the v2 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: { milliseconds: 5000 },
|
||||
frequency: { milliseconds: 5000 },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
it('can run the v2 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
it('aborts the task if shutdown hook is invoked', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise<AbortSignal>(resolve =>
|
||||
fn.mockImplementation(resolve),
|
||||
);
|
||||
await manager.scheduleTask({
|
||||
id: 'task3',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
|
||||
const shutdownHook = addShutdownHook.mock.calls[0][0];
|
||||
const abortSignal = await promise;
|
||||
expect(abortSignal.aborted).toBe(false);
|
||||
it('aborts the task if shutdown hook is invoked', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
// Should be aborted after the shutdown hook is invoked
|
||||
await shutdownHook();
|
||||
expect(abortSignal.aborted).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise<AbortSignal>(resolve =>
|
||||
fn.mockImplementation(resolve),
|
||||
);
|
||||
await manager.scheduleTask({
|
||||
id: 'task3',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
describe('triggerTask with local scope', () => {
|
||||
it('can manually trigger a task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
const shutdownHook = addShutdownHook.mock.calls[0][0];
|
||||
const abortSignal = await promise;
|
||||
expect(abortSignal.aborted).toBe(false);
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
// Should be aborted after the shutdown hook is invoked
|
||||
await shutdownHook();
|
||||
expect(abortSignal.aborted).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
await manager.triggerTask('task1');
|
||||
jest.advanceTimersByTime(5000);
|
||||
describe('triggerTask with local scope', () => {
|
||||
it('can manually trigger a task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
it('cant trigger a non-existent task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
await manager.triggerTask('task1');
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
|
||||
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
}, 60_000);
|
||||
it('cant trigger a non-existent task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
it('cant trigger a running task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const promise = createDeferred();
|
||||
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
promise.resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'local',
|
||||
});
|
||||
it('cant trigger a running task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
await promise;
|
||||
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
const promise = createDeferred();
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('createScheduledTaskRunner', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the happy path, %p',
|
||||
async databaseId => {
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
promise.resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await promise;
|
||||
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('createScheduledTaskRunner', () => {
|
||||
it('can run the happy path', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
@@ -356,14 +340,11 @@ describe('PluginTaskManagerImpl', () => {
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('can fetch task ids', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can fetch both global and local task ids, %p',
|
||||
async databaseId => {
|
||||
describe('can fetch task ids', () => {
|
||||
it('can fetch both global and local task ids', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
const fn = jest.fn();
|
||||
|
||||
@@ -395,52 +376,51 @@ describe('PluginTaskManagerImpl', () => {
|
||||
settings: expect.objectContaining({ cadence: 'PT5S' }),
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('cancelTask with local scope', () => {
|
||||
it('can cancel a running task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const promise = createDeferred();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
promise.resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
await promise;
|
||||
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
|
||||
}, 60_000);
|
||||
describe('cancelTask with local scope', () => {
|
||||
it('can cancel a running task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
it('cannot cancel a task that is not running', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
const promise = createDeferred();
|
||||
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
promise.resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await expect(manager.cancelTask('task1')).rejects.toThrow(ConflictError);
|
||||
}, 60_000);
|
||||
});
|
||||
await promise;
|
||||
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
|
||||
}, 60_000);
|
||||
|
||||
describe('cancelTask with global scope', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can cancel a running task, %p',
|
||||
async databaseId => {
|
||||
it('cannot cancel a task that is not running', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await expect(manager.cancelTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('cancelTask with global scope', () => {
|
||||
it('can cancel a running task', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const promise = createDeferred();
|
||||
@@ -458,23 +438,17 @@ describe('PluginTaskManagerImpl', () => {
|
||||
|
||||
await promise;
|
||||
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cannot cancel a non-existent task, %p',
|
||||
async databaseId => {
|
||||
it('cannot cancel a non-existent task', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
await expect(manager.cancelTask('nonexistent')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cannot cancel a task that is not running, %p',
|
||||
async databaseId => {
|
||||
it('cannot cancel a task that is not running', async () => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
await manager.scheduleTask({
|
||||
@@ -489,16 +463,16 @@ describe('PluginTaskManagerImpl', () => {
|
||||
await expect(manager.cancelTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('parseDuration', () => {
|
||||
it('should parse durations', () => {
|
||||
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
|
||||
expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S');
|
||||
expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *');
|
||||
expect(parseDuration({ trigger: 'manual' })).toEqual('manual');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDuration', () => {
|
||||
it('should parse durations', () => {
|
||||
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
|
||||
expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S');
|
||||
expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *');
|
||||
expect(parseDuration({ trigger: 'manual' })).toEqual('manual');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+23
-22
@@ -34,28 +34,29 @@ const getTask = async (knex: Knex): Promise<DbTasksRow> => {
|
||||
return (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
};
|
||||
|
||||
describe('PluginTaskSchedulerJanitor', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create({
|
||||
ids: [
|
||||
/* 'MYSQL_8' not supported yet */
|
||||
'POSTGRES_18',
|
||||
'POSTGRES_14',
|
||||
'SQLITE_3',
|
||||
'MYSQL_8',
|
||||
],
|
||||
});
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
const databases = TestDatabases.create({
|
||||
ids: [
|
||||
/* 'MYSQL_8' not supported yet */
|
||||
'POSTGRES_18',
|
||||
'POSTGRES_14',
|
||||
'SQLITE_3',
|
||||
'MYSQL_8',
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'PluginTaskSchedulerJanitor, %p',
|
||||
databaseId => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'Should update date if current_run_expires_at expires, %p',
|
||||
async databaseId => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('Should update date if current_run_expires_at expires', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
@@ -92,6 +93,6 @@ describe('PluginTaskSchedulerJanitor', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -328,8 +328,9 @@ describe.each(databases.eachSupportedId())('TaskWorker, %s', databaseId => {
|
||||
expect(fn1).toHaveBeenCalledTimes(0);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(fn1).toHaveBeenCalledTimes(0);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(fn1.mock.calls.length).toBeGreaterThan(0);
|
||||
await waitForExpect(() => {
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Start a second worker and make sure it waits but the first worker still works along
|
||||
const fn2 = jest.fn();
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { CronTime } from 'cron';
|
||||
import { Knex } from 'knex';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables';
|
||||
import {
|
||||
TaskSettingsV2,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
BackstageUserInfo,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { mockCredentials } from '@backstage/backend-test-utils';
|
||||
import { createDeferred } from '@backstage/types';
|
||||
import {
|
||||
CachedUserInfoService,
|
||||
UserInfoCacheEntry,
|
||||
} from './CachedUserInfoService';
|
||||
|
||||
const aliceInfo: BackstageUserInfo = {
|
||||
userEntityRef: 'user:default/alice',
|
||||
ownershipEntityRefs: ['user:default/alice', 'group:default/team-a'],
|
||||
};
|
||||
|
||||
describe('CachedUserInfoService', () => {
|
||||
it('delegates to the underlying service on the first call', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
const result = await service.getUserInfo(mockCredentials.user());
|
||||
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the cached result on subsequent calls within TTL', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
await service.getUserInfo(creds);
|
||||
await service.getUserInfo(creds);
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('coalesces concurrent in-flight requests for the same token', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
service.getUserInfo(creds),
|
||||
service.getUserInfo(creds),
|
||||
service.getUserInfo(creds),
|
||||
]);
|
||||
|
||||
expect(r1).toEqual(aliceInfo);
|
||||
expect(r2).toEqual(aliceInfo);
|
||||
expect(r3).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caches different tokens separately', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
await service.getUserInfo(mockCredentials.user('user:default/alice'));
|
||||
await service.getUserInfo(mockCredentials.user('user:default/bob'));
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-fetches after the TTL expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate, { ttlMs: 50 });
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(60);
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('evicts the cache entry on rejection and retries on next call', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('auth backend down'))
|
||||
.mockResolvedValueOnce(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await expect(service.getUserInfo(creds)).rejects.toThrow(
|
||||
'auth backend down',
|
||||
);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
const result = await service.getUserInfo(creds);
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evicts eagerly so concurrent waiters see the rejection and the next call retries', async () => {
|
||||
const firstCall = createDeferred<BackstageUserInfo>();
|
||||
firstCall.catch(() => {});
|
||||
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
const p1 = service.getUserInfo(creds);
|
||||
const p2 = service.getUserInfo(creds);
|
||||
|
||||
firstCall.reject(new Error('boom'));
|
||||
|
||||
await expect(p1).rejects.toThrow('boom');
|
||||
await expect(p2).rejects.toThrow('boom');
|
||||
|
||||
const result = await service.getUserInfo(creds);
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('delegates directly when credentials have no token', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
await service.getUserInfo(mockCredentials.none());
|
||||
await service.getUserInfo(mockCredentials.none());
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares cache entries across instances when given the same map', async () => {
|
||||
const delegate1: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const delegate2: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const entries = new Map<string, UserInfoCacheEntry>();
|
||||
const service1 = new CachedUserInfoService(delegate1, { entries });
|
||||
const service2 = new CachedUserInfoService(delegate2, { entries });
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service1.getUserInfo(creds);
|
||||
await service2.getUserInfo(creds);
|
||||
|
||||
expect(delegate1.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
expect(delegate2.getUserInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
BackstageCredentials,
|
||||
BackstageUserInfo,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { toInternalBackstageCredentials } from '../auth/helpers';
|
||||
|
||||
const DEFAULT_TTL_MS = 5_000;
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
|
||||
export type UserInfoCacheEntry = {
|
||||
promise: Promise<BackstageUserInfo>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export class CachedUserInfoService implements UserInfoService {
|
||||
readonly #delegate: UserInfoService;
|
||||
readonly #entries: Map<string, UserInfoCacheEntry>;
|
||||
readonly #ttlMs: number;
|
||||
#lastSweep: number = Date.now();
|
||||
|
||||
constructor(
|
||||
delegate: UserInfoService,
|
||||
options?: {
|
||||
entries?: Map<string, UserInfoCacheEntry>;
|
||||
ttlMs?: number;
|
||||
},
|
||||
) {
|
||||
this.#delegate = delegate;
|
||||
this.#entries = options?.entries ?? new Map();
|
||||
this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
||||
}
|
||||
|
||||
async getUserInfo(
|
||||
credentials: BackstageCredentials,
|
||||
): Promise<BackstageUserInfo> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
const token = internalCredentials.token;
|
||||
if (!token) {
|
||||
return this.#delegate.getUserInfo(credentials);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.#lastSweep > SWEEP_INTERVAL_MS) {
|
||||
this.#lastSweep = now;
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cached = this.#entries.get(token);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const promise = this.#delegate.getUserInfo(credentials).catch(error => {
|
||||
if (this.#entries.get(token)?.promise === promise) {
|
||||
this.#entries.delete(token);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.#entries.set(token, {
|
||||
promise,
|
||||
expiresAt: now + this.#ttlMs,
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
CachedUserInfoService,
|
||||
UserInfoCacheEntry,
|
||||
} from './CachedUserInfoService';
|
||||
import { DefaultUserInfoService } from './DefaultUserInfoService';
|
||||
|
||||
/**
|
||||
@@ -34,7 +38,13 @@ export const userInfoServiceFactory = createServiceFactory({
|
||||
deps: {
|
||||
discovery: coreServices.discovery,
|
||||
},
|
||||
async factory({ discovery }) {
|
||||
return new DefaultUserInfoService({ discovery });
|
||||
createRootContext() {
|
||||
return new Map<string, UserInfoCacheEntry>();
|
||||
},
|
||||
async factory({ discovery }, entries) {
|
||||
return new CachedUserInfoService(
|
||||
new DefaultUserInfoService({ discovery }),
|
||||
{ entries },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,83 +41,77 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('migrations', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20210928160613_init.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20210928160613_init.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20210928160613_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
await migrateUntilBefore(knex, '20210928160613_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.fn.now(),
|
||||
});
|
||||
next_run_start_at: expect.anything(),
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: expect.anything(),
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
// This looks odd - you might expect a .toThrow at the end but that
|
||||
// actually is flaky for some reason specifically on sqlite when
|
||||
// performing multiple runs in sequence
|
||||
await expect(knex('backstage_backend_tasks__tasks')).rejects.toEqual(
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// This looks odd - you might expect a .toThrow at the end but that
|
||||
// actually is flaky for some reason specifically on sqlite when
|
||||
// performing multiple runs in sequence
|
||||
await expect(knex('backstage_backend_tasks__tasks')).rejects.toEqual(
|
||||
expect.anything(),
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
it('20240712211735_nullable_next_run.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20240712211735_nullable_next_run.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUntilBefore(knex, '20240712211735_nullable_next_run.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await migrateUntilBefore(knex, '20240712211735_nullable_next_run.js');
|
||||
await migrateUpOnce(knex);
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.raw('null'),
|
||||
});
|
||||
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await expect(
|
||||
knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.raw('null'),
|
||||
});
|
||||
}),
|
||||
).rejects.toEqual(expect.anything());
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await expect(
|
||||
knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.raw('null'),
|
||||
}),
|
||||
).rejects.toEqual(expect.anything());
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @backstage/backend-dynamic-feature-service
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 41070b8: Upgraded `@module-federation/enhanced`, `@module-federation/runtime`, and `@module-federation/sdk` from `^0.21.6` to `^2.3.3` to address known vulnerabilities.
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
- @backstage/backend-defaults@0.17.1
|
||||
- @backstage/plugin-catalog-backend@3.7.0
|
||||
- @backstage/cli-node@0.3.2
|
||||
- @backstage/plugin-scaffolder-node@0.13.3
|
||||
- @backstage/plugin-permission-node@0.11.0
|
||||
- @backstage/plugin-auth-node@0.7.1
|
||||
- @backstage/plugin-permission-common@0.9.9
|
||||
- @backstage/plugin-search-backend-node@1.4.4
|
||||
- @backstage/backend-openapi-utils@0.6.9
|
||||
- @backstage/cli-common@0.2.2
|
||||
- @backstage/config@1.3.8
|
||||
- @backstage/config-loader@1.10.11
|
||||
- @backstage/plugin-app-node@0.1.45
|
||||
- @backstage/plugin-events-backend@0.6.2
|
||||
- @backstage/plugin-events-node@0.4.22
|
||||
- @backstage/plugin-search-common@1.2.24
|
||||
|
||||
## 0.8.2-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@1.9.1-next.1
|
||||
- @backstage/backend-defaults@0.17.1-next.2
|
||||
- @backstage/plugin-catalog-backend@3.7.0-next.2
|
||||
- @backstage/plugin-scaffolder-node@0.13.3-next.2
|
||||
|
||||
## 0.8.2-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-dynamic-feature-service",
|
||||
"version": "0.8.2-next.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Backstage dynamic feature service",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @backstage/backend-openapi-utils
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
|
||||
## 0.6.9-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-openapi-utils",
|
||||
"version": "0.6.9-next.0",
|
||||
"version": "0.6.9",
|
||||
"description": "OpenAPI typescript support.",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @backstage/backend-plugin-api
|
||||
|
||||
## 1.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 90b572e: Adds an alpha `TracingService` to provide a unified interface for emitting trace spans across Backstage plugins.
|
||||
- 6209065: Added `context` and `propagation` to the alpha `TracingService`. Plugins can bridge OpenTelemetry context across async boundaries via `tracing.propagation.extract(tracing.context.active(), carrier)` followed by `tracing.context.with(ctx, fn)`, and read propagated baggage via `tracing.propagation.getActiveBaggage()` or `tracing.propagation.getBaggage(ctx)`.
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/plugin-permission-node@0.11.0
|
||||
- @backstage/plugin-auth-node@0.7.1
|
||||
- @backstage/plugin-permission-common@0.9.9
|
||||
- @backstage/cli-common@0.2.2
|
||||
- @backstage/config@1.3.8
|
||||
|
||||
## 1.9.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 90b572e: Adds an alpha `TracingService` to provide a unified interface for emitting trace spans across Backstage plugins.
|
||||
|
||||
## 1.9.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-plugin-api",
|
||||
"version": "1.9.1-next.0",
|
||||
"version": "1.9.1",
|
||||
"description": "Core API used by Backstage backend plugins",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import type { Request as Request_2 } from 'express';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
@@ -289,5 +290,108 @@ export const rootSystemMetadataServiceRef: ServiceRef<
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @alpha
|
||||
export interface TracingService {
|
||||
readonly context: TracingServiceContextAPI;
|
||||
readonly propagation: TracingServicePropagationAPI;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceAttributes {
|
||||
// (undocumented)
|
||||
[key: string]: TracingServiceAttributeValue | undefined;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export type TracingServiceAttributeValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<null | undefined | string>
|
||||
| Array<null | undefined | number>
|
||||
| Array<null | undefined | boolean>;
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceBaggage {
|
||||
// (undocumented)
|
||||
getAllEntries(): Array<[string, TracingServiceBaggageEntry]>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceBaggageEntry {
|
||||
// (undocumented)
|
||||
value: string;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceContext {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/TracingServiceContext';
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceContextAPI {
|
||||
active(): TracingServiceContext;
|
||||
with<T>(context: TracingServiceContext, fn: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServicePropagationAPI {
|
||||
extract(
|
||||
context: TracingServiceContext,
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): TracingServiceContext;
|
||||
getActiveBaggage(): TracingServiceBaggage | undefined;
|
||||
getBaggage(context: TracingServiceContext): TracingServiceBaggage | undefined;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const tracingServiceRef: ServiceRef<
|
||||
TracingService,
|
||||
'plugin',
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceSpan {
|
||||
// (undocumented)
|
||||
setAttribute(key: string, value: TracingServiceAttributeValue): void;
|
||||
// (undocumented)
|
||||
setStatus(status: TracingServiceSpanStatus): void;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export type TracingServiceSpanKind =
|
||||
| 'internal'
|
||||
| 'server'
|
||||
| 'client'
|
||||
| 'producer'
|
||||
| 'consumer';
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceSpanOptions {
|
||||
attributes?: TracingServiceAttributes;
|
||||
credentials?: BackstageCredentials;
|
||||
kind?: TracingServiceSpanKind;
|
||||
request?: Request_2<any, any, any, any, any>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceSpanStatus {
|
||||
// (undocumented)
|
||||
code: 'unset' | 'ok' | 'error';
|
||||
// (undocumented)
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2026 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 { Request } from 'express';
|
||||
import { BackstageCredentials } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Attribute values that can be attached to spans.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type TracingServiceAttributeValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<null | undefined | string>
|
||||
| Array<null | undefined | number>
|
||||
| Array<null | undefined | boolean>;
|
||||
|
||||
/**
|
||||
* A set of key-value pairs that can be attached to spans.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceAttributes {
|
||||
[key: string]: TracingServiceAttributeValue | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of operation a span represents.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type TracingServiceSpanKind =
|
||||
| 'internal'
|
||||
| 'server'
|
||||
| 'client'
|
||||
| 'producer'
|
||||
| 'consumer';
|
||||
|
||||
/**
|
||||
* The status of a span.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceSpanStatus {
|
||||
code: 'unset' | 'ok' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A trace span. Provided to `startActiveSpan` so
|
||||
* additional attributes or status can be set from within the callback.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceSpan {
|
||||
setAttribute(key: string, value: TracingServiceAttributeValue): void;
|
||||
setStatus(status: TracingServiceSpanStatus): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `startActiveSpan`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceSpanOptions {
|
||||
/** Attributes to attach to the span. */
|
||||
attributes?: TracingServiceAttributes;
|
||||
/** The kind of span. */
|
||||
kind?: TracingServiceSpanKind;
|
||||
/**
|
||||
* Authenticated principal source for span enrichment. Preferred when
|
||||
* both `credentials` and `request` are supplied.
|
||||
*/
|
||||
credentials?: BackstageCredentials;
|
||||
/** HTTP request to extract credentials from for span enrichment. */
|
||||
request?: Request<any, any, any, any, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A service for emitting trace spans from a backend plugin.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingService {
|
||||
/**
|
||||
* Runs `fn` inside a new active span. The span is finished when `fn`
|
||||
* resolves or throws.
|
||||
*/
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
/**
|
||||
* Runs `fn` inside a new active span configured by `options`. The
|
||||
* span is finished when `fn` resolves or throws.
|
||||
*/
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* Read the active tracing context, or run work within a specific
|
||||
* one.
|
||||
*/
|
||||
readonly context: TracingServiceContextAPI;
|
||||
|
||||
/**
|
||||
* Extract a caller's tracing context from an inbound carrier, and
|
||||
* read baggage from a context. Use these to bridge context across
|
||||
* boundaries where automatic propagation is lost — for example,
|
||||
* when a request arrives over a transport that does not
|
||||
* automatically attach the caller's context.
|
||||
*/
|
||||
readonly propagation: TracingServicePropagationAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active tracing context, or run work within a specific one.
|
||||
* The context carries the active span and propagation fields (trace
|
||||
* parent, baggage) for the current unit of work, and is automatically
|
||||
* inherited by spans created via `startActiveSpan`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceContextAPI {
|
||||
/** Returns the currently active context. */
|
||||
active(): TracingServiceContext;
|
||||
/** Runs `fn` with the supplied context set as the active context. */
|
||||
with<T>(context: TracingServiceContext, fn: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a caller's trace parent and baggage from an inbound carrier
|
||||
* (typically HTTP headers), or read baggage from a context. Use these
|
||||
* to bridge context across boundaries where automatic propagation is
|
||||
* lost.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServicePropagationAPI {
|
||||
/**
|
||||
* Returns a new context with propagation fields (trace parent,
|
||||
* baggage, ...) read from the supplied carrier merged into it.
|
||||
*/
|
||||
extract(
|
||||
context: TracingServiceContext,
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): TracingServiceContext;
|
||||
/**
|
||||
* Returns the baggage attached to the supplied context, or
|
||||
* `undefined` when none is present.
|
||||
*/
|
||||
getBaggage(context: TracingServiceContext): TracingServiceBaggage | undefined;
|
||||
/**
|
||||
* Returns the baggage attached to the currently active context, or
|
||||
* `undefined` when none is present. Equivalent to
|
||||
* `getBaggage(context.active())`.
|
||||
*/
|
||||
getActiveBaggage(): TracingServiceBaggage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque handle representing a tracing context. Consumers receive
|
||||
* these from {@link TracingServiceContextAPI.active} or
|
||||
* {@link TracingServicePropagationAPI.extract} and pass them back into
|
||||
* the API; the type carries no inspectable fields.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceContext {
|
||||
readonly $$type: '@backstage/TracingServiceContext';
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only view of propagated baggage entries.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceBaggage {
|
||||
getAllEntries(): Array<[string, TracingServiceBaggageEntry]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single baggage entry.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceBaggageEntry {
|
||||
value: string;
|
||||
}
|
||||
@@ -46,9 +46,25 @@ export type {
|
||||
MetricsServiceObservableUpDownCounter,
|
||||
} from './MetricsService';
|
||||
|
||||
export type {
|
||||
TracingService,
|
||||
TracingServiceAttributeValue,
|
||||
TracingServiceAttributes,
|
||||
TracingServiceBaggage,
|
||||
TracingServiceBaggageEntry,
|
||||
TracingServiceContext,
|
||||
TracingServiceContextAPI,
|
||||
TracingServicePropagationAPI,
|
||||
TracingServiceSpan,
|
||||
TracingServiceSpanKind,
|
||||
TracingServiceSpanOptions,
|
||||
TracingServiceSpanStatus,
|
||||
} from './TracingService';
|
||||
|
||||
export {
|
||||
actionsRegistryServiceRef,
|
||||
actionsServiceRef,
|
||||
metricsServiceRef,
|
||||
rootSystemMetadataServiceRef,
|
||||
tracingServiceRef,
|
||||
} from './refs';
|
||||
|
||||
@@ -67,3 +67,16 @@ export const metricsServiceRef = createServiceRef<
|
||||
>({
|
||||
id: 'alpha.core.metrics',
|
||||
});
|
||||
|
||||
/**
|
||||
* Service for managing trace spans.
|
||||
*
|
||||
* See `TracingService` for the API surface.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const tracingServiceRef = createServiceRef<
|
||||
import('./TracingService').TracingService
|
||||
>({
|
||||
id: 'alpha.core.tracing',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# @backstage/backend-test-utils
|
||||
|
||||
## 1.11.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7fb12b8: Added a new tracing service mock to be leveraged in tests
|
||||
- ada7df7: Fixed `mockCredentials` to include the internal `version: 'v1'` field on all credential objects (`none()`, `user()`, `limitedUser()`, `service()`), and fixed `user()` to encode the user entity ref into the token (matching `user.token(ref)` behavior). This makes mock credentials compatible with `toInternalBackstageCredentials()`, which validates the version field, and ensures that credentials for different users produce different tokens.
|
||||
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
|
||||
- 6209065: Added `context` and `propagation` to the alpha `TracingService`. Plugins can bridge OpenTelemetry context across async boundaries via `tracing.propagation.extract(tracing.context.active(), carrier)` followed by `tracing.context.with(ctx, fn)`, and read propagated baggage via `tracing.propagation.getActiveBaggage()` or `tracing.propagation.getBaggage(ctx)`.
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
- @backstage/backend-defaults@0.17.1
|
||||
- @backstage/backend-app-api@1.7.0
|
||||
- @backstage/plugin-auth-node@0.7.1
|
||||
- @backstage/plugin-permission-common@0.9.9
|
||||
- @backstage/config@1.3.8
|
||||
- @backstage/plugin-events-node@0.4.22
|
||||
|
||||
## 1.11.3-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7fb12b8: Added a new tracing service mock to be leveraged in tests
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@1.9.1-next.1
|
||||
- @backstage/backend-defaults@0.17.1-next.2
|
||||
|
||||
## 1.11.3-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-defaults@0.17.1-next.1
|
||||
- @backstage/plugin-auth-node@0.7.1-next.1
|
||||
- @backstage/plugin-permission-common@0.9.9-next.1
|
||||
|
||||
## 1.11.3-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-test-utils",
|
||||
"version": "1.11.3-next.0",
|
||||
"version": "1.11.3",
|
||||
"description": "Test helpers library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -77,7 +77,6 @@
|
||||
"pg-connection-string": "^2.3.0",
|
||||
"testcontainers": "^11.9.0",
|
||||
"text-extensions": "^2.4.0",
|
||||
"uuid": "^11.0.0",
|
||||
"yn": "^4.0.0",
|
||||
"zod": "^3.25.76 || ^4.0.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
|
||||
@@ -14,6 +14,12 @@ import { JsonValue } from '@backstage/types';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { TracingService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { TracingServiceAttributeValue } from '@backstage/backend-plugin-api/alpha';
|
||||
import { TracingServiceContextAPI } from '@backstage/backend-plugin-api/alpha';
|
||||
import { TracingServicePropagationAPI } from '@backstage/backend-plugin-api/alpha';
|
||||
import { TracingServiceSpan } from '@backstage/backend-plugin-api/alpha';
|
||||
import { TracingServiceSpanStatus } from '@backstage/backend-plugin-api/alpha';
|
||||
|
||||
// @alpha (undocumented)
|
||||
export function actionsRegistryServiceMock(options?: {
|
||||
@@ -81,6 +87,36 @@ export class MockActionsRegistry
|
||||
>(options: ActionsRegistryActionOptions<TInputSchema, TOutputSchema>): void;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServiceContextAPI
|
||||
extends TracingServiceContextAPI {
|
||||
// (undocumented)
|
||||
active: jest.MockedFunction<TracingServiceContextAPI['active']>;
|
||||
// (undocumented)
|
||||
with: jest.MockedFunction<TracingServiceContextAPI['with']>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServicePropagationAPI
|
||||
extends TracingServicePropagationAPI {
|
||||
// (undocumented)
|
||||
extract: jest.MockedFunction<TracingServicePropagationAPI['extract']>;
|
||||
// (undocumented)
|
||||
getActiveBaggage: jest.MockedFunction<
|
||||
TracingServicePropagationAPI['getActiveBaggage']
|
||||
>;
|
||||
// (undocumented)
|
||||
getBaggage: jest.MockedFunction<TracingServicePropagationAPI['getBaggage']>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServiceSpan extends TracingServiceSpan {
|
||||
// (undocumented)
|
||||
setAttribute: jest.Mock<void, [string, TracingServiceAttributeValue]>;
|
||||
// (undocumented)
|
||||
setStatus: jest.Mock<void, [TracingServiceSpanStatus]>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ServiceMock<TService> = {
|
||||
factory: ServiceFactory<TService>;
|
||||
@@ -92,5 +128,24 @@ export type ServiceMock<TService> = {
|
||||
: TService[Key];
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceMock extends TracingService {
|
||||
// (undocumented)
|
||||
context: MockedTracingServiceContextAPI;
|
||||
// (undocumented)
|
||||
factory: ServiceFactory<TracingService>;
|
||||
// (undocumented)
|
||||
propagation: MockedTracingServicePropagationAPI;
|
||||
spans: MockedTracingServiceSpan[];
|
||||
// (undocumented)
|
||||
startActiveSpan: jest.MockedFunction<TracingService['startActiveSpan']>;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export namespace tracingServiceMock {
|
||||
const factory: () => ServiceFactory<TracingService, 'plugin', 'singleton'>;
|
||||
const mock: () => TracingServiceMock;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2026 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 { tracingServiceMock } from './TracingServiceMock';
|
||||
|
||||
describe('tracingServiceMock', () => {
|
||||
it('parses the baggage header via propagation.extract and exposes it via getActiveBaggage inside context.with', async () => {
|
||||
const tracing = tracingServiceMock.mock();
|
||||
|
||||
const ctx = tracing.propagation.extract(tracing.context.active(), {
|
||||
baggage:
|
||||
'gen_ai.conversation.id=conv-123, gen_ai.agent.id=agent-456;property=ignored',
|
||||
});
|
||||
|
||||
// Baggage is reachable directly off the extracted handle.
|
||||
expect(tracing.propagation.getBaggage(ctx)?.getAllEntries()).toEqual([
|
||||
['gen_ai.conversation.id', { value: 'conv-123' }],
|
||||
['gen_ai.agent.id', { value: 'agent-456' }],
|
||||
]);
|
||||
|
||||
const seen = await tracing.context.with(ctx, () =>
|
||||
tracing.propagation
|
||||
.getActiveBaggage()
|
||||
?.getAllEntries()
|
||||
.map(([k, v]) => [k, v.value]),
|
||||
);
|
||||
|
||||
expect(seen).toEqual([
|
||||
['gen_ai.conversation.id', 'conv-123'],
|
||||
['gen_ai.agent.id', 'agent-456'],
|
||||
]);
|
||||
|
||||
// Baggage is scoped to the context.with callback.
|
||||
expect(tracing.propagation.getActiveBaggage()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honours mockReturnValue overrides for getActiveBaggage', async () => {
|
||||
const tracing = tracingServiceMock.mock();
|
||||
const override = {
|
||||
getAllEntries: () =>
|
||||
[['gen_ai.conversation.id', { value: 'override' }]] as Array<
|
||||
[string, { value: string }]
|
||||
>,
|
||||
};
|
||||
tracing.propagation.getActiveBaggage.mockReturnValue(override);
|
||||
|
||||
expect(tracing.propagation.getActiveBaggage()).toBe(override);
|
||||
const ctx = tracing.propagation.extract(tracing.context.active(), {
|
||||
baggage: 'gen_ai.conversation.id=conv-from-header',
|
||||
});
|
||||
await tracing.context.with(ctx, () => {
|
||||
// mockReturnValue takes precedence over the default header parsing.
|
||||
expect(tracing.propagation.getActiveBaggage()).toBe(override);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined baggage when no baggage header is supplied to extract', async () => {
|
||||
const tracing = tracingServiceMock.mock();
|
||||
const ctx = tracing.propagation.extract(tracing.context.active(), {
|
||||
traceparent: 'whatever',
|
||||
});
|
||||
await tracing.context.with(ctx, () => {
|
||||
expect(tracing.propagation.getActiveBaggage()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Copyright 2026 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,
|
||||
ServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
TracingService,
|
||||
TracingServiceAttributeValue,
|
||||
TracingServiceBaggage,
|
||||
TracingServiceContext,
|
||||
TracingServiceContextAPI,
|
||||
TracingServicePropagationAPI,
|
||||
TracingServiceSpan,
|
||||
TracingServiceSpanStatus,
|
||||
tracingServiceRef,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
import { tracingServiceFactory } from '@backstage/backend-defaults/alpha';
|
||||
|
||||
// Internal context shape used by the mock. The opaque `TracingServiceContext`
|
||||
// is just this object cast to the public type.
|
||||
interface MockContext {
|
||||
baggage?: TracingServiceBaggage;
|
||||
}
|
||||
|
||||
function toMockContext(ctx: TracingServiceContext): MockContext {
|
||||
return ctx as unknown as MockContext;
|
||||
}
|
||||
function fromMockContext(ctx: MockContext): TracingServiceContext {
|
||||
return ctx as unknown as TracingServiceContext;
|
||||
}
|
||||
|
||||
// Parses the `baggage` header per the W3C Baggage member syntax,
|
||||
// dropping value properties (`;property=value`). This mirrors what
|
||||
// `propagation.extract` does in the real tracing service, just enough
|
||||
// for tests to assert end-to-end behaviour between propagated headers
|
||||
// and `getActiveBaggage()`.
|
||||
function parseBaggageHeader(
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): TracingServiceBaggage | undefined {
|
||||
let raw: string | undefined;
|
||||
for (const [name, value] of Object.entries(carrier)) {
|
||||
if (name.toLowerCase() !== 'baggage') continue;
|
||||
raw = Array.isArray(value) ? value[0] : value;
|
||||
break;
|
||||
}
|
||||
if (!raw) return undefined;
|
||||
|
||||
const entries = new Map<string, { value: string }>();
|
||||
for (const segment of raw.split(',')) {
|
||||
const [pair] = segment.split(';');
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = decodeURIComponent(pair.slice(0, eqIdx).trim());
|
||||
const value = decodeURIComponent(pair.slice(eqIdx + 1).trim());
|
||||
if (!key) continue;
|
||||
entries.set(key, { value });
|
||||
}
|
||||
if (entries.size === 0) return undefined;
|
||||
|
||||
return {
|
||||
getAllEntries: () => Array.from(entries.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A jest-mocked span captured by {@link TracingServiceMock}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface MockedTracingServiceSpan extends TracingServiceSpan {
|
||||
setAttribute: jest.Mock<void, [string, TracingServiceAttributeValue]>;
|
||||
setStatus: jest.Mock<void, [TracingServiceSpanStatus]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jest-mocked counterpart of the `context` member on the
|
||||
* `TracingService`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface MockedTracingServiceContextAPI
|
||||
extends TracingServiceContextAPI {
|
||||
active: jest.MockedFunction<TracingServiceContextAPI['active']>;
|
||||
with: jest.MockedFunction<TracingServiceContextAPI['with']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jest-mocked counterpart of the `propagation` member on the
|
||||
* `TracingService`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface MockedTracingServicePropagationAPI
|
||||
extends TracingServicePropagationAPI {
|
||||
extract: jest.MockedFunction<TracingServicePropagationAPI['extract']>;
|
||||
getBaggage: jest.MockedFunction<TracingServicePropagationAPI['getBaggage']>;
|
||||
getActiveBaggage: jest.MockedFunction<
|
||||
TracingServicePropagationAPI['getActiveBaggage']
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock for the `TracingService`. Captures every span created via
|
||||
* `startActiveSpan` so tests can assert on the options passed in and the
|
||||
* methods called on the span inside the callback.
|
||||
*
|
||||
* By default, `propagation.extract` parses the `baggage` header (W3C
|
||||
* Baggage syntax) out of the supplied carrier and stashes the entries
|
||||
* on the returned context handle. `context.with` activates that handle
|
||||
* for the duration of the wrapped callback so
|
||||
* `propagation.getActiveBaggage` (and `propagation.getBaggage` on the
|
||||
* supplied handle) returns those entries. Other propagation fields
|
||||
* (e.g. `traceparent`) are ignored. Tests that need fully custom
|
||||
* baggage can still override `propagation.getActiveBaggage` via
|
||||
* `mockReturnValue` / `mockImplementation`, which takes precedence over
|
||||
* the default behaviour.
|
||||
*
|
||||
* Unlike the real `DefaultTracingService`, the mock's `startActiveSpan`
|
||||
* does **not** resolve `options.credentials` from `options.request` via
|
||||
* `httpAuth`. Tests that need principal-derived span attributes should
|
||||
* supply `options.credentials` directly on the span options, or assert
|
||||
* on the raw `options` captured by `startActiveSpan.mock.calls`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface TracingServiceMock extends TracingService {
|
||||
startActiveSpan: jest.MockedFunction<TracingService['startActiveSpan']>;
|
||||
context: MockedTracingServiceContextAPI;
|
||||
propagation: MockedTracingServicePropagationAPI;
|
||||
/** Spans created by `startActiveSpan` calls, in order. */
|
||||
spans: MockedTracingServiceSpan[];
|
||||
factory: ServiceFactory<TracingService>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export namespace tracingServiceMock {
|
||||
/**
|
||||
* Returns the real `tracingServiceFactory` from `@backstage/backend-defaults`,
|
||||
* for tests that want the full default implementation.
|
||||
*/
|
||||
export const factory = () => tracingServiceFactory;
|
||||
|
||||
/**
|
||||
* Builds a mock `TracingService` backed by jest mocks.
|
||||
*/
|
||||
export const mock = (): TracingServiceMock => {
|
||||
const spans: MockedTracingServiceSpan[] = [];
|
||||
const startActiveSpan = jest.fn(
|
||||
async (
|
||||
_name: string,
|
||||
optionsOrFn: unknown,
|
||||
maybeFn?: (span: MockedTracingServiceSpan) => unknown,
|
||||
) => {
|
||||
const fn = (
|
||||
typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn
|
||||
) as (span: MockedTracingServiceSpan) => unknown;
|
||||
const span: MockedTracingServiceSpan = {
|
||||
setAttribute: jest.fn(),
|
||||
setStatus: jest.fn(),
|
||||
};
|
||||
spans.push(span);
|
||||
return await fn(span);
|
||||
},
|
||||
) as unknown as TracingServiceMock['startActiveSpan'];
|
||||
|
||||
const contextStack: MockContext[] = [{}];
|
||||
|
||||
const active = jest.fn(() =>
|
||||
fromMockContext(contextStack[contextStack.length - 1]),
|
||||
) as MockedTracingServiceContextAPI['active'];
|
||||
|
||||
const withFn = jest.fn(async (ctx, fn) => {
|
||||
contextStack.push(toMockContext(ctx));
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
contextStack.pop();
|
||||
}
|
||||
}) as MockedTracingServiceContextAPI['with'];
|
||||
|
||||
const extract = jest.fn((ctx, carrier) => {
|
||||
const baggage = parseBaggageHeader(carrier);
|
||||
// Carry forward the parsed baggage; preserve any baggage already on the
|
||||
// supplied handle if the carrier doesn't include one.
|
||||
const base = toMockContext(ctx);
|
||||
return fromMockContext({ baggage: baggage ?? base.baggage });
|
||||
}) as MockedTracingServicePropagationAPI['extract'];
|
||||
|
||||
const getBaggage = jest.fn(
|
||||
ctx => toMockContext(ctx).baggage,
|
||||
) as MockedTracingServicePropagationAPI['getBaggage'];
|
||||
|
||||
const getActiveBaggage = jest.fn(
|
||||
() => contextStack[contextStack.length - 1].baggage,
|
||||
) as MockedTracingServicePropagationAPI['getActiveBaggage'];
|
||||
|
||||
const context: MockedTracingServiceContextAPI = {
|
||||
active,
|
||||
with: withFn,
|
||||
};
|
||||
const propagation: MockedTracingServicePropagationAPI = {
|
||||
extract,
|
||||
getBaggage,
|
||||
getActiveBaggage,
|
||||
};
|
||||
|
||||
const service: TracingService = {
|
||||
startActiveSpan,
|
||||
context,
|
||||
propagation,
|
||||
};
|
||||
|
||||
return Object.assign(service as TracingServiceMock, {
|
||||
context,
|
||||
propagation,
|
||||
spans,
|
||||
factory: createServiceFactory({
|
||||
service: tracingServiceRef,
|
||||
deps: {},
|
||||
factory: () => service,
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -18,4 +18,11 @@ export { actionsRegistryServiceMock } from './ActionsRegistryServiceMock';
|
||||
export { MockActionsRegistry } from './MockActionsRegistry';
|
||||
export { actionsServiceMock } from './ActionsServiceMock';
|
||||
export { metricsServiceMock } from './MetricsServiceMock';
|
||||
export {
|
||||
tracingServiceMock,
|
||||
type TracingServiceMock,
|
||||
type MockedTracingServiceSpan,
|
||||
type MockedTracingServiceContextAPI,
|
||||
type MockedTracingServicePropagationAPI,
|
||||
} from './TracingServiceMock';
|
||||
export { type ServiceMock } from './alphaCreateServiceMock';
|
||||
|
||||
@@ -21,10 +21,10 @@ const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TestCaches', () => {
|
||||
const caches = TestCaches.create();
|
||||
const caches = TestCaches.create();
|
||||
|
||||
it.each(caches.eachSupportedId())('fires up a cache, %p', async cacheId => {
|
||||
describe.each(caches.eachSupportedId())('TestCaches, %p', cacheId => {
|
||||
it('fires up a cache', async () => {
|
||||
const { keyv } = await caches.init(cacheId);
|
||||
await keyv.set('test', 'value');
|
||||
await expect(keyv.get('test')).resolves.toBe('value');
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import Keyv, { type KeyvStoreAdapter } from 'keyv';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { waitForReady } from '../util/waitForReady';
|
||||
import { Instance } from './types';
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { startMemcachedContainer } from './memcache';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { startRedisContainer } from './redis';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { startValkeyContainer } from './valkey';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
@@ -18,24 +18,14 @@ import { TestDatabases } from './TestDatabases';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TestDatabases', () => {
|
||||
describe('each create', () => {
|
||||
const dbs = TestDatabases.create();
|
||||
const dbs = TestDatabases.create();
|
||||
|
||||
it.each(dbs.eachSupportedId())(
|
||||
'creates distinct %p databases',
|
||||
async databaseId => {
|
||||
if (!dbs.supports(databaseId)) {
|
||||
return;
|
||||
}
|
||||
const db1 = await dbs.init(databaseId);
|
||||
const db2 = await dbs.init(databaseId);
|
||||
await db1.schema.createTable('a', table => table.string('x').primary());
|
||||
await db2.schema.createTable('a', table => table.string('y').primary());
|
||||
await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([
|
||||
{ a: 1 },
|
||||
]);
|
||||
},
|
||||
);
|
||||
describe.each(dbs.eachSupportedId())('TestDatabases, %p', databaseId => {
|
||||
it('creates distinct databases', async () => {
|
||||
const db1 = await dbs.init(databaseId);
|
||||
const db2 = await dbs.init(databaseId);
|
||||
await db1.schema.createTable('a', table => table.string('x').primary());
|
||||
await db2.schema.createTable('a', table => table.string('y').primary());
|
||||
await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([{ a: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import knexFactory, { Knex } from 'knex';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import yn from 'yn';
|
||||
import { waitForReady } from '../util/waitForReady';
|
||||
import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import knexFactory, { Knex } from 'knex';
|
||||
import { parse as parsePgConnectionString } from 'pg-connection-string';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { waitForReady } from '../util/waitForReady';
|
||||
import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types';
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('mockCredentials', () => {
|
||||
it('creates a mocked credentials object for a none principal', () => {
|
||||
expect(mockCredentials.none()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'none' },
|
||||
});
|
||||
});
|
||||
@@ -27,11 +28,13 @@ describe('mockCredentials', () => {
|
||||
it('creates a mocked credentials object for a user principal', () => {
|
||||
expect(mockCredentials.user()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(mockCredentials.user('user:default/other')).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/other' },
|
||||
});
|
||||
});
|
||||
@@ -39,11 +42,13 @@ describe('mockCredentials', () => {
|
||||
it('creates a mocked credentials object for a limited user principal', () => {
|
||||
expect(mockCredentials.limitedUser()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(mockCredentials.limitedUser('user:default/other')).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/other' },
|
||||
});
|
||||
});
|
||||
@@ -51,11 +56,13 @@ describe('mockCredentials', () => {
|
||||
it('creates a mocked credentials object for a service principal', () => {
|
||||
expect(mockCredentials.service()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'service', subject: 'external:test-service' },
|
||||
});
|
||||
|
||||
expect(mockCredentials.service('plugin:other')).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'service', subject: 'plugin:other' },
|
||||
});
|
||||
});
|
||||
@@ -138,6 +145,7 @@ describe('mockCredentials', () => {
|
||||
mockCredentials.service('test', { permissionNames: ['do.it'] }),
|
||||
).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: 'test',
|
||||
|
||||
@@ -78,6 +78,7 @@ export namespace mockCredentials {
|
||||
export function none(): BackstageCredentials<BackstageNonePrincipal> {
|
||||
const result = {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: { type: 'none' },
|
||||
} as const;
|
||||
Object.defineProperties(result, {
|
||||
@@ -122,6 +123,7 @@ export namespace mockCredentials {
|
||||
validateUserEntityRef(userEntityRef);
|
||||
const result = {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: {
|
||||
type: 'user',
|
||||
userEntityRef,
|
||||
@@ -142,7 +144,10 @@ export namespace mockCredentials {
|
||||
token: {
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
value: user.token(),
|
||||
value:
|
||||
userEntityRef !== DEFAULT_MOCK_USER_ENTITY_REF || options?.actor
|
||||
? user.token(userEntityRef, options)
|
||||
: user.token(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
@@ -250,6 +255,7 @@ export namespace mockCredentials {
|
||||
): BackstageCredentials<BackstageServicePrincipal> {
|
||||
const result = {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject,
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
actionsRegistryServiceMock,
|
||||
actionsServiceMock,
|
||||
metricsServiceMock,
|
||||
tracingServiceMock,
|
||||
} from '../alpha/services';
|
||||
|
||||
/** @public */
|
||||
@@ -94,6 +95,7 @@ export const defaultServiceFactories = [
|
||||
actionsRegistryServiceMock.factory(),
|
||||
actionsServiceMock.factory(),
|
||||
metricsServiceMock.factory(),
|
||||
tracingServiceMock.factory(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,92 @@
|
||||
# example-backend
|
||||
|
||||
## 0.0.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-catalog-backend-module-ai-model@0.1.0
|
||||
- @backstage/catalog-model@1.9.0
|
||||
- @backstage/plugin-scaffolder-backend-module-github@0.9.9
|
||||
- @backstage/backend-plugin-api@1.9.1
|
||||
- @backstage/backend-defaults@0.17.1
|
||||
- @backstage/plugin-scaffolder-backend@4.0.0
|
||||
- @backstage/plugin-catalog-backend@3.7.0
|
||||
- @backstage/plugin-kubernetes-backend@0.21.4
|
||||
- @backstage/plugin-search-backend@2.1.2
|
||||
- @backstage/plugin-auth-backend@0.29.0
|
||||
- @backstage/plugin-techdocs-backend@2.2.0
|
||||
- @backstage/plugin-mcp-actions-backend@0.1.13
|
||||
- @backstage/plugin-permission-backend@0.7.12
|
||||
- @backstage/plugin-permission-node@0.11.0
|
||||
- @backstage/plugin-app-backend@0.5.14
|
||||
- @backstage/plugin-auth-node@0.7.1
|
||||
- @backstage/plugin-devtools-backend@0.5.17
|
||||
- @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.14
|
||||
- @backstage/plugin-notifications-backend@0.6.5
|
||||
- @backstage/plugin-permission-common@0.9.9
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@1.8.3
|
||||
- @backstage/plugin-search-backend-node@1.4.4
|
||||
- @backstage/plugin-signals-backend@0.3.15
|
||||
- @backstage/plugin-catalog-backend-module-unprocessed@0.6.12
|
||||
- @backstage/plugin-auth-backend-module-github-provider@0.5.3
|
||||
- @backstage/plugin-auth-backend-module-openshift-provider@0.1.7
|
||||
- @backstage/plugin-permission-backend-module-allow-all-policy@0.2.19
|
||||
- @backstage/plugin-auth-backend-module-guest-provider@0.2.19
|
||||
- @backstage/plugin-catalog-backend-module-logs@0.1.22
|
||||
- @backstage/plugin-catalog-backend-module-openapi@0.2.22
|
||||
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.20
|
||||
- @backstage/plugin-events-backend@0.6.2
|
||||
- @backstage/plugin-events-backend-module-google-pubsub@0.2.3
|
||||
- @backstage/plugin-proxy-backend@0.6.13
|
||||
- @backstage/plugin-scaffolder-backend-module-notifications@0.1.22
|
||||
- @backstage/plugin-search-backend-module-catalog@0.3.15
|
||||
- @backstage/plugin-search-backend-module-explore@0.3.14
|
||||
- @backstage/plugin-search-backend-module-techdocs@0.4.14
|
||||
|
||||
## 0.0.50-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@1.9.1-next.1
|
||||
- @backstage/backend-defaults@0.17.1-next.2
|
||||
- @backstage/plugin-scaffolder-backend@4.0.0-next.2
|
||||
- @backstage/plugin-catalog-backend@3.7.0-next.2
|
||||
- @backstage/plugin-kubernetes-backend@0.21.4-next.1
|
||||
- @backstage/plugin-auth-backend@0.28.1-next.2
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@1.8.3-next.2
|
||||
- @backstage/plugin-catalog-backend-module-logs@0.1.22-next.1
|
||||
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.20-next.1
|
||||
|
||||
## 0.0.50-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-search-backend@2.1.2-next.1
|
||||
- @backstage/plugin-techdocs-backend@2.2.0-next.1
|
||||
- @backstage/plugin-catalog-backend-module-unprocessed@0.6.12-next.1
|
||||
- @backstage/plugin-search-backend-module-techdocs@0.4.14-next.1
|
||||
|
||||
## 0.0.50-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-scaffolder-backend@3.5.0-next.1
|
||||
- @backstage/catalog-model@1.8.1-next.1
|
||||
- @backstage/backend-defaults@0.17.1-next.1
|
||||
- @backstage/plugin-auth-backend@0.28.1-next.1
|
||||
- @backstage/plugin-auth-node@0.7.1-next.1
|
||||
- @backstage/plugin-catalog-backend@3.6.2-next.1
|
||||
- @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.14-next.1
|
||||
- @backstage/plugin-notifications-backend@0.6.5-next.1
|
||||
- @backstage/plugin-permission-common@0.9.9-next.1
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@1.8.3-next.1
|
||||
- @backstage/plugin-search-backend-node@1.4.4-next.1
|
||||
- @backstage/plugin-signals-backend@0.3.15-next.1
|
||||
|
||||
## 0.0.50-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend",
|
||||
"version": "0.0.50-next.0",
|
||||
"version": "0.0.50",
|
||||
"backstage": {
|
||||
"role": "backend"
|
||||
},
|
||||
@@ -40,6 +40,7 @@
|
||||
"@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend-module-ai-model": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend-module-logs": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend-module-openapi": "workspace:^",
|
||||
|
||||
@@ -46,6 +46,7 @@ backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
|
||||
backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider'));
|
||||
backend.add(import('@backstage/plugin-app-backend'));
|
||||
backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed'));
|
||||
backend.add(import('@backstage/plugin-catalog-backend-module-ai-model'));
|
||||
backend.add(
|
||||
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/catalog-client
|
||||
|
||||
## 1.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.9.0
|
||||
- @backstage/errors@1.3.1
|
||||
- @backstage/filter-predicates@0.1.3
|
||||
|
||||
## 1.15.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/catalog-client",
|
||||
"version": "1.15.1-next.0",
|
||||
"version": "1.15.1",
|
||||
"description": "An isomorphic client for the catalog backend",
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @backstage/catalog-model
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3664148: Introduced the `AiResource` catalog entity kind. Entity types, validators, type guards, and the model layer are exported from `@backstage/catalog-model/alpha`. Install `@backstage/plugin-catalog-backend-module-ai-model` in your backend to register the kind with the catalog.
|
||||
- be71476: Added `spec.type: 'mcp-server'` as a structured subtype of the `API` kind under `v1alpha1`/`v1beta1`. MCP server entities carry a `spec.remotes` list instead of a string `definition`, for representing Model Context Protocol servers in the catalog. See RFC [#32062](https://github.com/backstage/backstage/issues/32062). New public exports: `McpServerApiEntity`, `McpServerRemote`, `mcpServerApiEntityValidator`, and `isMcpServerApiEntity`. Also adds `addKindVersion` to `CatalogModelLayerBuilder` (alpha) so layers can add new versions or spec types to existing kinds.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ab1cdbb: Removed a handful of internal imports that referenced the package by its own name. Value imports were switched to relative paths, and type-only imports to `import type`. These self-referential imports could trigger circular initialization errors in bundled ESM and when the package was loaded via `jest.requireActual` — most visibly `Cannot access '_AppRootElementBlueprintesm' before initialization` from `@backstage/frontend-plugin-api`. There are no user-facing API changes.
|
||||
- Updated dependencies
|
||||
- @backstage/errors@1.3.1
|
||||
|
||||
## 1.8.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ab1cdbb: Removed a handful of internal imports that referenced the package by its own name. Value imports were switched to relative paths, and type-only imports to `import type`. These self-referential imports could trigger circular initialization errors in bundled ESM and when the package was loaded via `jest.requireActual` — most visibly `Cannot access '_AppRootElementBlueprintesm' before initialization` from `@backstage/frontend-plugin-api`. There are no user-facing API changes.
|
||||
|
||||
## 1.8.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: AiResource
|
||||
metadata:
|
||||
name: frontend-design
|
||||
description: Skill for creating production-grade frontend interfaces using the internal design system
|
||||
spec:
|
||||
type: skill
|
||||
lifecycle: production
|
||||
owner: team-a
|
||||
system: artist-engagement-portal
|
||||
disciplines:
|
||||
- web
|
||||
categories:
|
||||
- framework
|
||||
- design-system
|
||||
agents:
|
||||
- claude-code
|
||||
- copilot
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: AiResource
|
||||
metadata:
|
||||
name: use-internal-apis
|
||||
description: Agents must use internal API clients instead of raw HTTP calls
|
||||
spec:
|
||||
type: rule
|
||||
lifecycle: production
|
||||
owner: team-a
|
||||
system: artist-engagement-portal
|
||||
disciplines:
|
||||
- web
|
||||
- backend
|
||||
category: architecture
|
||||
rationale: Ensures consistent error handling, authentication, and observability across all service calls
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Location
|
||||
metadata:
|
||||
name: example-ai-resources
|
||||
description: A collection of all Backstage example AI resources
|
||||
spec:
|
||||
targets:
|
||||
- ./ai-resources/frontend-design-skill.yaml
|
||||
- ./ai-resources/use-internal-apis-rule.yaml
|
||||
@@ -5,6 +5,7 @@ metadata:
|
||||
description: A collection of all Backstage example APIs
|
||||
spec:
|
||||
targets:
|
||||
- ./apis/backstage-mcp-server-api.yaml
|
||||
- ./apis/hello-world-api.yaml
|
||||
- ./apis/hello-world-trpc-api.yaml
|
||||
- ./apis/petstore-api.yaml
|
||||
|
||||
@@ -5,6 +5,7 @@ metadata:
|
||||
description: A collection of all Backstage example entities, except users, groups, and templates
|
||||
spec:
|
||||
targets:
|
||||
- ./all-ai-resources.yaml
|
||||
- ./all-apis.yaml
|
||||
- ./all-components.yaml
|
||||
- ./all-domains.yaml
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: API
|
||||
metadata:
|
||||
name: backstage-mcp-server
|
||||
description: An MCP server that exposes tools related to the Backstage ecosystem
|
||||
tags:
|
||||
- mcp
|
||||
- ai
|
||||
spec:
|
||||
type: mcp-server
|
||||
lifecycle: experimental
|
||||
owner: team-a
|
||||
remotes:
|
||||
- type: streamable-http
|
||||
url: http://localhost:7007/api/mcp/v1
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/catalog-model",
|
||||
"version": "1.8.1-next.0",
|
||||
"version": "1.9.0",
|
||||
"description": "Types and validators that help describe the model of a Backstage Catalog",
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
|
||||
@@ -3,16 +3,61 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import type { Entity as Entity_2 } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { SerializedError } from '@backstage/errors';
|
||||
|
||||
// @alpha
|
||||
export interface AlphaEntity extends Entity {
|
||||
export const aiResourceEntityModel: CatalogModelLayer;
|
||||
|
||||
// @alpha
|
||||
export type AiResourceEntityV1alpha1 =
|
||||
| AiResourceEntityV1alpha1Default
|
||||
| SkillAiResourceEntityV1alpha1
|
||||
| RuleAiResourceEntityV1alpha1;
|
||||
|
||||
// @alpha
|
||||
export interface AiResourceEntityV1alpha1Default extends Entity {
|
||||
// (undocumented)
|
||||
apiVersion: 'backstage.io/v1alpha1';
|
||||
// (undocumented)
|
||||
kind: 'AiResource';
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const aiResourceEntityV1alpha1Validator: KindValidator;
|
||||
|
||||
// @alpha
|
||||
export interface AlphaEntity extends Entity_2 {
|
||||
status?: EntityStatus;
|
||||
}
|
||||
|
||||
// @public
|
||||
interface ApiEntityV1alpha1 extends Entity {
|
||||
// (undocumented)
|
||||
apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
|
||||
// (undocumented)
|
||||
kind: 'API';
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
definition: string;
|
||||
system?: string;
|
||||
};
|
||||
}
|
||||
export { ApiEntityV1alpha1 as ApiEntity };
|
||||
export { ApiEntityV1alpha1 };
|
||||
|
||||
// @alpha
|
||||
export type AsyncCatalogModelSourceGenerator = AsyncGenerator<
|
||||
{
|
||||
@@ -43,6 +88,12 @@ export interface CatalogModel {
|
||||
listRelations(): CatalogModelRelationSummary[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelAddKindVersionDefinition {
|
||||
kind: string;
|
||||
versions: CatalogModelKindVersionDefinition[];
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface CatalogModelAnnotationDefinition {
|
||||
description: string;
|
||||
@@ -202,6 +253,7 @@ export interface CatalogModelLayer {
|
||||
export interface CatalogModelLayerBuilder {
|
||||
addAnnotation(annotation: CatalogModelAnnotationDefinition): void;
|
||||
addKind(kind: CatalogModelKindDefinition): void;
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
|
||||
addLabel(label: CatalogModelLabelDefinition): void;
|
||||
addRelationPair(relation: CatalogModelRelationPairDefinition): void;
|
||||
addTag(tag: CatalogModelTagDefinition): void;
|
||||
@@ -400,6 +452,43 @@ export function createCatalogModelLayerBuilder(options: {
|
||||
// @alpha
|
||||
export const defaultCatalogEntityModel: CatalogModelLayer;
|
||||
|
||||
// @public
|
||||
export type Entity = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: EntityMeta;
|
||||
spec?: JsonObject;
|
||||
relations?: EntityRelation[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type EntityLink = {
|
||||
url: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type EntityMeta = JsonObject & {
|
||||
uid?: string;
|
||||
etag?: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
tags?: string[];
|
||||
links?: EntityLink[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type EntityRelation = {
|
||||
type: string;
|
||||
targetRef: string;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export type EntityStatus = {
|
||||
items?: EntityStatusItem[];
|
||||
@@ -416,5 +505,91 @@ export type EntityStatusItem = {
|
||||
// @alpha
|
||||
export type EntityStatusLevel = 'info' | 'warning' | 'error';
|
||||
|
||||
// @alpha
|
||||
export const isAiResourceEntity: (
|
||||
entity: Entity,
|
||||
) => entity is AiResourceEntityV1alpha1;
|
||||
|
||||
// @alpha
|
||||
export function isMcpServerApiEntity(
|
||||
entity: ApiEntityV1alpha1 | McpServerApiEntity,
|
||||
): entity is McpServerApiEntity;
|
||||
|
||||
// @alpha
|
||||
export const isRuleAiResourceEntity: (
|
||||
entity: Entity,
|
||||
) => entity is RuleAiResourceEntityV1alpha1;
|
||||
|
||||
// @alpha
|
||||
export const isSkillAiResourceEntity: (
|
||||
entity: Entity,
|
||||
) => entity is SkillAiResourceEntityV1alpha1;
|
||||
|
||||
// @public
|
||||
export type KindValidator = {
|
||||
check(entity: Entity): Promise<boolean>;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface McpServerApiEntity extends Omit<ApiEntityV1alpha1, 'spec'> {
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: 'mcp-server';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
remotes: McpServerRemote[];
|
||||
};
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const mcpServerApiEntityModel: CatalogModelLayer;
|
||||
|
||||
// @alpha
|
||||
export const mcpServerApiEntityValidator: KindValidator;
|
||||
|
||||
// @alpha
|
||||
export type McpServerRemote = {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export interface RuleAiResourceEntityV1alpha1
|
||||
extends AiResourceEntityV1alpha1Default {
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: 'rule';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
disciplines?: string[];
|
||||
category: string;
|
||||
rationale: string;
|
||||
};
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const ruleAiResourceEntityV1alpha1Validator: KindValidator;
|
||||
|
||||
// @alpha
|
||||
export interface SkillAiResourceEntityV1alpha1
|
||||
extends AiResourceEntityV1alpha1Default {
|
||||
// (undocumented)
|
||||
spec: {
|
||||
type: 'skill';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
disciplines?: string[];
|
||||
categories?: string[];
|
||||
agents?: string[];
|
||||
dependsOn?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const skillAiResourceEntityV1alpha1Validator: KindValidator;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -14,11 +14,46 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type {
|
||||
Entity,
|
||||
EntityLink,
|
||||
EntityMeta,
|
||||
EntityRelation,
|
||||
} from './entity/Entity';
|
||||
export type { KindValidator } from './kinds/types';
|
||||
export type { AlphaEntity } from './entity/AlphaEntity';
|
||||
export type {
|
||||
EntityStatus,
|
||||
EntityStatusItem,
|
||||
EntityStatusLevel,
|
||||
} from './entity/EntityStatus';
|
||||
export type {
|
||||
AiResourceEntityV1alpha1,
|
||||
AiResourceEntityV1alpha1Default,
|
||||
SkillAiResourceEntityV1alpha1,
|
||||
RuleAiResourceEntityV1alpha1,
|
||||
} from './kinds/AiResourceEntityV1alpha1';
|
||||
export {
|
||||
aiResourceEntityV1alpha1Validator,
|
||||
skillAiResourceEntityV1alpha1Validator,
|
||||
ruleAiResourceEntityV1alpha1Validator,
|
||||
isAiResourceEntity,
|
||||
isSkillAiResourceEntity,
|
||||
isRuleAiResourceEntity,
|
||||
aiResourceEntityModel,
|
||||
} from './kinds/AiResourceEntityV1alpha1';
|
||||
export type {
|
||||
ApiEntityV1alpha1 as ApiEntity,
|
||||
ApiEntityV1alpha1,
|
||||
} from './kinds/ApiEntityV1alpha1';
|
||||
export type {
|
||||
McpServerApiEntity,
|
||||
McpServerRemote,
|
||||
} from './kinds/McpServerApiEntity';
|
||||
export {
|
||||
mcpServerApiEntityValidator,
|
||||
isMcpServerApiEntity,
|
||||
mcpServerApiEntityModel,
|
||||
} from './kinds/McpServerApiEntity';
|
||||
export * from './model';
|
||||
export { defaultCatalogEntityModel } from './model/defaultCatalogEntityModel';
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// TODO(Rugvip): Figure out best way to allow this import
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { EntityStatus } from './EntityStatus';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Copyright 2026 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 { Entity } from '../entity/Entity';
|
||||
import {
|
||||
type AiResourceEntityV1alpha1Default,
|
||||
type SkillAiResourceEntityV1alpha1,
|
||||
type RuleAiResourceEntityV1alpha1,
|
||||
aiResourceEntityV1alpha1Validator as defaultValidator,
|
||||
skillAiResourceEntityV1alpha1Validator as skillValidator,
|
||||
ruleAiResourceEntityV1alpha1Validator as ruleValidator,
|
||||
isAiResourceEntity,
|
||||
isSkillAiResourceEntity,
|
||||
isRuleAiResourceEntity,
|
||||
} from './AiResourceEntityV1alpha1';
|
||||
|
||||
describe('AiResourceV1alpha1 default validator', () => {
|
||||
let entity: AiResourceEntityV1alpha1Default;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: {
|
||||
name: 'internal-design-system',
|
||||
},
|
||||
spec: {
|
||||
type: 'rule',
|
||||
lifecycle: 'production',
|
||||
owner: 'frontend-platform',
|
||||
system: 'ai-tooling',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts valid data', async () => {
|
||||
await expect(defaultValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unknown apiVersion', async () => {
|
||||
(entity as any).apiVersion = 'backstage.io/v1beta0';
|
||||
await expect(defaultValidator.check(entity)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unknown kind', async () => {
|
||||
(entity as any).kind = 'Wizard';
|
||||
await expect(defaultValidator.check(entity)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing type', async () => {
|
||||
delete (entity as any).spec.type;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects wrong type', async () => {
|
||||
(entity as any).spec.type = 7;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects empty type', async () => {
|
||||
(entity as any).spec.type = '';
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects missing lifecycle', async () => {
|
||||
delete (entity as any).spec.lifecycle;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/);
|
||||
});
|
||||
|
||||
it('rejects wrong lifecycle', async () => {
|
||||
(entity as any).spec.lifecycle = 7;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/);
|
||||
});
|
||||
|
||||
it('rejects empty lifecycle', async () => {
|
||||
(entity as any).spec.lifecycle = '';
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/lifecycle/);
|
||||
});
|
||||
|
||||
it('rejects missing owner', async () => {
|
||||
delete (entity as any).spec.owner;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('rejects wrong owner', async () => {
|
||||
(entity as any).spec.owner = 7;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('rejects empty owner', async () => {
|
||||
(entity as any).spec.owner = '';
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('accepts missing system', async () => {
|
||||
delete (entity as any).spec.system;
|
||||
await expect(defaultValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong system', async () => {
|
||||
(entity as any).spec.system = 7;
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/system/);
|
||||
});
|
||||
|
||||
it('rejects empty system', async () => {
|
||||
(entity as any).spec.system = '';
|
||||
await expect(defaultValidator.check(entity)).rejects.toThrow(/system/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiResourceV1alpha1 skill validator', () => {
|
||||
let entity: SkillAiResourceEntityV1alpha1;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: {
|
||||
name: 'frontend-design',
|
||||
},
|
||||
spec: {
|
||||
type: 'skill',
|
||||
lifecycle: 'production',
|
||||
owner: 'ai-platform-team',
|
||||
system: 'ai-tooling',
|
||||
disciplines: ['web', 'backend'],
|
||||
categories: ['framework'],
|
||||
agents: ['claude-code'],
|
||||
dependsOn: ['airesource:default/base-coding-standards'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts valid skill data with all fields', async () => {
|
||||
await expect(skillValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts skill with only required fields', async () => {
|
||||
entity.spec = {
|
||||
type: 'skill',
|
||||
lifecycle: 'experimental',
|
||||
owner: 'team-a',
|
||||
};
|
||||
await expect(skillValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-skill type', async () => {
|
||||
(entity as any).spec.type = 'rule';
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects missing lifecycle', async () => {
|
||||
delete (entity as any).spec.lifecycle;
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/lifecycle/);
|
||||
});
|
||||
|
||||
it('rejects missing owner', async () => {
|
||||
delete (entity as any).spec.owner;
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('accepts missing optional fields', async () => {
|
||||
delete (entity as any).spec.system;
|
||||
delete (entity as any).spec.disciplines;
|
||||
delete (entity as any).spec.categories;
|
||||
delete (entity as any).spec.agents;
|
||||
delete (entity as any).spec.dependsOn;
|
||||
await expect(skillValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects disciplines with empty strings', async () => {
|
||||
(entity as any).spec.disciplines = [''];
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/disciplines/);
|
||||
});
|
||||
|
||||
it('rejects categories with wrong type', async () => {
|
||||
(entity as any).spec.categories = 'not-an-array';
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/categories/);
|
||||
});
|
||||
|
||||
it('rejects agents with wrong item type', async () => {
|
||||
(entity as any).spec.agents = [42];
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/agents/);
|
||||
});
|
||||
|
||||
it('rejects dependsOn with empty strings', async () => {
|
||||
(entity as any).spec.dependsOn = [''];
|
||||
await expect(skillValidator.check(entity)).rejects.toThrow(/dependsOn/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiResourceV1alpha1 rule validator', () => {
|
||||
let entity: RuleAiResourceEntityV1alpha1;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: {
|
||||
name: 'use-internal-apis',
|
||||
},
|
||||
spec: {
|
||||
type: 'rule',
|
||||
lifecycle: 'production',
|
||||
owner: 'frontend-platform',
|
||||
disciplines: ['web', 'backend'],
|
||||
category: 'architecture',
|
||||
rationale: 'Ensures consistent error handling across all service calls',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts valid rule data with all fields', async () => {
|
||||
await expect(ruleValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts rule with only required fields', async () => {
|
||||
entity.spec = {
|
||||
type: 'rule',
|
||||
lifecycle: 'production',
|
||||
owner: 'team-a',
|
||||
category: 'security',
|
||||
rationale: 'Prevents credential leaks',
|
||||
};
|
||||
await expect(ruleValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-rule type', async () => {
|
||||
(entity as any).spec.type = 'skill';
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects missing category', async () => {
|
||||
delete (entity as any).spec.category;
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/category/);
|
||||
});
|
||||
|
||||
it('rejects empty category', async () => {
|
||||
(entity as any).spec.category = '';
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/category/);
|
||||
});
|
||||
|
||||
it('rejects missing rationale', async () => {
|
||||
delete (entity as any).spec.rationale;
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/rationale/);
|
||||
});
|
||||
|
||||
it('rejects empty rationale', async () => {
|
||||
(entity as any).spec.rationale = '';
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/rationale/);
|
||||
});
|
||||
|
||||
it('accepts missing optional fields', async () => {
|
||||
delete (entity as any).spec.system;
|
||||
delete (entity as any).spec.disciplines;
|
||||
await expect(ruleValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects disciplines with empty strings', async () => {
|
||||
(entity as any).spec.disciplines = [''];
|
||||
await expect(ruleValidator.check(entity)).rejects.toThrow(/disciplines/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAiResourceEntity', () => {
|
||||
it('returns true when apiVersion and kind match', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
};
|
||||
expect(isAiResourceEntity(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a different kind', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
};
|
||||
expect(isAiResourceEntity(entity)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a different apiVersion', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
};
|
||||
expect(isAiResourceEntity(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSkillAiResourceEntity', () => {
|
||||
it('returns true for a skill AiResource', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
spec: { type: 'skill' },
|
||||
};
|
||||
expect(isSkillAiResourceEntity(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-skill AiResource', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
spec: { type: 'rule' },
|
||||
};
|
||||
expect(isSkillAiResourceEntity(entity)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a different kind', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
spec: { type: 'skill' },
|
||||
};
|
||||
expect(isSkillAiResourceEntity(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRuleAiResourceEntity', () => {
|
||||
it('returns true for a rule AiResource', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
spec: { type: 'rule' },
|
||||
};
|
||||
expect(isRuleAiResourceEntity(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-rule AiResource', () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'AiResource',
|
||||
metadata: { name: 'test' },
|
||||
spec: { type: 'skill' },
|
||||
};
|
||||
expect(isRuleAiResourceEntity(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright 2026 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 { createCatalogModelLayer } from '../model/createCatalogModelLayer';
|
||||
import type { Entity } from '../entity/Entity';
|
||||
import { entityKindSchemaValidator } from '../validation';
|
||||
import type { KindValidator } from './types';
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import defaultJsonSchema from '../schema/kinds/AiResource.v1alpha1.schema.json';
|
||||
import skillJsonSchema from '../schema/kinds/AiResource.v1alpha1.skill.schema.json';
|
||||
import ruleJsonSchema from '../schema/kinds/AiResource.v1alpha1.rule.schema.json';
|
||||
|
||||
/**
|
||||
* Default AiResource entity for types that don't have a structured spec.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The actual content of skills and rules is not stored in the entity spec.
|
||||
* Instead, the source file is referenced via the standard
|
||||
* `backstage.io/source-location` annotation, consistent with how other
|
||||
* Backstage entities reference their source files.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface AiResourceEntityV1alpha1Default extends Entity {
|
||||
apiVersion: 'backstage.io/v1alpha1';
|
||||
kind: 'AiResource';
|
||||
spec: {
|
||||
type: string;
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AiResource entity with spec.type 'skill'. Represents reusable contextual
|
||||
* knowledge consumed by AI coding tools.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface SkillAiResourceEntityV1alpha1
|
||||
extends AiResourceEntityV1alpha1Default {
|
||||
spec: {
|
||||
type: 'skill';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
disciplines?: string[];
|
||||
categories?: string[];
|
||||
agents?: string[];
|
||||
dependsOn?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AiResource entity with spec.type 'rule'. Represents a governance rule
|
||||
* or constraint for AI coding tools.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface RuleAiResourceEntityV1alpha1
|
||||
extends AiResourceEntityV1alpha1Default {
|
||||
spec: {
|
||||
type: 'rule';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
disciplines?: string[];
|
||||
category: string;
|
||||
rationale: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstage catalog AiResource kind Entity. Represents contextual information
|
||||
* consumed by AI coding tools, such as skills and rules.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type AiResourceEntityV1alpha1 =
|
||||
| AiResourceEntityV1alpha1Default
|
||||
| SkillAiResourceEntityV1alpha1
|
||||
| RuleAiResourceEntityV1alpha1;
|
||||
|
||||
const defaultValidator = entityKindSchemaValidator(defaultJsonSchema);
|
||||
|
||||
/**
|
||||
* Entity data validator for the default {@link AiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const aiResourceEntityV1alpha1Validator: KindValidator = {
|
||||
async check(data: Entity) {
|
||||
return defaultValidator(data) === data;
|
||||
},
|
||||
};
|
||||
|
||||
const skillValidator = entityKindSchemaValidator(skillJsonSchema);
|
||||
|
||||
/**
|
||||
* Entity data validator for {@link SkillAiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const skillAiResourceEntityV1alpha1Validator: KindValidator = {
|
||||
async check(data: Entity) {
|
||||
return skillValidator(data) === data;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard for {@link AiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const isAiResourceEntity = (
|
||||
entity: Entity,
|
||||
): entity is AiResourceEntityV1alpha1 =>
|
||||
entity.apiVersion === 'backstage.io/v1alpha1' && entity.kind === 'AiResource';
|
||||
|
||||
/**
|
||||
* Type guard for {@link SkillAiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const isSkillAiResourceEntity = (
|
||||
entity: Entity,
|
||||
): entity is SkillAiResourceEntityV1alpha1 =>
|
||||
isAiResourceEntity(entity) && entity.spec?.type === 'skill';
|
||||
|
||||
/**
|
||||
* Type guard for {@link RuleAiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const isRuleAiResourceEntity = (
|
||||
entity: Entity,
|
||||
): entity is RuleAiResourceEntityV1alpha1 =>
|
||||
isAiResourceEntity(entity) && entity.spec?.type === 'rule';
|
||||
|
||||
const ruleValidator = entityKindSchemaValidator(ruleJsonSchema);
|
||||
|
||||
/**
|
||||
* Entity data validator for {@link RuleAiResourceEntityV1alpha1}.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const ruleAiResourceEntityV1alpha1Validator: KindValidator = {
|
||||
async check(data: Entity) {
|
||||
return ruleValidator(data) === data;
|
||||
},
|
||||
};
|
||||
|
||||
const baseRelationFields = [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit' as const,
|
||||
allowedKinds: ['Group', 'User'],
|
||||
},
|
||||
{
|
||||
selector: { path: 'spec.system' },
|
||||
relation: 'partOf',
|
||||
defaultKind: 'System',
|
||||
defaultNamespace: 'inherit' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Extends the catalog model with the AiResource kind.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const aiResourceEntityModel = createCatalogModelLayer({
|
||||
layerId: 'catalog.backstage.io/kind-ai-resource',
|
||||
builder: model => {
|
||||
model.addKind({
|
||||
group: 'backstage.io',
|
||||
names: {
|
||||
kind: 'AiResource',
|
||||
singular: 'airesource',
|
||||
plural: 'airesources',
|
||||
},
|
||||
description:
|
||||
'An AI resource represents contextual information consumed by AI coding tools, such as skills and rules.',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
relationFields: baseRelationFields,
|
||||
schema: {
|
||||
jsonSchema: defaultJsonSchema as JsonObject,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
specType: 'skill',
|
||||
relationFields: [
|
||||
...baseRelationFields,
|
||||
{
|
||||
selector: { path: 'spec.dependsOn' },
|
||||
relation: 'dependsOn',
|
||||
defaultKind: 'AiResource',
|
||||
defaultNamespace: 'inherit' as const,
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema: skillJsonSchema as JsonObject,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
specType: 'rule',
|
||||
relationFields: baseRelationFields,
|
||||
schema: {
|
||||
jsonSchema: ruleJsonSchema as JsonObject,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -83,9 +83,7 @@ export const apiEntityModel = createCatalogModelLayer({
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema,
|
||||
},
|
||||
schema: { jsonSchema },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2026 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 { ApiEntityV1alpha1 } from './ApiEntityV1alpha1';
|
||||
import {
|
||||
McpServerApiEntity,
|
||||
mcpServerApiEntityValidator,
|
||||
isMcpServerApiEntity,
|
||||
} from './McpServerApiEntity';
|
||||
|
||||
describe('mcpServerApiEntityValidator', () => {
|
||||
let entity: McpServerApiEntity;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'test-mcp' },
|
||||
spec: {
|
||||
type: 'mcp-server',
|
||||
lifecycle: 'experimental',
|
||||
owner: 'backstage',
|
||||
remotes: [
|
||||
{
|
||||
type: 'streamable-http',
|
||||
url: 'http://localhost:7007/api/mcp',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts a valid mcp-server entity', async () => {
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts v1beta1', async () => {
|
||||
entity.apiVersion = 'backstage.io/v1beta1';
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong spec.type value', async () => {
|
||||
(entity as any).spec.type = 'openapi';
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/type/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing remotes', async () => {
|
||||
delete (entity as any).spec.remotes;
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/remotes/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty remotes array', async () => {
|
||||
(entity as any).spec.remotes = [];
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/remotes/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects remote missing url', async () => {
|
||||
(entity as any).spec.remotes[0] = { type: 'stdio' };
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/url/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects remote missing type', async () => {
|
||||
(entity as any).spec.remotes[0] = { url: 'http://x' };
|
||||
await expect(mcpServerApiEntityValidator.check(entity)).rejects.toThrow(
|
||||
/type/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMcpServerApiEntity', () => {
|
||||
it('returns true for an mcp-server entity', () => {
|
||||
const entity: McpServerApiEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'm' },
|
||||
spec: {
|
||||
type: 'mcp-server',
|
||||
lifecycle: 'production',
|
||||
owner: 'me',
|
||||
remotes: [{ type: 'stdio', url: 'cmd' }],
|
||||
},
|
||||
};
|
||||
expect(isMcpServerApiEntity(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-mcp-server API entity', () => {
|
||||
const entity: ApiEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'a' },
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
owner: 'me',
|
||||
definition: 'x',
|
||||
},
|
||||
};
|
||||
expect(isMcpServerApiEntity(entity)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2026 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 { createCatalogModelLayer } from '../model/createCatalogModelLayer';
|
||||
import type { ApiEntityV1alpha1 } from './ApiEntityV1alpha1';
|
||||
import mcpServerSchema from '../schema/kinds/API.v1alpha1.mcp-server.schema.json';
|
||||
import { ajvCompiledJsonSchemaValidator } from './util';
|
||||
|
||||
/**
|
||||
* An MCP (Model Context Protocol) server represented as an API entity
|
||||
* (spec.type: 'mcp-server').
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface McpServerApiEntity extends Omit<ApiEntityV1alpha1, 'spec'> {
|
||||
spec: {
|
||||
type: 'mcp-server';
|
||||
lifecycle: string;
|
||||
owner: string;
|
||||
system?: string;
|
||||
remotes: McpServerRemote[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A transport endpoint for an MCP server.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type McpServerRemote = {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* {@link KindValidator} for the `mcp-server` specType of API entities.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const mcpServerApiEntityValidator =
|
||||
ajvCompiledJsonSchemaValidator(mcpServerSchema);
|
||||
|
||||
/**
|
||||
* Type guard: narrows an entity to the MCP server API subtype.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export function isMcpServerApiEntity(
|
||||
entity: ApiEntityV1alpha1 | McpServerApiEntity,
|
||||
): entity is McpServerApiEntity {
|
||||
return entity.spec.type === 'mcp-server';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the API kind with the mcp-server specType.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const mcpServerApiEntityModel = createCatalogModelLayer({
|
||||
layerId: 'catalog.backstage.io/kind-api-mcp-server',
|
||||
builder: model => {
|
||||
model.addKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: ['v1alpha1', 'v1beta1'],
|
||||
specType: 'mcp-server',
|
||||
description:
|
||||
'An MCP (Model Context Protocol) server exposed as an API entity.',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
allowedKinds: ['Group', 'User'],
|
||||
},
|
||||
{
|
||||
selector: { path: 'spec.system' },
|
||||
relation: 'partOf',
|
||||
defaultKind: 'System',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: { jsonSchema: mcpServerSchema },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 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 lodash from 'lodash';
|
||||
import { compileCatalogModel } from '../model/compileCatalogModel';
|
||||
import { defaultCatalogEntityModel } from '../model/defaultCatalogEntityModel';
|
||||
import { mcpServerApiEntityModel } from './McpServerApiEntity';
|
||||
|
||||
describe('apiEntityModel mcp-server dispatch', () => {
|
||||
const model = compileCatalogModel([
|
||||
defaultCatalogEntityModel,
|
||||
mcpServerApiEntityModel,
|
||||
]);
|
||||
|
||||
it('routes mcp-server and non-mcp-server v1alpha1 entities to different schemas', () => {
|
||||
const mcp = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
const openapi = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'openapi' },
|
||||
});
|
||||
|
||||
expect(mcp).toBeDefined();
|
||||
expect(openapi).toBeDefined();
|
||||
expect(mcp!.description).not.toBe(openapi!.description);
|
||||
|
||||
const mcpSpecRequired = lodash.get(
|
||||
mcp,
|
||||
'jsonSchema.properties.spec.required',
|
||||
);
|
||||
const openapiSpecRequired = lodash.get(
|
||||
openapi,
|
||||
'jsonSchema.properties.spec.required',
|
||||
);
|
||||
|
||||
expect(mcpSpecRequired).toContain('remotes');
|
||||
expect(mcpSpecRequired).not.toContain('definition');
|
||||
expect(openapiSpecRequired).toContain('definition');
|
||||
expect(openapiSpecRequired).not.toContain('remotes');
|
||||
});
|
||||
|
||||
it('routes mcp-server under v1beta1 too', () => {
|
||||
const mcp = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
expect(mcp).toBeDefined();
|
||||
const required = lodash.get(mcp, 'jsonSchema.properties.spec.required');
|
||||
expect(required).toContain('remotes');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
type CatalogModelKindDefinition,
|
||||
opsFromCatalogModelKind,
|
||||
} from './modelActions/addKind';
|
||||
import {
|
||||
type CatalogModelAddKindVersionDefinition,
|
||||
opsFromCatalogModelAddKindVersion,
|
||||
} from './modelActions/addKindVersion';
|
||||
import {
|
||||
type CatalogModelLabelDefinition,
|
||||
opsFromCatalogModelLabel,
|
||||
@@ -87,6 +91,10 @@ export interface CatalogModelLayerBuilder {
|
||||
* Adds a new kind to the model.
|
||||
*/
|
||||
addKind(kind: CatalogModelKindDefinition): void;
|
||||
/**
|
||||
* Adds one or more versions to an already-declared kind.
|
||||
*/
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void;
|
||||
/**
|
||||
* Updates an existing kind in the model.
|
||||
*/
|
||||
@@ -169,6 +177,11 @@ export class DefaultCatalogModelLayerBuilder
|
||||
this.#ops.push(...ops);
|
||||
}
|
||||
|
||||
addKindVersion(definition: CatalogModelAddKindVersionDefinition): void {
|
||||
const ops = opsFromCatalogModelAddKindVersion(definition);
|
||||
this.#ops.push(...ops);
|
||||
}
|
||||
|
||||
updateKind(kind: CatalogModelUpdateKindDefinition): void {
|
||||
const ops = opsFromCatalogModelUpdateKind(kind);
|
||||
this.#ops.push(...ops);
|
||||
|
||||
@@ -15,12 +15,9 @@
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { reduceKindSchema } from '../jsonSchema/reduceKindSchema';
|
||||
import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics';
|
||||
import { validateMetaSchema } from '../jsonSchema/validateMetaSchema';
|
||||
import { CatalogModelOp } from '../operations';
|
||||
import { createDeclareKindOp } from '../operations/declareKind';
|
||||
import { createDeclareKindVersionOp } from '../operations/declareKindVersion';
|
||||
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
|
||||
|
||||
/**
|
||||
* The definition of a catalog model kind, roughly resembling a JSON Schema.
|
||||
@@ -168,33 +165,12 @@ export function opsFromCatalogModelKind(
|
||||
}),
|
||||
);
|
||||
|
||||
for (const version of kind.versions ?? []) {
|
||||
const jsonSchema = reduceKindSchema(version.schema.jsonSchema);
|
||||
validateMetaSchema(jsonSchema);
|
||||
validateKindRootSchemaSemantics(jsonSchema);
|
||||
const names = Array.isArray(version.name) ? version.name : [version.name];
|
||||
for (const name of names) {
|
||||
const specTypes = version.specType
|
||||
? [version.specType].flat()
|
||||
: [undefined];
|
||||
for (const specType of specTypes) {
|
||||
ops.push(
|
||||
createDeclareKindVersionOp({
|
||||
kind: kind.names.kind,
|
||||
name,
|
||||
specType: specType,
|
||||
properties: {
|
||||
description: version.description,
|
||||
relationFields: version.relationFields,
|
||||
schema: {
|
||||
jsonSchema: jsonSchema as any,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ops.push(
|
||||
...opsFromCatalogModelAddKindVersion({
|
||||
kind: kind.names.kind,
|
||||
versions: kind.versions ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2026 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 { compileCatalogModel } from '../compileCatalogModel';
|
||||
import { createCatalogModelLayer } from '../createCatalogModelLayer';
|
||||
import { opsFromCatalogModelAddKindVersion } from './addKindVersion';
|
||||
|
||||
describe('opsFromCatalogModelAddKindVersion', () => {
|
||||
it('produces declareKindVersion ops for a single version', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
description: 'A new version',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
op: 'declareKindVersion.v1',
|
||||
kind: 'API',
|
||||
name: 'v1alpha2',
|
||||
specType: undefined,
|
||||
properties: {
|
||||
description: 'A new version',
|
||||
relationFields: [
|
||||
{
|
||||
selector: { path: 'spec.owner' },
|
||||
relation: 'ownedBy',
|
||||
defaultKind: 'Group',
|
||||
defaultNamespace: 'inherit',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces separate ops for each specType', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: { definition: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
specType: 'mcp-server',
|
||||
description: 'An MCP server',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
properties: { remotes: { type: 'array' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops).toHaveLength(2);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'declareKindVersion.v1',
|
||||
name: 'v1alpha2',
|
||||
specType: undefined,
|
||||
});
|
||||
expect(ops[1]).toMatchObject({
|
||||
op: 'declareKindVersion.v1',
|
||||
name: 'v1alpha2',
|
||||
specType: 'mcp-server',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not produce a declareKind op', () => {
|
||||
const ops = opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ops.every(op => op.op === 'declareKindVersion.v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an invalid JSON schema', () => {
|
||||
expect(() =>
|
||||
opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: { type: 'not-a-real-type' as any },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/Invalid JSON schema/);
|
||||
});
|
||||
|
||||
it('rejects a schema that violates semantic rules', () => {
|
||||
expect(() =>
|
||||
opsFromCatalogModelAddKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha2',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
allOf: [{ properties: { spec: { type: 'object' } } }],
|
||||
properties: {
|
||||
spec: { type: 'object' },
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/allOf/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addKindVersion integration with compileCatalogModel', () => {
|
||||
it('adds a new specType to an existing kind via a separate layer', () => {
|
||||
const baseLayer = createCatalogModelLayer({
|
||||
layerId: 'test/base-api',
|
||||
builder: model => {
|
||||
model.addKind({
|
||||
group: 'backstage.io',
|
||||
names: { kind: 'API', singular: 'api', plural: 'apis' },
|
||||
description: 'An API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
required: ['definition'],
|
||||
properties: {
|
||||
definition: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const extensionLayer = createCatalogModelLayer({
|
||||
layerId: 'test/api-mcp-extension',
|
||||
builder: model => {
|
||||
model.addKindVersion({
|
||||
kind: 'API',
|
||||
versions: [
|
||||
{
|
||||
name: 'v1alpha1',
|
||||
specType: 'mcp-server',
|
||||
description: 'An MCP server API',
|
||||
schema: {
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
required: ['remotes'],
|
||||
properties: {
|
||||
remotes: { type: 'array', minItems: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const model = compileCatalogModel([baseLayer, extensionLayer]);
|
||||
|
||||
const defaultKind = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'openapi' },
|
||||
});
|
||||
const mcpKind = model.getKind({
|
||||
kind: 'API',
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
spec: { type: 'mcp-server' },
|
||||
});
|
||||
|
||||
expect(defaultKind).toBeDefined();
|
||||
expect(mcpKind).toBeDefined();
|
||||
|
||||
const defaultRequired = (defaultKind!.jsonSchema.properties as any).spec
|
||||
.required as string[];
|
||||
const mcpRequired = (mcpKind!.jsonSchema.properties as any).spec
|
||||
.required as string[];
|
||||
|
||||
expect(defaultRequired).toContain('definition');
|
||||
expect(mcpRequired).toContain('remotes');
|
||||
expect(mcpRequired).not.toContain('definition');
|
||||
expect(mcpKind!.description).toBe('An MCP server API');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 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 { reduceKindSchema } from '../jsonSchema/reduceKindSchema';
|
||||
import { validateKindRootSchemaSemantics } from '../jsonSchema/validateKindRootSchemaSemantics';
|
||||
import { validateMetaSchema } from '../jsonSchema/validateMetaSchema';
|
||||
import { CatalogModelOp } from '../operations';
|
||||
import { createDeclareKindVersionOp } from '../operations/declareKindVersion';
|
||||
import type { CatalogModelKindVersionDefinition } from './addKind';
|
||||
|
||||
/**
|
||||
* The definition for adding one or more versions to an already-declared kind.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface CatalogModelAddKindVersionDefinition {
|
||||
/**
|
||||
* The kind to add versions to, e.g. "API".
|
||||
*/
|
||||
kind: string;
|
||||
|
||||
/**
|
||||
* The versions to add.
|
||||
*/
|
||||
versions: CatalogModelKindVersionDefinition[];
|
||||
}
|
||||
|
||||
export function opsFromCatalogModelAddKindVersion(
|
||||
definition: CatalogModelAddKindVersionDefinition,
|
||||
): CatalogModelOp[] {
|
||||
const ops: CatalogModelOp[] = [];
|
||||
|
||||
for (const version of definition.versions) {
|
||||
const jsonSchema = reduceKindSchema(version.schema.jsonSchema);
|
||||
validateMetaSchema(jsonSchema);
|
||||
validateKindRootSchemaSemantics(jsonSchema);
|
||||
const names = Array.isArray(version.name) ? version.name : [version.name];
|
||||
for (const name of names) {
|
||||
const specTypes = version.specType
|
||||
? [version.specType].flat()
|
||||
: [undefined];
|
||||
for (const specType of specTypes) {
|
||||
ops.push(
|
||||
createDeclareKindVersionOp({
|
||||
kind: definition.kind,
|
||||
name,
|
||||
specType: specType,
|
||||
properties: {
|
||||
description: version.description,
|
||||
relationFields: version.relationFields,
|
||||
schema: {
|
||||
jsonSchema: jsonSchema as any,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
type CatalogModelKindRelationFieldDefinition,
|
||||
type CatalogModelKindVersionDefinition,
|
||||
} from './addKind';
|
||||
export { type CatalogModelAddKindVersionDefinition } from './addKindVersion';
|
||||
export { type CatalogModelLabelDefinition } from './addLabel';
|
||||
export { type CatalogModelRelationPairDefinition } from './addRelationPair';
|
||||
export { type CatalogModelRemoveAnnotationDefinition } from './removeAnnotation';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "ApiV1alpha1McpServer",
|
||||
"description": "An MCP (Model Context Protocol) server exposed as an API entity. See RFC backstage/backstage#32062.",
|
||||
"examples": [
|
||||
{
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "API",
|
||||
"metadata": {
|
||||
"name": "backstage-mcp-actions",
|
||||
"title": "Backstage MCP Server",
|
||||
"description": "Exposes tools related to the Backstage Ecosystem"
|
||||
},
|
||||
"spec": {
|
||||
"type": "mcp-server",
|
||||
"lifecycle": "experimental",
|
||||
"owner": "backstage",
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": "http://internal.backstage.company:7007/api/mcp-actions/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "Entity"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["spec"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
|
||||
},
|
||||
"kind": {
|
||||
"enum": ["API"]
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"required": ["type", "lifecycle", "owner", "remotes"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "mcp-server",
|
||||
"description": "Discriminant for the MCP server specType."
|
||||
},
|
||||
"lifecycle": {
|
||||
"type": "string",
|
||||
"description": "The lifecycle state of the MCP server.",
|
||||
"examples": ["experimental", "production", "deprecated"],
|
||||
"minLength": 1
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "An entity reference to the owner of the MCP server.",
|
||||
"examples": ["ai-platform-team", "user:john.johnson"],
|
||||
"minLength": 1
|
||||
},
|
||||
"system": {
|
||||
"type": "string",
|
||||
"description": "An entity reference to the system that the MCP server belongs to.",
|
||||
"minLength": 1
|
||||
},
|
||||
"remotes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Transport endpoints for the MCP server.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Transport type, e.g. streamable-http, stdio, sse.",
|
||||
"examples": ["streamable-http", "stdio", "sse"],
|
||||
"minLength": 1
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Endpoint URL for the remote. For stdio transports this may be a command string.",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user