Merge branch 'master' of https://github.com/backstage/backstage into fix/scaffolder-after-zero-query-param

This commit is contained in:
suhr25
2026-04-09 10:50:16 +05:30
842 changed files with 21947 additions and 6976 deletions
+43 -2
View File
@@ -1,5 +1,46 @@
# @backstage/plugin-scaffolder-backend
## 3.4.0-next.2
### Minor Changes
- 5af48e7: Migrated permission registration to use the `PermissionsRegistryService` instead of the deprecated `createPermissionIntegrationRouter`. This fixes an issue where scaffolder permissions were not visible to RBAC plugins because the `actionsRegistryServiceRef` dependency caused an empty permissions metadata router to shadow the scaffolder's actual permission metadata. The old `createPermissionIntegrationRouter` path is retained as a fallback for standalone `createRouter` usage.
### Patch Changes
- 482ceed: Migrated from `assertError` to `toError` for error handling.
- 961e274: Migrated OpenTelemetry metrics to use the `MetricsService` from `@backstage/backend-plugin-api/alpha` instead of the raw `@opentelemetry/api` meter.
- Updated dependencies
- @backstage/errors@1.3.0-next.0
- @backstage/plugin-catalog-node@2.2.0-next.2
- @backstage/plugin-scaffolder-node@0.13.2-next.2
- @backstage/integration@2.0.1-next.0
- @backstage/backend-openapi-utils@0.6.8-next.2
- @backstage/backend-plugin-api@1.9.0-next.2
- @backstage/catalog-model@1.7.8-next.0
- @backstage/config@1.3.7-next.0
- @backstage/plugin-events-node@0.4.21-next.2
- @backstage/plugin-permission-common@0.9.8-next.0
- @backstage/plugin-permission-node@0.10.12-next.2
- @backstage/plugin-scaffolder-common@2.0.1-next.0
## 3.3.0-next.1
### Minor Changes
- 309b712: Added a new `execute-template` actions registry action that executes a scaffolder template with provided input values and returns a task ID for tracking progress.
### Patch Changes
- 4559806: Removed unnecessary empty `examples` array from actions bridged via the actions registry.
- Updated dependencies
- @backstage/backend-plugin-api@1.9.0-next.1
- @backstage/backend-openapi-utils@0.6.8-next.1
- @backstage/plugin-catalog-node@2.1.1-next.1
- @backstage/plugin-events-node@0.4.21-next.1
- @backstage/plugin-permission-node@0.10.12-next.1
- @backstage/plugin-scaffolder-node@0.13.1-next.1
## 3.2.1-next.0
### Patch Changes
@@ -5634,7 +5675,7 @@
- `step`: The name of the step that was run
- `result`: A string describing whether the task ran successfully, failed, or was skipped
You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/prometheus-metrics.md
You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/384b7bac2e/contrib/docs/tutorials/prometheus-metrics.md
- 5921b5ce49: - The GitLab Project ID for the `publish:gitlab:merge-request` action is now passed through the query parameter `project` in the `repoUrl`. It still allows people to not use the `projectid` and use the `repoUrl` with the `owner` and `repo` query parameters instead. This makes it easier to publish to repositories instead of writing the full path to the project.
- 5025d2e8b6: Adds the ability to pass (an optional) array of strings that will be applied to the newly scaffolded repository as topic labels.
@@ -5727,7 +5768,7 @@
- `step`: The name of the step that was run
- `result`: A string describing whether the task ran successfully, failed, or was skipped
You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/prometheus-metrics.md
You can find a guide for running Prometheus metrics here: https://github.com/backstage/backstage/blob/384b7bac2e/contrib/docs/tutorials/prometheus-metrics.md
### Patch Changes
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "3.2.1-next.0",
"version": "3.4.0-next.2",
"description": "The Backstage backend plugin that helps you create new things",
"backstage": {
"role": "backend-plugin",
@@ -78,7 +78,6 @@
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/types": "workspace:^",
"@opentelemetry/api": "^1.9.0",
"@types/luxon": "^3.0.0",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
@@ -27,6 +27,12 @@ import { stringifyEntityRef } from '@backstage/catalog-model';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { scaffolderAutocompleteExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import {
scaffolderPermissions,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TASK,
} from '@backstage/plugin-scaffolder-common/alpha';
import { scaffolderPlugin } from './ScaffolderPlugin';
@@ -1216,6 +1222,32 @@ describe('scaffolderPlugin', () => {
});
});
it('exposes permissions metadata via the well-known endpoint', async () => {
const { server } = await startTestBackend({
features: [scaffolderPlugin],
});
const { body, status } = await request(server).get(
'/api/scaffolder/.well-known/backstage/permissions/metadata',
);
expect(status).toBe(200);
const permissionNames = body.permissions.map(
(p: { name: string }) => p.name,
);
for (const permission of scaffolderPermissions) {
expect(permissionNames).toContain(permission.name);
}
const ruleResourceTypes = body.rules.map(
(r: { resourceType: string }) => r.resourceType,
);
expect(ruleResourceTypes).toContain(RESOURCE_TYPE_SCAFFOLDER_TEMPLATE);
expect(ruleResourceTypes).toContain(RESOURCE_TYPE_SCAFFOLDER_ACTION);
expect(ruleResourceTypes).toContain(RESOURCE_TYPE_SCAFFOLDER_TASK);
});
it('supports listing templating extensions', async () => {
const { server } = await startTestBackend({
features: [scaffolderPlugin],
@@ -63,6 +63,7 @@ import {
import {
actionsServiceRef,
actionsRegistryServiceRef,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { createScaffolderActions } from './actions';
@@ -141,6 +142,7 @@ export const scaffolderPlugin = createBackendPlugin({
lifecycle: coreServices.rootLifecycle,
reader: coreServices.urlReader,
permissions: coreServices.permissions,
permissionsRegistry: coreServices.permissionsRegistry,
database: coreServices.database,
auth: coreServices.auth,
httpRouter: coreServices.httpRouter,
@@ -151,6 +153,7 @@ export const scaffolderPlugin = createBackendPlugin({
actionsRegistry: actionsServiceRef,
actionsRegistryService: actionsRegistryServiceRef,
scaffolderService: scaffolderServiceRef,
metrics: metricsServiceRef,
},
async init({
logger,
@@ -163,11 +166,13 @@ export const scaffolderPlugin = createBackendPlugin({
httpAuth,
catalog,
permissions,
permissionsRegistry,
events,
auditor,
actionsRegistry,
actionsRegistryService,
scaffolderService,
metrics,
}) {
const log = loggerToWinstonLogger(logger);
const integrations = ScmIntegrations.fromConfig(config);
@@ -239,11 +244,13 @@ export const scaffolderPlugin = createBackendPlugin({
auth,
httpAuth,
permissions,
permissionsRegistry,
autocompleteHandlers,
additionalWorkspaceProviders,
events,
auditor,
actionsRegistry,
metrics,
});
httpRouter.use(router);
},
@@ -19,6 +19,7 @@ import {
BackstageCredentials,
LoggerService,
} from '@backstage/backend-plugin-api';
import type { MetricsService } from '@backstage/backend-plugin-api/alpha';
import type { UserEntity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
@@ -81,6 +82,7 @@ export type TemplateTesterCreateOptions = {
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionEvaluator;
config?: Config;
metrics: MetricsService;
};
/**
@@ -39,7 +39,10 @@ import {
mockCredentials,
mockServices,
} from '@backstage/backend-test-utils';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import {
actionsRegistryServiceMock,
metricsServiceMock,
} from '@backstage/backend-test-utils/alpha';
describe('NunjucksWorkflowRunner', () => {
let actionRegistry: TemplateActionRegistry;
@@ -249,6 +252,7 @@ describe('NunjucksWorkflowRunner', () => {
logger,
permissions: mockedPermissionApi,
config,
metrics: metricsServiceMock.mock(),
});
});
@@ -23,7 +23,6 @@ import {
TaskStep,
} from '@backstage/plugin-scaffolder-common';
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
import { metrics } from '@opentelemetry/api';
import fs from 'fs-extra';
import { validate as validateJsonSchema } from 'jsonschema';
import nunjucks from 'nunjucks';
@@ -42,6 +41,7 @@ import type {
LoggerService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import type { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { UserEntity } from '@backstage/catalog-model';
import {
AuthorizeResult,
@@ -78,6 +78,7 @@ type NunjucksWorkflowRunnerOptions = {
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionsService;
config?: Config;
metrics: MetricsService;
};
type TemplateContext = {
@@ -188,6 +189,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
secrets?: Record<string, string>;
} = { parameters: {}, secrets: {} };
private readonly tracker: ReturnType<typeof scaffoldingTracker>;
constructor(options: NunjucksWorkflowRunnerOptions) {
this.options = options;
this.defaultTemplateFilters = convertFiltersToRecord(
@@ -195,10 +198,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
integrations: this.options.integrations,
}),
);
this.tracker = scaffoldingTracker(options.metrics);
}
private readonly tracker = scaffoldingTracker();
async getEnvironmentConfig(): Promise<{
parameters: JsonObject;
secrets?: TaskSecrets;
@@ -700,7 +702,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
}
}
function scaffoldingTracker() {
function scaffoldingTracker(metrics: MetricsService) {
// prom-client metrics are deprecated in favour of OpenTelemetry metrics.
const promTaskCount = createCounterMetric({
name: 'scaffolder_task_count',
@@ -723,23 +725,22 @@ function scaffoldingTracker() {
labelNames: ['template', 'step', 'result'],
});
const meter = metrics.getMeter('default');
const taskCount = meter.createCounter('scaffolder.task.count', {
description: 'Count of task runs',
const taskCount = metrics.createCounter('scaffolder.task.count', {
description: 'Total number of scaffolder tasks executed',
});
const taskDuration = meter.createHistogram('scaffolder.task.duration', {
description: 'Duration of a task run',
unit: 'seconds',
const taskDuration = metrics.createHistogram('scaffolder.task.duration', {
description: 'Time taken to complete a scaffolder task end-to-end',
unit: 's',
});
const stepCount = meter.createCounter('scaffolder.step.count', {
description: 'Count of step runs',
const stepCount = metrics.createCounter('scaffolder.step.count', {
description: 'Total number of individual scaffolder action steps executed',
});
const stepDuration = meter.createHistogram('scaffolder.step.duration', {
description: 'Duration of a step runs',
unit: 'seconds',
const stepDuration = metrics.createHistogram('scaffolder.step.duration', {
description: 'Time taken to complete a single scaffolder action step',
unit: 's',
});
async function taskStart(task: TaskContext) {
@@ -36,6 +36,7 @@ import { WorkflowRunner } from './types';
import ObservableImpl from 'zen-observable';
import waitForExpect from 'wait-for-expect';
import { mockServices } from '@backstage/backend-test-utils';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger';
jest.mock('./NunjucksWorkflowRunner');
@@ -93,6 +94,7 @@ describe('TaskWorker', () => {
integrations,
taskBroker: broker,
actionRegistry,
metrics: metricsServiceMock.mock(),
});
await broker.dispatch({
@@ -124,6 +126,7 @@ describe('TaskWorker', () => {
integrations,
taskBroker: broker,
actionRegistry,
metrics: metricsServiceMock.mock(),
});
const { taskId } = await broker.dispatch({
@@ -174,6 +177,7 @@ describe('TaskWorker', () => {
},
},
}),
metrics: metricsServiceMock.mock(),
});
await taskWorker.runOneTask({
@@ -261,6 +265,7 @@ describe('Concurrent TaskWorker', () => {
taskBroker: broker,
actionRegistry,
concurrentTasksLimit: expectedConcurrentTasks,
metrics: metricsServiceMock.mock(),
});
taskWorker.start();
@@ -307,6 +312,7 @@ describe('Cancellable TaskWorker', () => {
integrations,
taskBroker,
actionRegistry,
metrics: metricsServiceMock.mock(),
});
const steps = [...Array(10)].map(n => ({
@@ -15,7 +15,8 @@
*/
import { AuditorService, LoggerService } from '@backstage/backend-plugin-api';
import { assertError, InputError, stringifyError } from '@backstage/errors';
import type { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { InputError, stringifyError, toError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
@@ -78,6 +79,7 @@ export type CreateWorkerOptions = {
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionEvaluator;
gracefulShutdown?: boolean;
metrics: MetricsService;
};
/**
@@ -123,6 +125,7 @@ export class TaskWorker {
additionalTemplateGlobals,
permissions,
gracefulShutdown,
metrics,
} = options;
const workflowRunner = new NunjucksWorkflowRunner({
@@ -135,6 +138,7 @@ export class TaskWorker {
additionalTemplateGlobals,
permissions,
config,
metrics,
});
return new TaskWorker({
@@ -224,12 +228,12 @@ export class TaskWorker {
await task.complete('completed', { output });
await auditorEvent?.success();
} catch (error) {
assertError(error);
const err = toError(error);
await auditorEvent?.fail({
error,
error: err,
});
await task.complete('failed', {
error: { name: error.name, message: error.message },
error: { name: err.name, message: err.message },
});
}
}
@@ -27,7 +27,7 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, InputError, NotFoundError } from '@backstage/errors';
import { InputError, NotFoundError, toError } from '@backstage/errors';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import fs from 'fs-extra';
@@ -47,13 +47,13 @@ export async function getWorkingDirectory(
await fs.access(workingDirectory, fs.constants.F_OK | fs.constants.W_OK);
logger.info(`using working directory: ${workingDirectory}`);
} catch (err) {
assertError(err);
const error = toError(err);
logger.error(
`working directory ${workingDirectory} ${
err.code === 'ENOENT' ? 'does not exist' : 'is not writable'
error.code === 'ENOENT' ? 'does not exist' : 'is not writable'
}`,
);
throw err;
throw error;
}
return workingDirectory;
}
@@ -58,7 +58,10 @@ import {
import { createDefaultFilters } from '../lib/templating/filters/createDefaultFilters';
import { createRouter } from './router';
import { DatabaseTaskStore } from '../scaffolder/tasks/DatabaseTaskStore';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import {
actionsRegistryServiceMock,
metricsServiceMock,
} from '@backstage/backend-test-utils/alpha';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
function createDatabase(): DatabaseService {
@@ -201,6 +204,7 @@ const createTestRouter = async (
const httpAuth = mockServices.httpAuth();
const events = mockServices.events();
const permissionsRegistry = mockServices.permissionsRegistry.mock();
const router = await createRouter({
logger,
config: new ConfigReader({}),
@@ -208,6 +212,7 @@ const createTestRouter = async (
catalog,
taskBroker,
permissions,
permissionsRegistry,
auth,
httpAuth,
events,
@@ -229,6 +234,7 @@ const createTestRouter = async (
createDebugLogAction(),
],
actionsRegistry: overrides.actionsRegistry ?? actionsRegistryServiceMock(),
metrics: metricsServiceMock.mock(),
});
router.use(mockErrorHandler());
@@ -23,6 +23,7 @@ import {
HttpAuthService,
LifecycleService,
LoggerService,
PermissionsRegistryService,
PermissionsService,
resolveSafeChildPath,
SchedulerService,
@@ -45,7 +46,6 @@ import {
ConditionTransformer,
createConditionAuthorizer,
createConditionTransformer,
createPermissionIntegrationRouter,
} from '@backstage/plugin-permission-node';
import {
TaskSpec,
@@ -53,16 +53,13 @@ import {
templateEntityV1beta3Validator,
} from '@backstage/plugin-scaffolder-common';
import {
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TASK,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
scaffolderActionPermissions,
scaffolderPermissions,
scaffolderTaskPermissions,
scaffolderTemplatePermissions,
taskCancelPermission,
taskCreatePermission,
taskReadPermission,
templateManagementPermission,
templateParameterReadPermission,
templateStepReadPermission,
} from '@backstage/plugin-scaffolder-common/alpha';
@@ -78,6 +75,9 @@ import {
AutocompleteHandler,
CreatedTemplateFilter,
CreatedTemplateGlobal,
scaffolderActionPermissionResourceRef,
scaffolderTaskPermissionResourceRef,
scaffolderTemplatePermissionResourceRef,
WorkspaceProvider,
} from '@backstage/plugin-scaffolder-node/alpha';
import { HumanDuration, JsonObject } from '@backstage/types';
@@ -131,7 +131,10 @@ import {
scaffolderTaskRules,
scaffolderTemplateRules,
} from './rules';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import {
ActionsService,
MetricsService,
} from '@backstage/backend-plugin-api/alpha';
/**
* RouterOptions
@@ -158,6 +161,7 @@ export interface RouterOptions {
| CreatedTemplateGlobal[];
additionalWorkspaceProviders?: Record<string, WorkspaceProvider>;
permissions?: PermissionsService;
permissionsRegistry: PermissionsRegistryService;
permissionRules?: Array<ScaffolderPermissionRuleInput>;
auth: AuthService;
httpAuth: HttpAuthService;
@@ -165,6 +169,7 @@ export interface RouterOptions {
auditor?: AuditorService;
autocompleteHandlers?: Record<string, AutocompleteHandler>;
actionsRegistry: ActionsService;
metrics: MetricsService;
}
function isSupportedTemplate(entity: TemplateEntityV1beta3) {
@@ -249,6 +254,7 @@ export async function createRouter(
additionalTemplateGlobals,
additionalWorkspaceProviders,
permissions,
permissionsRegistry,
permissionRules,
autocompleteHandlers = {},
events: eventsService,
@@ -256,6 +262,7 @@ export async function createRouter(
httpAuth,
auditor,
actionsRegistry,
metrics,
} = options;
const concurrentTasksLimit =
@@ -344,6 +351,7 @@ export async function createRouter(
concurrentTasksLimit,
permissions,
gracefulShutdown,
metrics,
...templateExtensions,
});
@@ -375,6 +383,7 @@ export async function createRouter(
workingDirectory,
permissions,
config,
metrics,
...templateExtensions,
});
@@ -403,35 +412,35 @@ export async function createRouter(
const taskTransformConditions: ConditionTransformer<TaskFilters> =
createConditionTransformer(Object.values(taskRules));
const permissionIntegrationRouter = createPermissionIntegrationRouter({
resources: [
{
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
permissions: scaffolderTemplatePermissions,
rules: templateRules,
},
{
resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION,
permissions: scaffolderActionPermissions,
rules: actionRules,
},
{
resourceType: RESOURCE_TYPE_SCAFFOLDER_TASK,
permissions: scaffolderTaskPermissions,
rules: taskRules,
getResources: async resourceRefs => {
return Promise.all(
resourceRefs.map(async taskId => {
return await taskBroker.get(taskId);
}),
);
},
},
],
permissions: scaffolderPermissions,
permissionsRegistry.addResourceType({
resourceRef: scaffolderTemplatePermissionResourceRef,
permissions: scaffolderTemplatePermissions,
rules: templateRules,
});
router.use(permissionIntegrationRouter);
permissionsRegistry.addResourceType({
resourceRef: scaffolderActionPermissionResourceRef,
permissions: scaffolderActionPermissions,
rules: actionRules,
});
permissionsRegistry.addResourceType({
resourceRef: scaffolderTaskPermissionResourceRef,
permissions: scaffolderTaskPermissions,
rules: taskRules,
getResources: async resourceRefs => {
return Promise.all(
resourceRefs.map(async taskId => {
return await taskBroker.get(taskId);
}),
);
},
});
permissionsRegistry.addPermissions([
taskCreatePermission,
templateManagementPermission,
]);
router
.get(