Merge branch 'master' into canon-forms

This commit is contained in:
Charles de Dreuille
2025-06-16 23:11:03 +01:00
61 changed files with 1855 additions and 320 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/plugin-notifications-backend': patch
---
Notifications are now automatically deleted after 1 year by default.
There is a new scheduled task that runs every 24 hours to delete notifications older than 1 year.
This can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
```yaml
notifications:
retention: 1y
```
If the retention is set to false, notifications will not be automatically deleted.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder': minor
---
Added information about the `entityRef` and `taskId` to the analytics events whenever is possible.
+27
View File
@@ -0,0 +1,27 @@
---
'@backstage/backend-defaults': patch
---
Added new rate limit middleware to allow rate limiting requests to the backend
If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/28708/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration.
Rate limiting can be turned on by adding the following configuration to `app-config.yaml`:
```yaml
backend:
rateLimit:
window: 6s
incomingRequestLimit: 100
```
Plugin specific rate limiting can be configured by adding the following configuration to `app-config.yaml`:
```yaml
backend:
rateLimit:
global: false # This will disable the global rate limiting
plugin:
catalog:
window: 6s
incomingRequestLimit: 100
```
+5
View File
@@ -0,0 +1,5 @@
---
'yarn-plugin-backstage': patch
---
added functionality so that adding or updating a backstage dependency to a package would maintain the "backstage:^" placeholder for the version.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
The filter input in menu comboboxes should now always use the full width of the menu it's in.
+38
View File
@@ -0,0 +1,38 @@
---
'@backstage/plugin-catalog-backend-module-ldap': patch
---
Added the ability to configure disabling one side of the relations tree with LDAP.
Groups have a `member` attribute and users have a `memberOf` attribute, however these can drift out of sync in some LDAP installations, leaving weird states in the Catalog as we collate these results together and deduplicate them.
You can chose to optionally disable one side of these relationships, or even both by setting the respective mapping to `null` in your `app-config.yaml` for your groups and/or users:
```yaml
catalog:
providers:
ldapOrg:
default:
target: ldaps://ds.example.net
bind:
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
secret: ${LDAP_SECRET}
users:
- dn: ou=people,ou=example,dc=example,dc=net
options:
filter: (uid=*)
map:
# this ensures that outgoing memberships from users is ignored
memberOf: null
groups:
- dn: ou=access,ou=groups,ou=example,dc=example,dc=net
options:
filter: (&(objectClass=some-group-class)(!(groupType=email)))
map:
description: l
set:
metadata.customField: 'hello'
map:
# this ensures that outgoing memberships from groups is ignored
members: null
```
+6
View File
@@ -36,6 +36,12 @@ backend:
# keys:
# - secret: ${BACKEND_SECRET}
# Used for testing rate limiting locally
# rateLimit:
# windowMs: 1m
# incomingRequestLimit: 1
# ipAllowList: []
auth:
# TODO: once plugins have been migrated we can remove this, but right now it
# is require for the backend-next to work in this repo
@@ -68,6 +68,60 @@ For those routes you will also have to specify `allowLimitedAccess: true` when
using the [`auth`](./auth.md) and [`httpAuth`](./http-auth.md) services to
access the incoming credentials.
## Rate limiting
Rate limiting allows you to limit the amount of requests users can send to you backend.
This is useful for blocking various network attacks, such as DDOS.
To enable rate limiting, add the following to your config:
```yaml
backend:
rateLimit: true
```
You can additionally configure the rate limiting parameters, also by plugin:
```yaml
backend:
rateLimit:
global: true # Enables or disables rate limit for all plugins
window: 6s # Time window for rate limiting for single client
incomingRequestLimit: 100 # Number of requests to accept from one client during time window
ipAllowList: ['127.0.0.1'] # IPs to bypass rate limiting
skipSuccesfulRequests: false # Rate limit successful requests
skipFailedRequests: false # Rate limit failed requests
plugin:
# Plugin specific rate limiting
catalog:
window: 3s
incomingRequestLimit: 50
```
By default, the rate limiting is per instance and the request counts are stored into memory.
If you want to share this information across all your backstage instances, you have to configure
the rate limiting store:
```yaml
backend:
rateLimit:
global: true
store:
type: redis
connection: redis://127.0.0.1:16379
```
If your instance is working behind a proxy, you have to configure the backend to trust the proxy
for the rate limiting being able to distinguish clients.
```yaml
backend:
trustProxy: true
```
For more information about the trust proxy configuration and available options,
please refer to [express documentation](https://expressjs.com/en/guide/behind-proxies.html).
## Configuring the service
For more advanced customization, there are several APIs from the `@backstage/backend-defaults/httpRouter` package that allow you to customize the implementation of the config service. The default implementation uses all of the middleware exported from `@backstage/backend-defaults/httpRouter`, including `createLifecycleMiddleware`, `createAuthIntegrationRouter`, `createCredentialsBarrier` and `createCookieAuthRefreshMiddleware`. You can use these to create your own `httpRouter` service implementation, for example - here's how you would add a custom health check route to all plugins:
@@ -78,6 +132,7 @@ import {
createCookieAuthRefreshMiddleware,
createCredentialsBarrier,
createAuthIntegrationRouter,
createRateLimitMiddleware,
} from '@backstage/backend-defaults/httpRouter';
import { createServiceFactory } from '@backstage/backend-plugin-api';
@@ -105,6 +160,11 @@ backend.add(
}) {
const router = PromiseRouter();
// Optional rate limiting middleware
router.use(
createRateLimitMiddleware({ pluginId: plugin.getId(), config }),
);
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
const credentialsBarrier = createCredentialsBarrier({
@@ -40,6 +40,10 @@ createBackendPlugin({
});
```
## Rate limiting
Please refer to the [HTTP Router documentation](./http-router.md#rate-limiting).
## Configuring the service
### Via `app-config.yaml`
@@ -121,6 +125,11 @@ backend.add(
app.use(middleware.cors());
app.use(middleware.compression());
// Optional rate limiting middleware
app.use(middleware.rateLimit());
// If you are using rate limiting behind a proxy, you should set the `trust proxy` setting to true
app.set('trust proxy', true);
app.use(healthRouter);
// you can add you your own middleware in here
+15
View File
@@ -158,6 +158,21 @@ You can customize the origin names shown in the UI by passing an object where th
Each notification processor will receive its own row in the settings page, where the user can enable or disable notifications from that processor.
### Automatic notification cleanup
Notifications are deleted automatically after a certain period of time to prevent the database from growing indefinitely
and to keep the user interface clean. The default retention period is set to 1 year, meaning that notifications older
than that will be deleted automatically.
The retention period can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
```yaml
notifications:
retention: 1y
```
If the retention is set to false, notifications will not be automatically deleted.
## Additional info
An example of a backend plugin sending notifications can be found in the [`@backstage/plugin-scaffolder-backend-module-notifications` package](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend-module-notifications).
+10
View File
@@ -0,0 +1,10 @@
---
title: Spacelift.io
author: Spacelift.io
authorUrl: https://spacelift.io/
category: Infrastructure
description: The Spacelift plugin allows you to manage your infrastructure directly from Backstage. Visualize your IaC stacks and trigger runs with ease.
documentation: https://docs.spacelift.io/integrations/external-integrations/backstage
iconUrl: https://avatars.githubusercontent.com/u/53318513?s=200&v=4
npmPackageName: '@spacelift-io/backstage-integration-frontend'
addedDate: '2025-06-10'
+86
View File
@@ -790,6 +790,92 @@ export interface Config {
headers?: { [name: string]: string };
};
/**
* Rate limiting options. Defining this as `true` will enable rate limiting with default values.
*/
rateLimit?:
| true
| {
store?:
| {
type: 'redis';
connection: string;
}
| {
type: 'memory';
};
/**
* Enable/disable global rate limiting. If this is disabled, plugin specific rate limiting must be
* used.
*/
global?: boolean;
/**
* Time frame in milliseconds or as human duration for which requests are checked/remembered.
* Defaults to one minute.
*/
window?: string | HumanDuration;
/**
* The maximum number of connections to allow during the `window` before rate limiting the client.
* Defaults to 5.
*/
incomingRequestLimit?: number;
/**
* Whether to pass requests in case of store failure.
* Defaults to false.
*/
passOnStoreError?: boolean;
/**
* List of allowed IP addresses that are not rate limited.
* Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1].
*/
ipAllowList?: string[];
/**
* Skip rate limiting for requests that have been successful.
* Defaults to false.
*/
skipSuccessfulRequests?: boolean;
/**
* Skip rate limiting for requests that have failed.
* Defaults to false.
*/
skipFailedRequests?: boolean;
/** Plugin specific rate limiting configuration */
plugin?: {
[pluginId: string]: {
/**
* Time frame in milliseconds or as human duration for which requests are checked/remembered.
* Defaults to one minute.
*/
window?: string | HumanDuration;
/**
* The maximum number of connections to allow during the `window` before rate limiting the client.
* Defaults to 5.
*/
incomingRequestLimit?: number;
/**
* Whether to pass requests in case of store failure.
* Defaults to false.
*/
passOnStoreError?: boolean;
/**
* List of allowed IP addresses that are not rate limited.
* Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1].
*/
ipAllowList?: string[];
/**
* Skip rate limiting for requests that have been successful.
* Defaults to false.
*/
skipSuccessfulRequests?: boolean;
/**
* Skip rate limiting for requests that have failed.
* Defaults to false.
*/
skipFailedRequests?: boolean;
};
};
};
/**
* Configuration related to URL reading, used for example for reading catalog info
* files, scaffolder templates, and techdocs content.
+2
View File
@@ -168,6 +168,7 @@
"cron": "^3.0.0",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-rate-limit": "^7.5.0",
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
@@ -187,6 +188,7 @@
"pg": "^8.11.3",
"pg-connection-string": "^2.3.0",
"pg-format": "^1.0.4",
"rate-limit-redis": "^4.2.0",
"raw-body": "^2.4.1",
"selfsigned": "^2.0.0",
"tar": "^6.1.12",
@@ -93,6 +93,7 @@ export class MiddlewareFactory {
helmet(): RequestHandler;
logging(): RequestHandler;
notFound(): RequestHandler;
rateLimit(): RequestHandler;
}
// @public
@@ -70,6 +70,11 @@ describe('actionsServiceFactory', () => {
input: {},
output: {},
},
attributes: {
destructive: false,
idempotent: false,
readOnly: false,
},
};
beforeEach(() => {
@@ -306,6 +311,11 @@ describe('actionsServiceFactory', () => {
type: 'object',
},
},
attributes: {
destructive: true,
idempotent: false,
readOnly: false,
},
title: 'Test',
},
],
@@ -67,13 +67,20 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
actions: Array.from(this.actions.entries()).map(([id, action]) => ({
id,
...action,
attributes: {
// Inspired by the @modelcontextprotocol/sdk defaults for the hints.
// https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812
destructive: action.attributes?.destructive ?? true,
idempotent: action.attributes?.idempotent ?? false,
readOnly: action.attributes?.readOnly ?? false,
},
schema: {
input: action.schema?.input
? zodToJsonSchema(action.schema.input(z))
: zodToJsonSchema(z.any()),
: zodToJsonSchema(z.object({})),
output: action.schema?.output
? zodToJsonSchema(action.schema.output(z))
: zodToJsonSchema(z.any()),
: zodToJsonSchema(z.object({})),
},
})),
});
@@ -181,6 +181,109 @@ describe('actionsRegistryServiceFactory', () => {
});
});
it('should set default attributes', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: { ok: true } }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body).toMatchObject({
actions: [
{
name: 'test',
attributes: {
destructive: true,
idempotent: false,
readOnly: false,
},
},
],
});
});
it('should allow setting attributes', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
attributes: {
destructive: false,
idempotent: true,
readOnly: true,
},
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: { ok: true } }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body).toMatchObject({
actions: [
{
name: 'test',
title: 'Test',
description: 'Test',
attributes: {
destructive: false,
idempotent: true,
readOnly: true,
},
},
],
});
});
it('should forces registration of input and output schema as objects', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
@@ -0,0 +1,40 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NextFunction, Request, Response } from 'express';
import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts';
import { Config } from '@backstage/config';
import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts';
export const createRateLimitMiddleware = (options: {
pluginId: string;
config: Config;
}) => {
const { pluginId, config } = options;
const configKey = `backend.rateLimit.plugin.${pluginId}`;
const enabled = config.has(configKey);
if (!enabled) {
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
const rateLimitOptions = config.getConfig(configKey);
return rateLimitMiddleware({
store: RateLimitStoreFactory.create({ config, prefix: pluginId }),
config: rateLimitOptions,
});
};
@@ -22,12 +22,13 @@ import {
HttpRouterServiceAuthPolicy,
} from '@backstage/backend-plugin-api';
import {
createLifecycleMiddleware,
createAuthIntegrationRouter,
createCookieAuthRefreshMiddleware,
createCredentialsBarrier,
createAuthIntegrationRouter,
createLifecycleMiddleware,
} from './http';
import { MiddlewareFactory } from '../rootHttpRouter';
import { createRateLimitMiddleware } from './http/createRateLimitMiddleware.ts';
/**
* HTTP route registration for plugins.
@@ -61,6 +62,8 @@ export const httpRouterServiceFactory = createServiceFactory({
}) {
const router = PromiseRouter();
router.use(createRateLimitMiddleware({ pluginId: plugin.getId(), config }));
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
const credentialsBarrier = createCredentialsBarrier({
@@ -15,15 +15,15 @@
*/
import {
RootConfigService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import {
Request,
Response,
ErrorRequestHandler,
NextFunction,
Request,
RequestHandler,
Response,
} from 'express';
import cors from 'cors';
import helmet from 'helmet';
@@ -37,12 +37,14 @@ import {
InputError,
NotAllowedError,
NotFoundError,
NotImplementedError,
NotModifiedError,
ServiceUnavailableError,
serializeError,
ServiceUnavailableError,
} from '@backstage/errors';
import { NotImplementedError } from '@backstage/errors';
import { applyInternalErrorFilter } from './applyInternalErrorFilter';
import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts';
import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts';
type LogMeta = {
date: string;
@@ -227,6 +229,47 @@ export class MiddlewareFactory {
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Returns a middleware that implements rate limiting.
*
* @remarks
*
* Rate limiting is a common technique to prevent abuse of APIs. This middleware is
* configured using the config key `backend.rateLimit`.
*
* @returns An Express request handler
*/
rateLimit(): RequestHandler {
const enabled = this.#config.has('backend.rateLimit');
if (!enabled) {
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
const useDefaults = this.#config.getOptional('backend.rateLimit') === true;
const rateLimitOptions = useDefaults
? undefined
: this.#config.getOptionalConfig('backend.rateLimit');
// Global rate limiting disabled
if (
rateLimitOptions &&
rateLimitOptions.getOptionalBoolean('global') === false
) {
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
return rateLimitMiddleware({
store: useDefaults
? undefined
: RateLimitStoreFactory.create({ config: this.#config }),
config: rateLimitOptions,
});
}
/**
* Express middleware to handle errors during request processing.
*
@@ -15,13 +15,13 @@
*/
import {
RootConfigService,
coreServices,
createServiceFactory,
LifecycleService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import express, { RequestHandler, Express } from 'express';
import express, { Express, RequestHandler } from 'express';
import type { Server } from 'node:http';
import {
createHttpServer,
@@ -121,6 +121,7 @@ const rootHttpRouterServiceFactoryWithOptions = (
app.use(middleware.cors());
app.use(middleware.compression());
app.use(middleware.logging());
app.use(middleware.rateLimit());
app.use(healthRouter);
app.use(routes);
app.use(middleware.notFound());
@@ -0,0 +1,68 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { RateLimitStoreFactory } from './RateLimitStoreFactory.ts';
import { RedisStore } from 'rate-limit-redis';
jest.mock('@keyv/redis', () => {
const Actual = jest.requireActual('@keyv/redis');
return {
...Actual,
__esModule: true,
default: jest.fn(() => {
return {
getClient: jest.fn(() => ({
sendCommand: jest.fn().mockReturnValue('mock'),
})),
};
}),
};
});
describe('CacheRateLimitStoreFactory', () => {
afterEach(jest.clearAllMocks);
it('should return undefined store with auto configuration if redis is not available', () => {
const config = mockServices.rootConfig({
data: {
backend: {
rateLimit: {
store: undefined,
},
},
},
});
const store = RateLimitStoreFactory.create({ config });
expect(store).toBeUndefined();
});
it('should return redis store if configured explicitly', async () => {
const config = mockServices.rootConfig({
data: {
backend: {
rateLimit: {
store: {
type: 'redis',
connection: 'redis://localhost:6379',
},
},
},
},
});
const store = RateLimitStoreFactory.create({ config });
expect(store).toBeInstanceOf(RedisStore);
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import type { Store } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
/**
* Creates a store for `express-rate-limit` based on the configuration.
*
* @internal
*/
export class RateLimitStoreFactory {
static create(options: {
config: Config;
prefix?: string;
}): Store | undefined {
const { config, prefix } = options;
const store = config.getOptionalConfig('backend.rateLimit.store');
if (!store) {
return undefined;
}
const type = store.getString('type');
switch (type) {
case 'redis':
return this.redis({ store, prefix });
case 'memory':
default:
return undefined;
}
}
private static redis(options: { store: Config; prefix?: string }): Store {
const { store, prefix } = options;
const connectionString = store.getString('connection');
const KeyvRedis = require('@keyv/redis').default;
const keyv = new KeyvRedis(connectionString);
return new RedisStore({
prefix,
sendCommand: async (...args: string[]) => {
const client = await keyv.getClient();
return client.sendCommand(args);
},
});
}
}
@@ -0,0 +1,81 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RequestHandler } from 'express';
import { rateLimit, Store } from 'express-rate-limit';
import { Config, readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
export const rateLimitMiddleware = (options: {
store?: Store;
config?: Config;
}): RequestHandler => {
const { store, config } = options;
let windowMs: number = 60000;
if (config && config.has('window')) {
const windowDuration = readDurationFromConfig(config, {
key: 'window',
});
windowMs = durationToMilliseconds(windowDuration);
}
const limit = config?.getOptionalNumber('incomingRequestLimit');
const ipAllowList = config?.getOptionalStringArray('ipAllowList') ?? [
'127.0.0.1',
'0:0:0:0:0:0:0:1',
'::1',
];
const skipSuccessfulRequests = config?.getOptionalBoolean(
'skipSuccessfulRequests',
);
const skipFailedRequests = config?.getOptionalBoolean('skipFailedRequests');
const passOnStoreError = config?.getOptionalBoolean('passOnStoreError');
return rateLimit({
windowMs,
limit,
skipSuccessfulRequests,
message: {
error: {
name: 'Error',
message: `Too many requests, please try again later`,
},
response: {
statusCode: 429,
},
},
statusCode: 429,
skipFailedRequests,
passOnStoreError: passOnStoreError,
keyGenerator(req, _res): string {
if (!req.ip) {
return req.socket.remoteAddress!;
}
return req.ip;
},
skip: (req, _res) => {
return (
Boolean(req.ip && ipAllowList.includes(req.ip)) ||
Boolean(
req.socket.remoteAddress &&
ipAllowList.includes(req.socket.remoteAddress),
)
);
},
validate: {
trustProxy: false,
},
store,
});
};
+10
View File
@@ -48,6 +48,11 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
attributes?: {
destructive?: boolean;
idempotent?: boolean;
readOnly?: boolean;
};
action: (context: ActionsRegistryActionContext<TInputSchema>) => Promise<
z.infer<TOutputSchema> extends void
? void
@@ -94,6 +99,11 @@ export type ActionsServiceAction = {
input: JSONSchema7;
output: JSONSchema7;
};
attributes: {
readOnly: boolean;
destructive: boolean;
idempotent: boolean;
};
};
// @public
@@ -40,6 +40,11 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
attributes?: {
destructive?: boolean;
idempotent?: boolean;
readOnly?: boolean;
};
action: (
context: ActionsRegistryActionContext<TInputSchema>,
) => Promise<
@@ -29,6 +29,11 @@ export type ActionsServiceAction = {
input: JSONSchema7;
output: JSONSchema7;
};
attributes: {
readOnly: boolean;
destructive: boolean;
idempotent: boolean;
};
};
/**
+1
View File
@@ -94,6 +94,7 @@
border: none;
border-bottom: 1px solid var(--canon-border);
background-color: var(--canon-bg-surface-1);
width: 100%;
height: 32px;
color: var(--canon-fg-primary);
line-height: 140%;
@@ -93,6 +93,7 @@
.canon-SubmenuComboboxSearch {
padding-inline: var(--canon-space-3);
width: 100%;
height: 32px;
border: none;
border-bottom: 1px solid var(--canon-border);
+9
View File
@@ -82,3 +82,12 @@ specific Backstage repository. As such, when publishing packages, all
`backstage:^` versions should be removed from the package.json and replaced with
the appropriate npm version ranges. This is handled by the
`beforeWorkspacePacking` hook.
### `afterWorkspaceDependencyAddition` hook
_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after
the plugin has converted existing dependencies to `backstage:^` range_
### `afterWorkspaceDependencyReplacement` hook
_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired._
+2 -1
View File
@@ -32,8 +32,9 @@
"dependencies": {
"@backstage/cli-common": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@yarnpkg/core": "^4.4.0",
"@yarnpkg/core": "^4.4.1",
"@yarnpkg/fslib": "^3.1.2",
"@yarnpkg/plugin-essentials": "^4.4.0",
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch",
"@yarnpkg/plugin-pack": "^4.0.1",
"semver": "^7.6.0"
+3 -2
View File
@@ -4,10 +4,11 @@
```ts
import { Hooks } from '@yarnpkg/core';
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-pack';
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-essentials';
import { Hooks as Hooks_3 } from '@yarnpkg/plugin-pack';
import { Plugin as Plugin_2 } from '@yarnpkg/core';
// @public (undocumented)
const plugin: Plugin_2<Hooks & Hooks_2>;
const plugin: Plugin_2<Hooks & Hooks_2 & Hooks_3>;
export default plugin;
```
@@ -0,0 +1,119 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Descriptor,
DescriptorHash,
IdentHash,
Workspace,
} from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
jest.mock('../util', () => ({
getPackageVersion: jest.fn(),
}));
describe('afterWorkspaceDependencyAddition', () => {
const workspace = {
project: {
configuration: {},
},
} as Workspace;
const target = {} as suggestUtils.Target;
const strategies: Array<suggestUtils.Strategy> = [];
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
typeof getPackageVersion
>;
beforeEach(() => {
mockGetPackageVersion.mockReset();
});
it('should replace the range for a backstage scoped dependency', async () => {
const input: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('backstage:^');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
input,
workspace.project.configuration,
);
});
it('should not replace the range for a backstage scoped dependency where it cant find a version from remote', async () => {
const input: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() =>
Promise.reject(new Error('test error')),
);
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
input,
workspace.project.configuration,
);
});
it('should not replace the range for a non-backstage scoped dependency', async () => {
const input: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('^1.0.0');
expect(mockGetPackageVersion).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
export const afterWorkspaceDependencyAddition = async (
workspace: Workspace,
_target: suggestUtils.Target,
descriptor: Descriptor,
_strategies: Array<suggestUtils.Strategy>,
) => {
const descriptorRange = structUtils.parseRange(descriptor.range);
if (
descriptor.scope === 'backstage' &&
descriptorRange.protocol !== PROTOCOL
) {
try {
await getPackageVersion(descriptor, workspace.project.configuration);
console.info(
`Setting ${descriptor.scope}/${descriptor.name} to ${PROTOCOL}^`,
);
descriptor.range = `${PROTOCOL}^`;
} catch (_error: any) {
// if there's no found version then this is likely a deprecated package
// or otherwise the plugin won't be able to resolve the real version
// and we should leave the desired range as is
}
}
};
@@ -0,0 +1,138 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Descriptor,
DescriptorHash,
IdentHash,
Workspace,
} from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
jest.mock('../util', () => ({
getPackageVersion: jest.fn(),
}));
describe('afterWorkspaceDependencyReplacement.test', () => {
const workspace = {
project: {
configuration: {},
},
} as Workspace;
const target = {} as suggestUtils.Target;
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
typeof getPackageVersion
>;
beforeEach(() => {
mockGetPackageVersion.mockReset();
});
it('should warn that the range is being changed for a backstage scoped dependency', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
toDescriptor,
workspace.project.configuration,
);
});
it('should not warn that the range is being changed for a backstage scoped dependency where it cant find a version from remote', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() =>
Promise.reject(new Error('test error')),
);
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
toDescriptor,
workspace.project.configuration,
);
});
it('should ignore that the range is being changed for a non-backstage scoped dependency', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
export const afterWorkspaceDependencyReplacement = async (
workspace: Workspace,
_target: suggestUtils.Target,
_fromDescriptor: Descriptor,
toDescriptor: Descriptor,
) => {
const toDescriptorRange = structUtils.parseRange(toDescriptor.range);
if (
toDescriptor.scope === 'backstage' &&
toDescriptorRange.protocol !== PROTOCOL
) {
try {
await getPackageVersion(toDescriptor, workspace.project.configuration);
console.warn(
`${toDescriptor.name} should be set to "${PROTOCOL}^" instead of "${toDescriptor.range}". Make sure this change is intentional and not a mistake.`,
);
} catch (_error: any) {
// if there's no found version then this is likely a deprecated package
// or otherwise the plugin won't be able to resolve the real version
// and we should not warn them.
}
}
};
@@ -16,3 +16,5 @@
export { beforeWorkspacePacking } from './beforeWorkspacePacking';
export { reduceDependency } from './reduceDependency';
export { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
export { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
+10 -2
View File
@@ -23,7 +23,13 @@
import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core';
import { Hooks as PackHooks } from '@yarnpkg/plugin-pack';
import { beforeWorkspacePacking, reduceDependency } from './handlers';
import { Hooks as EssentialHooks } from '@yarnpkg/plugin-essentials';
import {
afterWorkspaceDependencyAddition,
afterWorkspaceDependencyReplacement,
beforeWorkspacePacking,
reduceDependency,
} from './handlers';
import { BackstageNpmResolver } from './resolvers';
// All dependencies of the yarn plugin are bundled during the build. Chalk
@@ -44,8 +50,10 @@ if (!semverUtils.satisfiesWithPrereleases(YarnVersion, '^4.1.1')) {
/**
* @public
*/
const plugin: Plugin<Hooks & PackHooks> = {
const plugin: Plugin<Hooks & EssentialHooks & PackHooks> = {
hooks: {
afterWorkspaceDependencyAddition,
afterWorkspaceDependencyReplacement,
reduceDependency,
beforeWorkspacePacking,
},
+17 -16
View File
@@ -141,7 +141,7 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.memberOf field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
};
}
| Array<{
@@ -221,7 +221,7 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.memberOf field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
};
}>;
@@ -311,12 +311,12 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.parent field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
/**
* The name of the attribute that shall be used for the values of
* the spec.children field of the entity. Defaults to "member".
*/
members?: string;
members?: string | null;
};
}
| Array<{
@@ -401,12 +401,12 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.parent field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
/**
* The name of the attribute that shall be used for the values of
* the spec.children field of the entity. Defaults to "member".
*/
members?: string;
members?: string | null;
};
}>;
/**
@@ -556,7 +556,7 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.memberOf field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
};
}
| Array<{
@@ -588,6 +588,7 @@ export interface Config {
pagePause?: boolean;
};
};
/**
* JSON paths (on a.b.c form) and hard coded values to set on those
* paths.
@@ -636,7 +637,7 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.memberOf field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
};
}>;
@@ -726,12 +727,12 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.parent field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
/**
* The name of the attribute that shall be used for the values of
* the spec.children field of the entity. Defaults to "member".
*/
members?: string;
members?: string | null;
};
}
| Array<{
@@ -816,12 +817,12 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.parent field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
/**
* The name of the attribute that shall be used for the values of
* the spec.children field of the entity. Defaults to "member".
*/
members?: string;
members?: string | null;
};
}>;
@@ -926,7 +927,6 @@ export interface Config {
* paths.
*
* This can be useful for example if you want to hard code a
* namespace or similar on the generated entities.
*/
set?: { [key: string]: JsonValue };
/**
@@ -969,7 +969,7 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.memberOf field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
};
};
@@ -1006,6 +1006,7 @@ export interface Config {
};
};
/**
* @default false
* JSON paths (on a.b.c form) and hard coded values to set on those
* paths.
*
@@ -1058,12 +1059,12 @@ export interface Config {
* The name of the attribute that shall be used for the values of
* the spec.parent field of the entity. Defaults to "memberOf".
*/
memberOf?: string;
memberOf?: string | null;
/**
* The name of the attribute that shall be used for the values of
* the spec.children field of the entity. Defaults to "member".
*/
members?: string;
members?: string | null;
};
};
/**
@@ -63,8 +63,8 @@ export type GroupConfig = {
displayName: string;
email?: string;
picture?: string;
memberOf: string;
members: string;
memberOf: string | null;
members: string | null;
};
};
@@ -259,7 +259,7 @@ export type UserConfig = {
displayName: string;
email: string;
picture?: string;
memberOf: string;
memberOf: string | null;
};
};
@@ -90,6 +90,7 @@ export type UserConfig = {
// Only the scope, filter, attributes, and paged fields are supported. The
// default is scope "one" and attributes "*" and "+".
options: SearchOptions;
// JSON paths (on a.b.c form) and hard coded values to set on those paths
set?: { [path: string]: JsonValue };
// Mappings from well known entity fields, to LDAP attribute names
@@ -114,7 +115,7 @@ export type UserConfig = {
picture?: string;
// The name of the attribute that shall be used for the values of the
// spec.memberOf field of the entity. Defaults to "memberOf".
memberOf: string;
memberOf: string | null;
};
};
@@ -129,6 +130,7 @@ export type GroupConfig = {
// The search options to use.
// Only the scope, filter, attributes, and paged fields are supported.
options: SearchOptions;
// JSON paths (on a.b.c form) and hard coded values to set on those paths
set?: { [path: string]: JsonValue };
// Mappings from well known entity fields, to LDAP attribute names
@@ -156,10 +158,10 @@ export type GroupConfig = {
picture?: string;
// The name of the attribute that shall be used for the values of the
// spec.parent field of the entity. Defaults to "memberOf".
memberOf: string;
memberOf: string | null;
// The name of the attribute that shall be used for the values of the
// spec.children field of the entity. Defaults to "member".
members: string;
members: string | null;
};
};
@@ -210,6 +210,53 @@ describe('readLdapUsers', () => {
);
});
it('should allow skipping memberOf', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
await fn(searchEntry({ memberOf: ['x', 'y', 'z'] }));
});
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
await fn(
searchEntry({
uid: ['uid-value'],
description: ['description-value'],
cn: ['cn-value'],
mail: ['mail-value'],
avatarUrl: ['avatarUrl-value'],
memberOf: ['x', 'y', 'z'],
customDN: ['dn-value'],
customUUID: ['uuid-value'],
}),
);
});
const config: UserConfig[] = [
{
dn: 'ddd',
options: {},
map: {
rdn: 'uid',
name: 'uid',
description: 'description',
displayName: 'cn',
email: 'mail',
picture: 'avatarUrl',
memberOf: null,
},
},
];
const vendorConfig: VendorConfig = {
dnAttributeName: 'customDN',
uuidAttributeName: 'customUUID',
};
const { userMemberOf } = await readLdapUsers(client, config, vendorConfig);
expect(userMemberOf.size).toBe(0);
});
it('transfers all attributes from Microsoft Active Directory', async () => {
client.getVendor.mockResolvedValue(ActiveDirectoryVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
@@ -729,6 +776,51 @@ describe('readLdapGroups', () => {
);
});
it('should allow skipping members', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
await fn(
searchEntry({
cn: ['cn-value'],
description: ['description-value'],
tt: ['type-value'],
mail: ['mail-value'],
avatarUrl: ['avatarUrl-value'],
memberOf: ['x', 'y', 'z'],
member: ['e', 'f', 'g'],
customDN: ['dn-value'],
customUUID: ['uuid-value'],
}),
);
});
const config: GroupConfig[] = [
{
dn: 'ddd',
options: {},
map: {
rdn: 'cn',
name: 'cn',
description: 'description',
displayName: 'cn',
email: 'mail',
picture: 'avatarUrl',
type: 'tt',
memberOf: 'memberOf',
members: null,
},
},
];
const vendorConfig: VendorConfig = {
dnAttributeName: 'customDN',
uuidAttributeName: 'customUUID',
};
const { groupMember } = await readLdapGroups(client, config, vendorConfig);
expect(groupMember.size).toBe(0);
});
it('can process a list of GroupConfigs', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
@@ -146,6 +146,7 @@ export async function readLdapUsers(
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
ensureItems(userMemberOf, myDn, vs);
});
entities.push(entity);
});
}
@@ -277,6 +278,7 @@ export async function readLdapGroups(
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
ensureItems(groupMemberOf, myDn, vs);
});
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
ensureItems(groupMember, myDn, vs);
});
@@ -349,7 +351,7 @@ export async function readLdapOrg(
function mapReferencesAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
attributeName: string | undefined | null,
setter: (sourceDn: string, targets: string[]) => void,
) {
if (attributeName) {
+5
View File
@@ -28,5 +28,10 @@ export interface Config {
* Throttle duration between notification sending, defaults to 50ms
*/
throttleInterval?: HumanDuration | string;
/**
* Time to keep the notifications in the database, defaults to 365 days.
* Can be disabled by setting to false.
*/
retention?: HumanDuration | string | false;
};
}
@@ -805,5 +805,24 @@ describe.each(databases.eachSupportedId())(
});
});
});
describe('clearNotifications', () => {
it('should clear notifications older than specified days', async () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10); // 10 days ago
await storage.saveNotification({
...testNotification1,
created: oldDate,
});
await storage.saveNotification(testNotification2);
const result = await storage.clearNotifications({
maxAge: { days: 5 },
}); // Clear notifications older than 5 days
expect(result.deletedCount).toBe(1); // Only the first notification should be cleared
const remainingNotifications = await storage.getNotifications({ user });
expect(remainingNotifications.map(idOnly)).toEqual([id2]);
});
});
},
);
@@ -31,6 +31,7 @@ import {
} from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
import crypto from 'crypto';
import { durationToMilliseconds, HumanDuration } from '@backstage/types';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-notifications-backend',
@@ -656,4 +657,24 @@ export class DatabaseNotificationsStore implements NotificationsStore {
.distinct(['topic']);
return { topics: topics.map(row => row.topic) };
}
async clearNotifications(options: {
maxAge: HumanDuration;
}): Promise<{ deletedCount: number }> {
const ms = durationToMilliseconds(options.maxAge);
const now = new Date(new Date().getTime() - ms);
const notificationsCount = await this.db('notification')
.where(builder => {
builder.where('created', '<=', now).whereNull('updated');
})
.orWhere('updated', '<=', now)
.delete();
const broadcastsCount = await this.db('broadcast')
.where(builder => {
builder.where('created', '<=', now).whereNull('updated');
})
.orWhere('updated', '<=', now)
.delete();
return { deletedCount: notificationsCount + broadcastsCount };
}
}
@@ -20,6 +20,7 @@ import {
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
import { HumanDuration } from '@backstage/types';
/** @internal */
export type EntityOrder = {
@@ -99,6 +100,10 @@ export interface NotificationsStore {
user: string;
}): Promise<{ origins: string[] }>;
getUserNotificationTopics(options: {
user: string;
}): Promise<{ topics: { origin: string; topic: string }[] }>;
getNotificationSettings(options: {
user: string;
}): Promise<NotificationSettings>;
@@ -109,4 +114,8 @@ export interface NotificationsStore {
}): Promise<void>;
getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>;
clearNotifications(options: {
maxAge: HumanDuration;
}): Promise<{ deletedCount: number }>;
}
+15 -1
View File
@@ -26,6 +26,8 @@ import {
NotificationsProcessingExtensionPoint,
} from '@backstage/plugin-notifications-node';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { DatabaseNotificationsStore } from './database';
import { NotificationCleaner } from './service/NotificationCleaner.ts';
class NotificationsProcessingExtensionPointImpl
implements NotificationsProcessingExtensionPoint
@@ -69,6 +71,7 @@ export const notificationsPlugin = createBackendPlugin({
signals: signalsServiceRef,
config: coreServices.rootConfig,
catalog: catalogServiceRef,
scheduler: coreServices.scheduler,
},
async init({
auth,
@@ -80,7 +83,10 @@ export const notificationsPlugin = createBackendPlugin({
signals,
config,
catalog,
scheduler,
}) {
const store = await DatabaseNotificationsStore.create({ database });
httpRouter.use(
await createRouter({
auth,
@@ -88,7 +94,7 @@ export const notificationsPlugin = createBackendPlugin({
userInfo,
logger,
config,
database,
store,
catalog,
signals,
processors: processingExtensions.processors,
@@ -98,6 +104,14 @@ export const notificationsPlugin = createBackendPlugin({
path: '/health',
allow: 'unauthenticated',
});
const cleaner = new NotificationCleaner(
config,
scheduler,
logger,
store,
);
await cleaner.initTaskRunner();
},
});
},
@@ -0,0 +1,118 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService, SchedulerService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { mockServices } from '@backstage/backend-test-utils';
import { NotificationsStore } from '../database';
import { NotificationCleaner } from './NotificationCleaner.ts';
describe('NotificationCleaner', () => {
let mockConfig: Config;
let mockScheduler: SchedulerService;
let mockLogger: LoggerService;
let mockDatabase: NotificationsStore;
beforeEach(() => {
mockConfig = mockServices.rootConfig();
mockScheduler = mockServices.scheduler.mock();
mockLogger = mockServices.logger.mock();
mockDatabase = {
clearNotifications: jest.fn(),
} as unknown as NotificationsStore;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initNotificationCleaner', () => {
it('should initialize the notification cleaner with the correct schedule', async () => {
const mockTaskRunner = {
run: jest.fn(),
};
mockScheduler.createScheduledTaskRunner = jest
.fn()
.mockReturnValue(mockTaskRunner);
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
expect(cleaner).toBeInstanceOf(NotificationCleaner);
await cleaner.initTaskRunner();
expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalled();
expect(mockTaskRunner.run).toHaveBeenCalledWith(
expect.objectContaining({
id: 'notification-cleaner',
fn: expect.any(Function),
}),
);
});
it('should not create a task runner if retention is disabled', async () => {
mockConfig = mockServices.rootConfig({
data: { notifications: { retention: false } },
});
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
await cleaner.initTaskRunner();
expect(mockScheduler.createScheduledTaskRunner).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
'Notification retention is disabled, skipping notification cleaner task',
);
});
});
describe('clearNotifications', () => {
it('should clear notifications', async () => {
mockDatabase.clearNotifications = jest
.fn()
.mockResolvedValue({ deletedCount: 1 });
const mockTaskRunner = {
run: jest.fn().mockImplementation(({ fn }) => fn()),
};
mockScheduler.createScheduledTaskRunner = jest
.fn()
.mockReturnValue(mockTaskRunner);
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
await cleaner.initTaskRunner();
expect(mockLogger.info).toHaveBeenCalledWith(
'Starting notification cleaner task',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Notification cleaner task completed successfully, deleted 1 notifications',
);
expect(mockDatabase.clearNotifications).toHaveBeenCalledWith({
maxAge: { years: 1 },
});
});
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
LoggerService,
SchedulerService,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Config, readDurationFromConfig } from '@backstage/config';
import { NotificationsStore } from '../database';
import { HumanDuration } from '@backstage/types';
import { ForwardedError } from '@backstage/errors';
export class NotificationCleaner {
private readonly retention: HumanDuration = { years: 1 };
private readonly enabled: boolean = true;
constructor(
config: Config,
private readonly scheduler: SchedulerService,
private readonly logger: LoggerService,
private readonly database: NotificationsStore,
) {
if (config.has('notifications.retention')) {
const retentionConfig = config.get('notifications.retention');
if (typeof retentionConfig === 'boolean' && !retentionConfig) {
logger.info(
'Notification retention is disabled, skipping notification cleaner task',
);
this.enabled = false;
return;
}
this.retention = readDurationFromConfig(config, {
key: 'notifications.retention',
});
}
}
async initTaskRunner() {
if (!this.enabled) {
return;
}
const schedule: SchedulerServiceTaskScheduleDefinition = {
frequency: { cron: '0 0 * * *' },
timeout: { hours: 1 },
initialDelay: { hours: 1 },
scope: 'global',
};
const taskRunner = this.scheduler.createScheduledTaskRunner(schedule);
await taskRunner.run({
id: 'notification-cleaner',
fn: async () => {
await this.clearNotifications(
this.logger,
this.database,
this.retention,
);
},
});
}
private async clearNotifications(
logger: LoggerService,
database: NotificationsStore,
retention: HumanDuration,
) {
logger.info('Starting notification cleaner task');
try {
const result = await database.clearNotifications({ maxAge: retention });
logger.info(
`Notification cleaner task completed successfully, deleted ${result.deletedCount} notifications`,
);
} catch (error) {
throw new ForwardedError('Notification cleaner task failed', error);
}
}
}
@@ -29,8 +29,10 @@ import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { DatabaseService } from '@backstage/backend-plugin-api';
import { v4 as uuid } from 'uuid';
import { DatabaseNotificationsStore } from '../database';
const databases = TestDatabases.create();
let store: DatabaseNotificationsStore;
async function createDatabase(
databaseId: TestDatabaseId,
@@ -83,6 +85,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
database = await createDatabase(databaseId);
store = await DatabaseNotificationsStore.create({
database,
});
});
describe('POST /notifications', () => {
@@ -93,7 +98,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -460,7 +465,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -550,7 +555,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -600,7 +605,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -17,9 +17,9 @@
import express, { Request, Response } from 'express';
import Router from 'express-promise-router';
import {
DatabaseNotificationsStore,
normalizeSeverity,
NotificationGetOptions,
NotificationsStore,
TopicGetOptions,
} from '../database';
import { v4 as uuid } from 'uuid';
@@ -31,7 +31,6 @@ import {
import { InputError, NotFoundError } from '@backstage/errors';
import {
AuthService,
DatabaseService,
HttpAuthService,
LoggerService,
UserInfoService,
@@ -58,7 +57,7 @@ import pThrottle from 'p-throttle';
export interface RouterOptions {
logger: LoggerService;
config: Config;
database: DatabaseService;
store: NotificationsStore;
auth: AuthService;
httpAuth: HttpAuthService;
userInfo: UserInfoService;
@@ -74,7 +73,7 @@ export async function createRouter(
const {
config,
logger,
database,
store,
auth,
httpAuth,
userInfo,
@@ -84,7 +83,6 @@ export async function createRouter(
} = options;
const WEB_NOTIFICATION_CHANNEL = 'Web';
const store = await DatabaseNotificationsStore.create({ database });
const frontendBaseUrl = config.getString('app.baseUrl');
const concurrencyLimit =
config.getOptionalNumber('notifications.concurrencyLimit') ?? 10;
@@ -22,7 +22,7 @@ import {
} from '@backstage/core-components';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateCardProps, TemplateCard } from '../TemplateCard';
import { IconComponent } from '@backstage/core-plugin-api';
import { AnalyticsContext, IconComponent } from '@backstage/core-plugin-api';
/**
* The props for the {@link TemplateGroup} component.
@@ -69,12 +69,18 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
{titleComponent}
<ItemCardGrid>
{templates.map(({ template, additionalLinks }) => (
<Card
<AnalyticsContext
attributes={{
entityRef: stringifyEntityRef(template),
}}
key={stringifyEntityRef(template)}
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
>
<Card
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
</AnalyticsContext>
))}
</ItemCardGrid>
</Content>
@@ -22,7 +22,7 @@ import {
} from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
import Typography from '@material-ui/core/Typography';
import { ComponentType, useCallback } from 'react';
import { ComponentType, useCallback, useEffect } from 'react';
import { TemplateGroup } from '../TemplateGroup/TemplateGroup';
@@ -58,12 +58,17 @@ export const TemplateGroups = (props: TemplateGroupsProps) => {
[onTemplateSelected],
);
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
if (loading) {
return <Progress />;
}
if (error) {
errorApi.post(error);
return null;
}
@@ -97,13 +97,14 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
async (formState: Record<string, JsonValue>) => {
await onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
analytics.captureEvent('create', name ?? templateName ?? 'unknown', {
analytics.captureEvent('create', 'Task has been created', {
value: minutesSaved,
attributes: {
templateSteps: sortedManifest?.steps?.length ?? 0,
},
});
},
[onCreate, analytics, templateName, minutesSaved],
[onCreate, analytics, minutesSaved, sortedManifest],
);
useEffect(() => {
@@ -16,6 +16,7 @@
import cloneDeep from 'lodash/cloneDeep';
import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api';
import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react';
import { useMemo } from 'react';
/**
* Returns manifest of software templates with steps without a featureFlag tag.
@@ -28,49 +29,51 @@ export const useFilteredSchemaProperties = (
const featureFlagKey = 'backstage:featureFlag';
const featureFlagApi = useApi(featureFlagsApiRef);
if (!manifest) {
return undefined;
}
const filteredSteps = manifest?.steps
.filter(step => {
const featureFlag = step.schema[featureFlagKey];
return (
typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag)
);
})
.map(step => {
const filteredStep = cloneDeep(step);
const removedPropertyKeys: Array<string> = [];
if (filteredStep.schema.properties) {
filteredStep.schema.properties = Object.fromEntries(
Object.entries(filteredStep.schema.properties).filter(
([key, value]) => {
if (value[featureFlagKey]) {
if (featureFlagApi.isActive(value[featureFlagKey])) {
return true;
}
removedPropertyKeys.push(key);
return false;
}
return true;
},
),
return useMemo(() => {
if (!manifest) {
return undefined;
}
const filteredSteps = manifest?.steps
.filter(step => {
const featureFlag = step.schema[featureFlagKey];
return (
typeof featureFlag !== 'string' ||
featureFlagApi.isActive(featureFlag)
);
})
.map(step => {
const filteredStep = cloneDeep(step);
const removedPropertyKeys: Array<string> = [];
if (filteredStep.schema.properties) {
filteredStep.schema.properties = Object.fromEntries(
Object.entries(filteredStep.schema.properties).filter(
([key, value]) => {
if (value[featureFlagKey]) {
if (featureFlagApi.isActive(value[featureFlagKey])) {
return true;
}
// remove the feature flag property key from required if they are not active
filteredStep.schema.required = Array.isArray(
filteredStep.schema.required,
)
? filteredStep.schema.required?.filter(
r => !removedPropertyKeys.includes(r as string),
)
: filteredStep.schema.required;
}
removedPropertyKeys.push(key);
return false;
}
return true;
},
),
);
return filteredStep;
});
// remove the feature flag property key from required if they are not active
filteredStep.schema.required = Array.isArray(
filteredStep.schema.required,
)
? filteredStep.schema.required?.filter(
r => !removedPropertyKeys.includes(r as string),
)
: filteredStep.schema.required;
}
return { ...manifest, steps: filteredSteps };
return filteredStep;
});
return { ...manifest, steps: filteredSteps };
}, [manifest, featureFlagApi]);
};
@@ -21,7 +21,7 @@ import {
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import {
ScaffolderApi,
scaffolderApiRef,
@@ -127,14 +127,10 @@ describe('TemplateWizardPage', () => {
});
// Go to the final page
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Review' }));
});
fireEvent.click(await findByRole('button', { name: 'Review' }));
// Create the software
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Create' }));
});
fireEvent.click(await findByRole('button', { name: 'Create' }));
// The "Next Step" button should have fired an event
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
@@ -148,15 +144,20 @@ describe('TemplateWizardPage', () => {
);
// And the "Create" button should have fired an event
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create',
subject: 'expected-name',
context: expect.objectContaining({
entityRef: 'template:default/test',
await waitFor(() =>
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create',
subject: 'Task has been created',
attributes: {
templateSteps: 1,
},
context: expect.objectContaining({
entityRef: 'template:default/test',
}),
value: 120,
}),
value: 120,
}),
),
);
});
@@ -26,6 +26,7 @@ import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { Link } from '@backstage/core-components';
const useStyles = makeStyles(theme => ({
button: {
@@ -82,7 +83,7 @@ export function TemplateWizardPageContextMenu(
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuList>
<MenuItem onClick={() => window.open(editUrl, '_blank')}>
<MenuItem component={Link} to={editUrl}>
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
@@ -21,7 +21,6 @@ import MenuItem from '@material-ui/core/MenuItem';
import MenuList from '@material-ui/core/MenuList';
import Popover from '@material-ui/core/Popover';
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
import { useAsync } from '@react-hookz/web';
import Cancel from '@material-ui/icons/Cancel';
import Repeat from '@material-ui/icons/Repeat';
import Replay from '@material-ui/icons/Replay';
@@ -29,11 +28,8 @@ import Toc from '@material-ui/icons/Toc';
import ControlPointIcon from '@material-ui/icons/ControlPoint';
import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { useAnalytics, useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import { usePermission } from '@backstage/plugin-permission-react';
import {
taskCancelPermission,
taskReadPermission,
taskCreatePermission,
} from '@backstage/plugin-scaffolder-common/alpha';
@@ -50,7 +46,8 @@ type ContextMenuProps = {
onStartOver?: () => void;
onToggleLogs?: (state: boolean) => void;
onToggleButtonBar?: (state: boolean) => void;
taskId?: string;
isCancelButtonDisabled: boolean;
onCancel: () => void;
};
const useStyles = makeStyles<Theme, { fontColor: string }>(() => ({
@@ -70,27 +67,13 @@ export const ContextMenu = (props: ContextMenuProps) => {
onStartOver,
onToggleLogs,
onToggleButtonBar,
taskId,
} = props;
const { getPageTheme } = useTheme();
const pageTheme = getPageTheme({ themeId: 'website' });
const classes = useStyles({ fontColor: pageTheme.fontColor });
const scaffolderApi = useApi(scaffolderApiRef);
const analytics = useAnalytics();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const { t } = useTranslationRef(scaffolderTranslationRef);
const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => {
if (taskId) {
analytics.captureEvent('cancelled', 'Template has been cancelled');
await scaffolderApi.cancelTask(taskId);
}
});
const { allowed: canCancelTask } = usePermission({
permission: taskCancelPermission,
});
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
});
@@ -171,12 +154,8 @@ export const ContextMenu = (props: ContextMenuProps) => {
</MenuItem>
)}
<MenuItem
onClick={cancel}
disabled={
!cancelEnabled ||
cancelStatus !== 'not-executed' ||
!canCancelTask
}
onClick={props.onCancel}
disabled={props.isCancelButtonDisabled}
data-testid="cancel-task"
>
<ListItemIcon>
@@ -32,7 +32,12 @@ import {
useTaskEventStream,
} from '@backstage/plugin-scaffolder-react';
import { selectedTemplateRouteRef } from '../../routes';
import { useAnalytics, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
AnalyticsContext,
useAnalytics,
useApi,
useRouteRef,
} from '@backstage/core-plugin-api';
import qs from 'qs';
import { ContextMenu } from './ContextMenu';
import {
@@ -51,6 +56,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import { default as reactUseAsync } from 'react-use/esm/useAsync';
import { stringifyEntityRef } from '@backstage/catalog-model';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -82,6 +88,36 @@ export const OngoingTask = (props: {
}>;
}) => {
// todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful.
const { taskId } = useParams();
const taskStream = useTaskEventStream(taskId!);
const { namespace, name } =
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
return (
<AnalyticsContext
attributes={{
entityRef:
name &&
stringifyEntityRef({
kind: 'template',
namespace,
name,
}),
taskId,
}}
>
<Page themeId="website">
<OngoingTaskContent {...props} />
</Page>
</AnalyticsContext>
);
};
function OngoingTaskContent(props: {
TemplateOutputsComponent?: ComponentType<{
output?: ScaffolderTaskOutput;
}>;
}) {
const { taskId } = useParams();
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
const navigate = useNavigate();
@@ -183,7 +219,7 @@ export const OngoingTask = (props: {
templateRouteRef,
]);
const [{ status: _ }, { execute: triggerRetry }] = useAsync(async () => {
const [, { execute: triggerRetry }] = useAsync(async () => {
if (taskId) {
analytics.captureEvent('retried', 'Template has been retried');
await scaffolderApi.retry?.(taskId);
@@ -202,9 +238,11 @@ export const OngoingTask = (props: {
const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs;
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
const isCancelButtonDisabled =
!cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask;
return (
<Page themeId="website">
<>
<Header
pageTitleOverride={
presentation
@@ -231,7 +269,8 @@ export const OngoingTask = (props: {
onRetry={triggerRetry}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
taskId={taskId}
onCancel={triggerCancel}
isCancelButtonDisabled={isCancelButtonDisabled}
/>
</Header>
<Content className={classes.contentWrapper}>
@@ -316,6 +355,6 @@ export const OngoingTask = (props: {
</Paper>
) : null}
</Content>
</Page>
</>
);
};
}
+204 -171
View File
@@ -3602,6 +3602,7 @@ __metadata:
cron: "npm:^3.0.0"
express: "npm:^4.17.1"
express-promise-router: "npm:^4.1.0"
express-rate-limit: "npm:^7.5.0"
fs-extra: "npm:^11.2.0"
git-url-parse: "npm:^15.0.0"
helmet: "npm:^6.0.0"
@@ -3624,6 +3625,7 @@ __metadata:
pg: "npm:^8.11.3"
pg-connection-string: "npm:^2.3.0"
pg-format: "npm:^1.0.4"
rate-limit-redis: "npm:^4.2.0"
raw-body: "npm:^2.4.1"
selfsigned: "npm:^2.0.0"
supertest: "npm:^7.0.0"
@@ -9667,9 +9669,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/aix-ppc64@npm:0.25.4"
"@esbuild/aix-ppc64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/aix-ppc64@npm:0.25.5"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
@@ -9681,9 +9683,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm64@npm:0.25.4"
"@esbuild/android-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/android-arm64@npm:0.25.5"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
@@ -9695,9 +9697,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm@npm:0.25.4"
"@esbuild/android-arm@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/android-arm@npm:0.25.5"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
@@ -9709,9 +9711,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-x64@npm:0.25.4"
"@esbuild/android-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/android-x64@npm:0.25.5"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
@@ -9723,9 +9725,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-arm64@npm:0.25.4"
"@esbuild/darwin-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/darwin-arm64@npm:0.25.5"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -9737,9 +9739,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-x64@npm:0.25.4"
"@esbuild/darwin-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/darwin-x64@npm:0.25.5"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -9751,9 +9753,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-arm64@npm:0.25.4"
"@esbuild/freebsd-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/freebsd-arm64@npm:0.25.5"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
@@ -9765,9 +9767,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-x64@npm:0.25.4"
"@esbuild/freebsd-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/freebsd-x64@npm:0.25.5"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
@@ -9779,9 +9781,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm64@npm:0.25.4"
"@esbuild/linux-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-arm64@npm:0.25.5"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
@@ -9793,9 +9795,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm@npm:0.25.4"
"@esbuild/linux-arm@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-arm@npm:0.25.5"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
@@ -9807,9 +9809,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ia32@npm:0.25.4"
"@esbuild/linux-ia32@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-ia32@npm:0.25.5"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
@@ -9821,9 +9823,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-loong64@npm:0.25.4"
"@esbuild/linux-loong64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-loong64@npm:0.25.5"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
@@ -9835,9 +9837,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-mips64el@npm:0.25.4"
"@esbuild/linux-mips64el@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-mips64el@npm:0.25.5"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
@@ -9849,9 +9851,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ppc64@npm:0.25.4"
"@esbuild/linux-ppc64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-ppc64@npm:0.25.5"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
@@ -9863,9 +9865,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-riscv64@npm:0.25.4"
"@esbuild/linux-riscv64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-riscv64@npm:0.25.5"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
@@ -9877,9 +9879,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-s390x@npm:0.25.4"
"@esbuild/linux-s390x@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-s390x@npm:0.25.5"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
@@ -9891,16 +9893,16 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-x64@npm:0.25.4"
"@esbuild/linux-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/linux-x64@npm:0.25.5"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-arm64@npm:0.25.4"
"@esbuild/netbsd-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/netbsd-arm64@npm:0.25.5"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
@@ -9912,16 +9914,16 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-x64@npm:0.25.4"
"@esbuild/netbsd-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/netbsd-x64@npm:0.25.5"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-arm64@npm:0.25.4"
"@esbuild/openbsd-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/openbsd-arm64@npm:0.25.5"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
@@ -9933,9 +9935,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-x64@npm:0.25.4"
"@esbuild/openbsd-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/openbsd-x64@npm:0.25.5"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
@@ -9947,9 +9949,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/sunos-x64@npm:0.25.4"
"@esbuild/sunos-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/sunos-x64@npm:0.25.5"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
@@ -9961,9 +9963,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-arm64@npm:0.25.4"
"@esbuild/win32-arm64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/win32-arm64@npm:0.25.5"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -9975,9 +9977,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-ia32@npm:0.25.4"
"@esbuild/win32-ia32@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/win32-ia32@npm:0.25.5"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
@@ -9989,9 +9991,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-x64@npm:0.25.4"
"@esbuild/win32-x64@npm:0.25.5":
version: 0.25.5
resolution: "@esbuild/win32-x64@npm:0.25.5"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -12414,9 +12416,9 @@ __metadata:
languageName: node
linkType: hard
"@mswjs/interceptors@npm:^0.37.0":
version: 0.37.1
resolution: "@mswjs/interceptors@npm:0.37.1"
"@mswjs/interceptors@npm:^0.39.1":
version: 0.39.2
resolution: "@mswjs/interceptors@npm:0.39.2"
dependencies:
"@open-draft/deferred-promise": "npm:^2.2.0"
"@open-draft/logger": "npm:^0.3.0"
@@ -12424,7 +12426,7 @@ __metadata:
is-node-process: "npm:^1.2.0"
outvariant: "npm:^1.4.3"
strict-event-emitter: "npm:^0.5.1"
checksum: 10/332d8aa50beb4834ccbda6a800ca00b1204adc0eba23e1c1f7bb9f4e564a92707e563f7a2424d4a8607404ec91424e5d8c34a87c250b191ca7b24dff12eba2c5
checksum: 10/faaa95d636363a197f125c32066457fa74d5063d8ccae4c9c0e0510179060d92b1faf8640df45a0623e0bf42a30d610c83364a58e0eb0ca412c87b2e835936c1
languageName: node
linkType: hard
@@ -13381,10 +13383,10 @@ __metadata:
languageName: node
linkType: hard
"@octokit/openapi-types@npm:^22.2.0":
version: 22.2.0
resolution: "@octokit/openapi-types@npm:22.2.0"
checksum: 10/0471b0c789fada5aa2390e6f82ba477738228ef7d2d986dda9aab0cb625d1562bd178ba0ba4d2655ce841079cd5efff9e58ece2077c27e569ea22109ea301830
"@octokit/openapi-types@npm:^24.2.0":
version: 24.2.0
resolution: "@octokit/openapi-types@npm:24.2.0"
checksum: 10/000897ebc6e247c2591049d6081e95eb5636f73798dadd695ee6048496772b58065df88823e74a760201828545a7ac601dd3c1bcd2e00079a62a9ee9d389409c
languageName: node
linkType: hard
@@ -13397,14 +13399,14 @@ __metadata:
languageName: node
linkType: hard
"@octokit/plugin-paginate-rest@npm:11.3.1":
version: 11.3.1
resolution: "@octokit/plugin-paginate-rest@npm:11.3.1"
"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2":
version: 11.4.4-cjs.2
resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2"
dependencies:
"@octokit/types": "npm:^13.5.0"
"@octokit/types": "npm:^13.7.0"
peerDependencies:
"@octokit/core": 5
checksum: 10/82f5bcc3a536a44bed0a205c8301176c0d210b7a1c6d035a79b31a102e2e02f46234a38629cc984a21be544194ac69151814e9a909416aa7389cdffd1297bcd9
checksum: 10/e0f696b3b69febe4e7c736d909065871f38bb8346a07f19a9c83246a02972568ac672667db472f846baef20a9611adf26ce8f0f189a11004c4b6618765078e19
languageName: node
linkType: hard
@@ -13440,14 +13442,14 @@ __metadata:
languageName: node
linkType: hard
"@octokit/plugin-rest-endpoint-methods@npm:13.2.2":
version: 13.2.2
resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.2.2"
"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1":
version: 13.3.2-cjs.1
resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1"
dependencies:
"@octokit/types": "npm:^13.5.0"
"@octokit/types": "npm:^13.8.0"
peerDependencies:
"@octokit/core": ^5
checksum: 10/9eccc1a22aa0b65f3f9378f26a74c386683db420c33202998918df1eef492e93212e1849e1d85530f425602663cfc2bfbf385a30991b8a04470334c74ba2386b
checksum: 10/479827e62466e55bc1a50129d51597807bddc6c909e56be9e8dd9c1a91efa0f466a2f56b7d80438649e21ab0a3a195f840b3fccf2ae7f11fb0a919db8e62bc62
languageName: node
linkType: hard
@@ -13589,12 +13591,12 @@ __metadata:
languageName: node
linkType: hard
"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.5.0":
version: 13.6.2
resolution: "@octokit/types@npm:13.6.2"
"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0":
version: 13.10.0
resolution: "@octokit/types@npm:13.10.0"
dependencies:
"@octokit/openapi-types": "npm:^22.2.0"
checksum: 10/8e614796f3554d28dfb77c570e80ef52d68ef311bdd4614ec263f8ea2266b9c06d4f7963fe2989f32cbfe4ea0c05e13eba9a64a6e0f64afb997cd975af154d52
"@octokit/openapi-types": "npm:^24.2.0"
checksum: 10/32f8f5010d7faae128b0cdd0c221f0ca8c3781fe44483ecd87162b3da507db667f7369acda81340f6e2c9c374d9a938803409c6085c2c01d98210b6c58efb99a
languageName: node
linkType: hard
@@ -13641,10 +13643,10 @@ __metadata:
languageName: node
linkType: hard
"@octokit/webhooks-methods@npm:^4.0.0":
version: 4.0.0
resolution: "@octokit/webhooks-methods@npm:4.0.0"
checksum: 10/f26892ed868488bf08d5be1fdacbc51f5b6ba84cef21067e0b1ff969d087202989e74303049691a77c75bb1940e7835ad6522b0f4f151ceef015e6d083789c80
"@octokit/webhooks-methods@npm:^4.1.0":
version: 4.1.0
resolution: "@octokit/webhooks-methods@npm:4.1.0"
checksum: 10/a95ad68600c43798b09ea29d5a356fb69de25b45d38fbddf0ade00aadb0492b1d59985031e072a66d12e91034999a536653e5d2d4d01350d38cccf735d9ca270
languageName: node
linkType: hard
@@ -13655,10 +13657,10 @@ __metadata:
languageName: node
linkType: hard
"@octokit/webhooks-types@npm:7.1.0":
version: 7.1.0
resolution: "@octokit/webhooks-types@npm:7.1.0"
checksum: 10/80b41945586243df9178a24dce9a5c4b2784eb963c5f6a1c76bcf5600a56e9cb51d8dc4a0da2108abfab3323cde577474a6365615fa1dfaac84c14e9295b54f9
"@octokit/webhooks-types@npm:7.6.1":
version: 7.6.1
resolution: "@octokit/webhooks-types@npm:7.6.1"
checksum: 10/0b11bd7e8d13b5a9cf14214421298a423d0180a5e1aaaea876ee4db6f97b5cca536f48d89af63105db75419d777a2402733eb0e110002d4dd59581ef36037bdc
languageName: node
linkType: hard
@@ -13674,15 +13676,15 @@ __metadata:
languageName: node
linkType: hard
"@octokit/webhooks@npm:^12.0.4":
version: 12.0.10
resolution: "@octokit/webhooks@npm:12.0.10"
"@octokit/webhooks@npm:^12.0.4, @octokit/webhooks@npm:^12.3.1":
version: 12.3.1
resolution: "@octokit/webhooks@npm:12.3.1"
dependencies:
"@octokit/request-error": "npm:^5.0.0"
"@octokit/webhooks-methods": "npm:^4.0.0"
"@octokit/webhooks-types": "npm:7.1.0"
"@octokit/webhooks-methods": "npm:^4.1.0"
"@octokit/webhooks-types": "npm:7.6.1"
aggregate-error: "npm:^3.1.0"
checksum: 10/ab7d216d1a1fae91bc3f75057c093707c1f6dbd5ff2106656154a7f471b1a1b5b78c947474e21362590e5510516f9537c4ab79ad6780cc91bfaaaa685aa366e0
checksum: 10/373266807eb8dcf8d8c6f4685594106f1a257798a1b391cbe36e5b5faf60aca38a0467acd1356427ea7375edc111c03be3d443bc04efb16019a84e853c8a1bc3
languageName: node
linkType: hard
@@ -15136,13 +15138,13 @@ __metadata:
linkType: hard
"@playwright/test@npm:^1.32.3":
version: 1.52.0
resolution: "@playwright/test@npm:1.52.0"
version: 1.53.0
resolution: "@playwright/test@npm:1.53.0"
dependencies:
playwright: "npm:1.52.0"
playwright: "npm:1.53.0"
bin:
playwright: cli.js
checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88
checksum: 10/968df4fba133dd18b8c65504c3cc5a3a6071e49f0706c6524711cdfab321a51debfeb506b9ff0a8f7dd8ce3015921d82fa51429d8f11d392cc68de1938703c33
languageName: node
linkType: hard
@@ -22439,9 +22441,9 @@ __metadata:
linkType: hard
"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3":
version: 15.7.14
resolution: "@types/prop-types@npm:15.7.14"
checksum: 10/d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d
version: 15.7.15
resolution: "@types/prop-types@npm:15.7.15"
checksum: 10/31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356
languageName: node
linkType: hard
@@ -24036,7 +24038,7 @@ __metadata:
languageName: node
linkType: hard
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0":
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0, @yarnpkg/core@npm:^4.4.1":
version: 4.4.1
resolution: "@yarnpkg/core@npm:4.4.1"
dependencies:
@@ -25748,7 +25750,7 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:1.9.0, axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8":
"axios@npm:1.9.0":
version: 1.9.0
resolution: "axios@npm:1.9.0"
dependencies:
@@ -25759,6 +25761,17 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8":
version: 1.10.0
resolution: "axios@npm:1.10.0"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0"
checksum: 10/d43c80316a45611fd395743e15d16ea69a95f2b7f7095f2bb12cb78f9ca0a905194a02e52a3bf4e0db9f85fd1186d6c690410644c10ecd8bb0a468e57c2040e4
languageName: node
linkType: hard
"axobject-query@npm:^4.1.0":
version: 4.1.0
resolution: "axobject-query@npm:4.1.0"
@@ -29468,8 +29481,8 @@ __metadata:
linkType: hard
"dockerode@npm:^4.0.0":
version: 4.0.6
resolution: "dockerode@npm:4.0.6"
version: 4.0.7
resolution: "dockerode@npm:4.0.7"
dependencies:
"@balena/dockerignore": "npm:^1.0.2"
"@grpc/grpc-js": "npm:^1.11.1"
@@ -29478,7 +29491,7 @@ __metadata:
protobufjs: "npm:^7.3.2"
tar-fs: "npm:~2.1.2"
uuid: "npm:^10.0.0"
checksum: 10/75bd706f20f01742d22913b72e2a5215a4d9f79772c29079772f84fc41a2b1890a704a1aa3d1d764405367090e93c33197d6add19fde3546bac919d98eebc8e3
checksum: 10/d7cd174cf4489f41335ec8aaaa7c98c164a624f9a793544aa5280d85254ce276e7797de896042ce47d87aca6f8d2653acc37a0d18807d4ce8ea31892faef40a8
languageName: node
linkType: hard
@@ -30327,34 +30340,34 @@ __metadata:
linkType: hard
"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0, esbuild@npm:^0.25.0":
version: 0.25.4
resolution: "esbuild@npm:0.25.4"
version: 0.25.5
resolution: "esbuild@npm:0.25.5"
dependencies:
"@esbuild/aix-ppc64": "npm:0.25.4"
"@esbuild/android-arm": "npm:0.25.4"
"@esbuild/android-arm64": "npm:0.25.4"
"@esbuild/android-x64": "npm:0.25.4"
"@esbuild/darwin-arm64": "npm:0.25.4"
"@esbuild/darwin-x64": "npm:0.25.4"
"@esbuild/freebsd-arm64": "npm:0.25.4"
"@esbuild/freebsd-x64": "npm:0.25.4"
"@esbuild/linux-arm": "npm:0.25.4"
"@esbuild/linux-arm64": "npm:0.25.4"
"@esbuild/linux-ia32": "npm:0.25.4"
"@esbuild/linux-loong64": "npm:0.25.4"
"@esbuild/linux-mips64el": "npm:0.25.4"
"@esbuild/linux-ppc64": "npm:0.25.4"
"@esbuild/linux-riscv64": "npm:0.25.4"
"@esbuild/linux-s390x": "npm:0.25.4"
"@esbuild/linux-x64": "npm:0.25.4"
"@esbuild/netbsd-arm64": "npm:0.25.4"
"@esbuild/netbsd-x64": "npm:0.25.4"
"@esbuild/openbsd-arm64": "npm:0.25.4"
"@esbuild/openbsd-x64": "npm:0.25.4"
"@esbuild/sunos-x64": "npm:0.25.4"
"@esbuild/win32-arm64": "npm:0.25.4"
"@esbuild/win32-ia32": "npm:0.25.4"
"@esbuild/win32-x64": "npm:0.25.4"
"@esbuild/aix-ppc64": "npm:0.25.5"
"@esbuild/android-arm": "npm:0.25.5"
"@esbuild/android-arm64": "npm:0.25.5"
"@esbuild/android-x64": "npm:0.25.5"
"@esbuild/darwin-arm64": "npm:0.25.5"
"@esbuild/darwin-x64": "npm:0.25.5"
"@esbuild/freebsd-arm64": "npm:0.25.5"
"@esbuild/freebsd-x64": "npm:0.25.5"
"@esbuild/linux-arm": "npm:0.25.5"
"@esbuild/linux-arm64": "npm:0.25.5"
"@esbuild/linux-ia32": "npm:0.25.5"
"@esbuild/linux-loong64": "npm:0.25.5"
"@esbuild/linux-mips64el": "npm:0.25.5"
"@esbuild/linux-ppc64": "npm:0.25.5"
"@esbuild/linux-riscv64": "npm:0.25.5"
"@esbuild/linux-s390x": "npm:0.25.5"
"@esbuild/linux-x64": "npm:0.25.5"
"@esbuild/netbsd-arm64": "npm:0.25.5"
"@esbuild/netbsd-x64": "npm:0.25.5"
"@esbuild/openbsd-arm64": "npm:0.25.5"
"@esbuild/openbsd-x64": "npm:0.25.5"
"@esbuild/sunos-x64": "npm:0.25.5"
"@esbuild/win32-arm64": "npm:0.25.5"
"@esbuild/win32-ia32": "npm:0.25.5"
"@esbuild/win32-x64": "npm:0.25.5"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@@ -30408,7 +30421,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10/227ffe9b31f0b184a0b0a0210bb9d32b2b115b8c5c9b09f08db2c3928cb470fc55a22dbba3c2894365d3abcc62c2089b85638be96a20691d1234d31990ea01b2
checksum: 10/0fa4c3b42c6ddf1a008e75a4bb3dcab08ce22ac0b31dd59dc01f7fe8e21380bfaec07a2fe3730a7cf430da5a30142d016714b358666325a4733547afa42be405
languageName: node
linkType: hard
@@ -31437,6 +31450,15 @@ __metadata:
languageName: node
linkType: hard
"express-rate-limit@npm:^7.5.0":
version: 7.5.0
resolution: "express-rate-limit@npm:7.5.0"
peerDependencies:
express: ^4.11 || 5 || ^5.0.0-beta.1
checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca
languageName: node
linkType: hard
"express-session@npm:^1.17.1, express-session@npm:^1.17.3":
version: 1.18.1
resolution: "express-session@npm:1.18.1"
@@ -39670,14 +39692,14 @@ __metadata:
linkType: hard
"msw@npm:^2.0.0, msw@npm:^2.0.8":
version: 2.8.2
resolution: "msw@npm:2.8.2"
version: 2.10.2
resolution: "msw@npm:2.10.2"
dependencies:
"@bundled-es-modules/cookie": "npm:^2.0.1"
"@bundled-es-modules/statuses": "npm:^1.0.1"
"@bundled-es-modules/tough-cookie": "npm:^0.1.6"
"@inquirer/confirm": "npm:^5.0.0"
"@mswjs/interceptors": "npm:^0.37.0"
"@mswjs/interceptors": "npm:^0.39.1"
"@open-draft/deferred-promise": "npm:^2.2.0"
"@open-draft/until": "npm:^2.1.0"
"@types/cookie": "npm:^0.6.0"
@@ -39698,7 +39720,7 @@ __metadata:
optional: true
bin:
msw: cli/index.js
checksum: 10/7579a8dccb8cc8eb0f13d0bf3a232a3d50a478511d95bc2a4b70778e78ffa28fd0949a855fbceb6ec381bbf76f6331a6d36fcb4a3197ff5bf55d0f14a7f3b35c
checksum: 10/bc90bc34a0b9e978e662f33fa630a0de66c4b6eff3a92b41efa08bf67d79b43e4a961a17b0d274580393c912f4ac6226c76a8bc33148799b1ee43477df712c26
languageName: node
linkType: hard
@@ -40763,20 +40785,21 @@ __metadata:
linkType: hard
"octokit@npm:^3.0.0":
version: 3.2.1
resolution: "octokit@npm:3.2.1"
version: 3.2.2
resolution: "octokit@npm:3.2.2"
dependencies:
"@octokit/app": "npm:^14.0.2"
"@octokit/core": "npm:^5.0.0"
"@octokit/oauth-app": "npm:^6.0.0"
"@octokit/plugin-paginate-graphql": "npm:^4.0.0"
"@octokit/plugin-paginate-rest": "npm:11.3.1"
"@octokit/plugin-rest-endpoint-methods": "npm:13.2.2"
"@octokit/plugin-paginate-rest": "npm:11.4.4-cjs.2"
"@octokit/plugin-rest-endpoint-methods": "npm:13.3.2-cjs.1"
"@octokit/plugin-retry": "npm:^6.0.0"
"@octokit/plugin-throttling": "npm:^8.0.0"
"@octokit/request-error": "npm:^5.0.0"
"@octokit/types": "npm:^13.0.0"
checksum: 10/a3539831c9c0828b1e37dc94a3668f5fb9aa873fc396c0464275d152e4065b6b20300cfba766871cb52ed2e6d537febda7d4be872894c4c62024393a3578fde0
"@octokit/webhooks": "npm:^12.3.1"
checksum: 10/a258cc62767552fcf9d9a2dfb2aac44b326a2a235fb6444c1f37bc018bda4ac4bf0203704d8b7b3955d3caee6f9256c200287f7e99843c8be01febe4d9fe86d8
languageName: node
linkType: hard
@@ -42152,27 +42175,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.52.0":
version: 1.52.0
resolution: "playwright-core@npm:1.52.0"
"playwright-core@npm:1.53.0":
version: 1.53.0
resolution: "playwright-core@npm:1.53.0"
bin:
playwright-core: cli.js
checksum: 10/42e13f5f98dc25ebc95525fb338a215b9097b2ba39d41e99972a190bf75d79979f163f5bc07b1ca06847ee07acb2c9b487d070fab67e9cd55e33310fc05aca3c
checksum: 10/881f27a9b7edd9954700489a5a4212cb91bcada226fd1d79a239b2eab0f333df1e2e41e275e6fa846d7f57c6a92afe14dca33ca7a2ce303dfb687d02511b7c69
languageName: node
linkType: hard
"playwright@npm:1.52.0":
version: 1.52.0
resolution: "playwright@npm:1.52.0"
"playwright@npm:1.53.0":
version: 1.53.0
resolution: "playwright@npm:1.53.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.52.0"
playwright-core: "npm:1.53.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec
checksum: 10/0b0258630f39b4d6ff1555d008ee4d591fe45cbe1e0f643a612397e3e6b1f7a99a2037a957eaa7351edd907ba10966ba105b2d244eafd1b247378910b660f086
languageName: node
linkType: hard
@@ -43414,6 +43437,15 @@ __metadata:
languageName: node
linkType: hard
"rate-limit-redis@npm:^4.2.0":
version: 4.2.1
resolution: "rate-limit-redis@npm:4.2.1"
peerDependencies:
express-rate-limit: ">= 6"
checksum: 10/c36c50cfca992cbd14c97c08bb01c7d1d340d27b5fa1a2c1154275fec0b59c187eb6fb207da3d035c7c87e7629c92324cd53074f854dbb16548faf94f5a41351
languageName: node
linkType: hard
"rate-limiter-flexible@npm:^4.0.1":
version: 4.0.1
resolution: "rate-limiter-flexible@npm:4.0.1"
@@ -50644,8 +50676,9 @@ __metadata:
"@backstage/cli-common": "workspace:^"
"@backstage/release-manifests": "workspace:^"
"@yarnpkg/builder": "npm:^4.2.1"
"@yarnpkg/core": "npm:^4.4.0"
"@yarnpkg/core": "npm:^4.4.1"
"@yarnpkg/fslib": "npm:^3.1.2"
"@yarnpkg/plugin-essentials": "npm:^4.4.0"
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch"
"@yarnpkg/plugin-pack": "npm:^4.0.1"
fs-extra: "npm:^11.2.0"