Merge branch 'backstage:master' into feature/catalog-export

This commit is contained in:
1337
2026-05-20 20:32:27 +02:00
committed by GitHub
1172 changed files with 67648 additions and 10161 deletions
+10
View File
@@ -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 -1
View File
@@ -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"
+8
View File
@@ -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 -1
View File
@@ -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",
+54
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app-legacy",
"version": "0.2.121-next.0",
"version": "0.2.121",
"backstage": {
"role": "frontend"
},
+62
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app",
"version": "0.0.35-next.0",
"version": "0.0.35",
"backstage": {
"role": "frontend"
},
+2
View File
@@ -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),
}),
}),
],
});
+13
View File
@@ -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 -1
View File
@@ -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"
+50
View File
@@ -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
View File
@@ -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.
*/
+2 -2
View File
@@ -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,
@@ -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(),
});
});
@@ -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';
@@ -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';
@@ -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(
@@ -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';
@@ -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 = [
{
@@ -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
+5 -1
View File
@@ -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();
});
});
@@ -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();
});
});
});
@@ -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');
});
});
},
);
@@ -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 -1
View File
@@ -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"
+20
View File
@@ -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 -1
View File
@@ -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',
});
+37
View File
@@ -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 -2
View File
@@ -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';
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
];
/**
+87
View File
@@ -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
+2 -1
View File
@@ -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:^",
+1
View File
@@ -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'),
);
+9
View File
@@ -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 -1
View File
@@ -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"
+19
View File
@@ -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
+1
View File
@@ -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 -1
View File
@@ -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"
+177 -2
View File
@@ -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)
```
+35
View File
@@ -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