Merge branch 'master' of github.com:backstage/backstage

Signed-off-by: Justin Bryant <justintbry@gmail.com>
This commit is contained in:
Justin Bryant
2026-04-17 17:32:02 -04:00
5632 changed files with 358313 additions and 109837 deletions
+526 -2
View File
@@ -1,5 +1,529 @@
# @backstage/plugin-scaffolder-backend
## 3.4.0
### 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.
- 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.
- 8a42f77: Fix handling of `after=0` in task events endpoint
- 4559806: Removed unnecessary empty `examples` array from actions bridged via the actions registry.
- 79453c0: Updated dependency `wait-for-expect` to `^4.0.0`.
- 3ef6078: Added support for conditional `if` filtering on output `links` and `text` items. Items where the `if` condition evaluates to false are now excluded from the task output.
- Updated dependencies
- @backstage/backend-plugin-api@1.9.0
- @backstage/errors@1.3.0
- @backstage/catalog-model@1.8.0
- @backstage/plugin-catalog-node@2.2.0
- @backstage/plugin-scaffolder-common@2.1.0
- @backstage/plugin-scaffolder-node@0.13.2
- @backstage/backend-openapi-utils@0.6.8
- @backstage/integration@2.0.1
- @backstage/plugin-permission-node@0.10.12
- @backstage/config@1.3.7
- @backstage/plugin-events-node@0.4.21
- @backstage/plugin-permission-common@0.9.8
## 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
- 79453c0: Updated dependency `wait-for-expect` to `^4.0.0`.
- Updated dependencies
- @backstage/backend-plugin-api@1.8.1-next.0
- @backstage/plugin-permission-node@0.10.12-next.0
- @backstage/backend-openapi-utils@0.6.8-next.0
- @backstage/plugin-catalog-node@2.1.1-next.0
- @backstage/plugin-events-node@0.4.21-next.0
- @backstage/plugin-scaffolder-node@0.13.1-next.0
- @backstage/catalog-model@1.7.7
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration@2.0.0
- @backstage/types@1.2.2
- @backstage/plugin-permission-common@0.9.7
- @backstage/plugin-scaffolder-common@2.0.0
## 3.2.0
### Minor Changes
- c9b11eb: Added a new `list-scaffolder-tasks` action that allows querying scaffolder tasks with optional ownership filtering and pagination support
- 1b42218: Adds a new `get-scaffolder-task-logs` action to `@backstage/plugin-scaffolder-backend` that retrieves log events for a given scaffolder task, with optional support for retrieving only new events after a given event ID.
- 0fbcf23: Migrated OpenAPI schemas to 3.1.
- 7695dd2: Added a new `list-scaffolder-actions` action that returns all installed scaffolder actions with their schemas and examples
- e8736ea: Added secrets schema validation for task creation, retry, and dry-run endpoints. When a template defines `spec.secrets.schema`, the API validates provided secrets against the schema and returns a `400` error if validation fails.
### Patch Changes
- e27bd4e: Removed `@backstage/plugin-scaffolder-backend-module-bitbucket` from `package.json` as the package itself has been deprecated and the code deleted.
- 4f5ed06: Fixed a security vulnerability where server-configured environment secrets were exposed through the scaffolder dry-run endpoint.
- 30ff981: Fixed a security vulnerability where secrets could bypass log redaction when transformed through Nunjucks filters in scaffolder templates.
- 4e39e63: Removed unused dependencies
- a49a40d: Updated dependency `zod` to `^3.25.76 || ^4.0.0` & migrated to `/v3` or `/v4` imports.
- ccc20cf: create scaffolder MCP action to dry run a provided scaffolder template
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0
- @backstage/plugin-scaffolder-node@0.13.0
- @backstage/integration@2.0.0
- @backstage/plugin-catalog-node@2.1.0
- @backstage/plugin-scaffolder-common@2.0.0
- @backstage/plugin-permission-common@0.9.7
- @backstage/plugin-permission-node@0.10.11
- @backstage/catalog-model@1.7.7
- @backstage/backend-openapi-utils@0.6.7
- @backstage/plugin-events-node@0.4.20
## 3.2.0-next.2
### Minor Changes
- e8736ea: Added secrets schema validation for task creation, retry, and dry-run endpoints. When a template defines `spec.secrets.schema`, the API validates provided secrets against the schema and returns a `400` error if validation fails.
### Patch Changes
- 30ff981: Fixed a security vulnerability where secrets could bypass log redaction when transformed through Nunjucks filters in scaffolder templates.
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
- @backstage/integration@2.0.0-next.2
- @backstage/plugin-scaffolder-common@2.0.0-next.2
- @backstage/backend-openapi-utils@0.6.7-next.1
- @backstage/plugin-catalog-node@2.1.0-next.2
- @backstage/plugin-events-node@0.4.20-next.1
- @backstage/plugin-permission-node@0.10.11-next.1
- @backstage/plugin-scaffolder-node@0.13.0-next.2
## 3.2.0-next.1
### Minor Changes
- c9b11eb: Added a new `list-scaffolder-tasks` action that allows querying scaffolder tasks with optional ownership filtering and pagination support
- 0fbcf23: Migrated OpenAPI schemas to 3.1.
- 7695dd2: Added a new `list-scaffolder-actions` action that returns all installed scaffolder actions with their schemas and examples
### Patch Changes
- e27bd4e: Removed `@backstage/plugin-scaffolder-backend-module-bitbucket` from `package.json` as the package itself has been deprecated and the code deleted.
- ccc20cf: create scaffolder MCP action to dry run a provided scaffolder template
- Updated dependencies
- @backstage/integration@2.0.0-next.1
- @backstage/plugin-scaffolder-common@2.0.0-next.1
- @backstage/plugin-scaffolder-node@0.13.0-next.1
- @backstage/plugin-catalog-node@2.1.0-next.1
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
## 3.1.4-next.0
### Patch Changes
- 4e39e63: Removed unused dependencies
- Updated dependencies
- @backstage/integration@1.21.0-next.0
- @backstage/plugin-catalog-node@2.1.0-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-scaffolder-common@1.7.7-next.0
- @backstage/plugin-scaffolder-node@0.12.6-next.0
## 3.1.3
### Patch Changes
- 7455dae: Use node prefix on native imports
- 4fc7bf0: Removed unused dependency
- 0ce78b0: Support `if` conditions inside `each` loops for scaffolder steps
- 5e3ef57: Added `peerModules` metadata declaring recommended modules for cross-plugin integrations.
- 8148621: Moved `@backstage/backend-defaults` from `dependencies` to `devDependencies`.
- 1e669cc: Migrate audit events reference docs to http://backstage.io/docs.
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.3
- @backstage/integration@1.20.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.17
- @backstage/plugin-catalog-node@2.0.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.3
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.19
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.18
- @backstage/plugin-scaffolder-backend-module-github@0.9.6
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.18
- @backstage/backend-openapi-utils@0.6.6
- @backstage/plugin-bitbucket-cloud-common@0.3.7
- @backstage/backend-plugin-api@1.7.0
- @backstage/plugin-scaffolder-node@0.12.5
- @backstage/plugin-auth-node@0.6.13
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.10
- @backstage/plugin-events-node@0.4.19
- @backstage/plugin-scaffolder-backend-module-azure@0.2.18
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.18
- @backstage/plugin-scaffolder-common@1.7.6
## 3.1.3-next.2
### Patch Changes
- 8148621: Moved `@backstage/backend-defaults` from `dependencies` to `devDependencies`.
- Updated dependencies
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.3-next.2
- @backstage/integration@1.20.0-next.2
- @backstage/plugin-catalog-node@2.0.0-next.1
- @backstage/backend-plugin-api@1.7.0-next.1
- @backstage/plugin-auth-node@0.6.13-next.1
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.17-next.1
- @backstage/plugin-events-node@0.4.19-next.0
- @backstage/plugin-permission-node@0.10.10-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.19-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.3-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-github@0.9.6-next.2
- @backstage/plugin-scaffolder-node@0.12.5-next.1
## 3.1.3-next.1
### Patch Changes
- 5e3ef57: Added `peerModules` metadata declaring recommended modules for cross-plugin integrations.
- Updated dependencies
- @backstage/integration@1.20.0-next.1
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.3-next.1
- @backstage/backend-plugin-api@1.7.0-next.1
- @backstage/backend-defaults@0.15.2-next.1
- @backstage/plugin-bitbucket-cloud-common@0.3.7-next.1
- @backstage/plugin-scaffolder-backend-module-azure@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.19-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.3-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.18-next.1
- @backstage/plugin-scaffolder-backend-module-github@0.9.6-next.1
- @backstage/plugin-scaffolder-common@1.7.6-next.1
- @backstage/plugin-scaffolder-node@0.12.5-next.1
## 3.1.2-next.0
### Patch Changes
- 7455dae: Use node prefix on native imports
- 4fc7bf0: Removed unused dependency
- 1e669cc: Migrate audit events reference docs to http://backstage.io/docs.
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.17-next.0
- @backstage/plugin-catalog-node@1.21.0-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.2-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.19-next.0
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.18-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.5-next.0
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.2-next.0
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.18-next.0
- @backstage/backend-openapi-utils@0.6.6-next.0
- @backstage/plugin-bitbucket-cloud-common@0.3.7-next.0
- @backstage/backend-plugin-api@1.7.0-next.0
- @backstage/backend-defaults@0.15.1-next.0
- @backstage/plugin-scaffolder-node@0.12.4-next.0
- @backstage/integration@1.19.3-next.0
- @backstage/plugin-auth-node@0.6.12-next.0
- @backstage/plugin-permission-common@0.9.5-next.0
- @backstage/plugin-permission-node@0.10.9-next.0
- @backstage/plugin-events-node@0.4.19-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.18-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-scaffolder-backend-module-azure@0.2.18-next.0
- @backstage/plugin-scaffolder-common@1.7.6-next.0
## 3.1.1
### Patch Changes
- 5012852: Remove unused abort controller in debug:wait action
- c641c14: Wrap some of the action logic with `resolveSafeChildPath` and improve symlink handling when fetching remote and local files
- 27f9061: REwrite]
- 872eb91: Upgrade `zod-to-json-schema` to latest version
- Updated dependencies
- @backstage/backend-defaults@0.15.0
- @backstage/backend-plugin-api@1.6.1
- @backstage/plugin-scaffolder-node@0.12.3
- @backstage/integration@1.19.2
- @backstage/backend-openapi-utils@0.6.5
- @backstage/plugin-scaffolder-backend-module-github@0.9.4
- @backstage/plugin-auth-node@0.6.11
- @backstage/plugin-scaffolder-backend-module-azure@0.2.17
- @backstage/plugin-permission-common@0.9.4
- @backstage/plugin-permission-node@0.10.8
- @backstage/plugin-bitbucket-cloud-common@0.3.6
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.16
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.18
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.17
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.17
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.17
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.1
- @backstage/plugin-scaffolder-common@1.7.5
## 3.1.1-next.2
### Patch Changes
- Updated dependencies
- @backstage/backend-defaults@0.15.0-next.2
- @backstage/plugin-scaffolder-backend-module-github@0.9.4-next.1
- @backstage/plugin-auth-node@0.6.10
- @backstage/plugin-permission-node@0.10.7
## 3.1.1-next.1
### Patch Changes
- Updated dependencies
- @backstage/backend-defaults@0.14.1-next.1
- @backstage/integration@1.19.2-next.0
- @backstage/plugin-bitbucket-cloud-common@0.3.6-next.0
- @backstage/plugin-scaffolder-backend-module-azure@0.2.17-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.18-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.1-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.17-next.0
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.17-next.0
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.17-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.4-next.0
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.1-next.0
- @backstage/plugin-scaffolder-common@1.7.5-next.0
- @backstage/plugin-scaffolder-node@0.12.3-next.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.16-next.0
## 3.1.1-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-openapi-utils@0.6.5-next.0
- @backstage/backend-defaults@0.14.1-next.0
- @backstage/backend-plugin-api@1.6.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration@1.19.0
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.10
- @backstage/plugin-bitbucket-cloud-common@0.3.5
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.15
- @backstage/plugin-catalog-node@1.20.1
- @backstage/plugin-events-node@0.4.18
- @backstage/plugin-permission-common@0.9.3
- @backstage/plugin-permission-node@0.10.7
- @backstage/plugin-scaffolder-backend-module-azure@0.2.16
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.17
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.16
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.16
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.16
- @backstage/plugin-scaffolder-backend-module-github@0.9.3
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.0
- @backstage/plugin-scaffolder-common@1.7.4
- @backstage/plugin-scaffolder-node@0.12.2
## 3.1.0
### Minor Changes
- a4cd405: Add `defaultEnvironment` config to scaffolder to enable more flexible and custom templates. Now it's possible enable access to default parameters and secrets in templates, improving security and reducing complexity.
### Patch Changes
- be5972b: Fixed a bug where config was not passed to NunjucksWorkflowRunner, causing defaultEnvironment to be undefined
- de96a60: chore(deps): bump `express` from 4.21.2 to 4.22.0
- 2bae83a: Updated `isolated-vm` to `6.0.1`
- 25b560e: Internal change to support new versions of the `logform` library
- 8f4aded: Fixing OpenAPI definition
- 1226647: Updated dependency `esbuild` to `^0.27.0`.
- Updated dependencies
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.0
- @backstage/integration@1.19.0
- @backstage/plugin-auth-node@0.6.10
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.3.0
- @backstage/plugin-bitbucket-cloud-common@0.3.5
- @backstage/backend-defaults@0.14.0
- @backstage/backend-openapi-utils@0.6.4
- @backstage/plugin-events-node@0.4.18
- @backstage/plugin-permission-node@0.10.7
- @backstage/backend-plugin-api@1.6.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.3
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.16
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.17
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.15
- @backstage/plugin-catalog-node@1.20.1
- @backstage/plugin-scaffolder-backend-module-azure@0.2.16
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.16
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.16
- @backstage/plugin-scaffolder-common@1.7.4
- @backstage/plugin-scaffolder-node@0.12.2
## 3.1.0-next.1
### Patch Changes
- de96a60: chore(deps): bump `express` from 4.21.2 to 4.22.0
- 2bae83a: Updated `isolated-vm` to `6.0.1`
- 25b560e: Internal change to support new versions of the `logform` library
- 1226647: Updated dependency `esbuild` to `^0.27.0`.
- Updated dependencies
- @backstage/plugin-scaffolder-backend-module-gitlab@0.11.0-next.1
- @backstage/backend-defaults@0.14.0-next.1
- @backstage/backend-openapi-utils@0.6.4-next.1
- @backstage/plugin-auth-node@0.6.10-next.1
- @backstage/plugin-events-node@0.4.18-next.1
- @backstage/plugin-permission-node@0.10.7-next.1
- @backstage/integration@1.18.3-next.1
- @backstage/backend-plugin-api@1.6.0-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.16-next.1
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-bitbucket-cloud-common@0.3.5-next.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.15-next.1
- @backstage/plugin-catalog-node@1.20.1-next.1
- @backstage/plugin-permission-common@0.9.3
- @backstage/plugin-scaffolder-backend-module-azure@0.2.16-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.17-next.1
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.2.16-next.1
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.16-next.1
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.16-next.1
- @backstage/plugin-scaffolder-backend-module-github@0.9.3-next.1
- @backstage/plugin-scaffolder-common@1.7.4-next.0
- @backstage/plugin-scaffolder-node@0.12.2-next.1
## 3.1.0-next.0
### Minor Changes
- a4cd405: Add `defaultEnvironment` config to scaffolder to enable more flexible and custom templates. Now it's possible enable access to default parameters and secrets in templates, improving security and reducing complexity.
### Patch Changes
- 8f4aded: Fixing OpenAPI definition
- Updated dependencies
- @backstage/plugin-auth-node@0.6.10-next.0
- @backstage/backend-defaults@0.14.0-next.0
- @backstage/backend-plugin-api@1.5.1-next.0
- @backstage/integration@1.18.3-next.0
- @backstage/plugin-permission-node@0.10.7-next.0
- @backstage/backend-openapi-utils@0.6.4-next.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.15-next.0
- @backstage/plugin-catalog-node@1.20.1-next.0
- @backstage/plugin-events-node@0.4.18-next.0
- @backstage/plugin-scaffolder-backend-module-azure@0.2.16-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.17-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.2.16-next.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.16-next.0
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.16-next.0
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.16-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.3-next.0
- @backstage/plugin-scaffolder-backend-module-gitlab@0.10.1-next.0
- @backstage/plugin-scaffolder-node@0.12.2-next.0
- @backstage/plugin-bitbucket-cloud-common@0.3.5-next.0
- @backstage/plugin-scaffolder-common@1.7.4-next.0
- @backstage/config@1.3.6
- @backstage/catalog-model@1.7.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-permission-common@0.9.3
## 3.0.1
### Patch Changes
- 05f60e1: Refactored constructor parameter properties to explicit property declarations for compatibility with TypeScript's `erasableSyntaxOnly` setting. This internal refactoring maintains all existing functionality while ensuring TypeScript compilation compatibility.
- Updated dependencies
- @backstage/backend-defaults@0.13.1
- @backstage/plugin-catalog-node@1.20.0
- @backstage/plugin-scaffolder-backend-module-bitbucket-cloud@0.2.15
- @backstage/plugin-scaffolder-backend-module-bitbucket@0.3.16
- @backstage/plugin-bitbucket-cloud-common@0.3.4
- @backstage/integration@1.18.2
- @backstage/plugin-scaffolder-backend-module-gitlab@0.10.0
- @backstage/backend-plugin-api@1.5.0
- @backstage/plugin-permission-common@0.9.3
- @backstage/plugin-events-node@0.4.17
- @backstage/plugin-auth-node@0.6.9
- @backstage/config@1.3.6
- @backstage/catalog-model@1.7.6
- @backstage/plugin-scaffolder-node@0.12.1
- @backstage/backend-openapi-utils@0.6.3
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.14
- @backstage/plugin-permission-node@0.10.6
- @backstage/plugin-scaffolder-backend-module-azure@0.2.15
- @backstage/plugin-scaffolder-backend-module-bitbucket-server@0.2.15
- @backstage/plugin-scaffolder-backend-module-gerrit@0.2.15
- @backstage/plugin-scaffolder-backend-module-gitea@0.2.15
- @backstage/plugin-scaffolder-backend-module-github@0.9.2
- @backstage/plugin-scaffolder-common@1.7.3
## 3.0.1-next.1
### Patch Changes
@@ -5180,7 +5704,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.
@@ -5273,7 +5797,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
+51 -19
View File
@@ -64,31 +64,63 @@ you will not have any templates available to use. These need to be [added to the
To get up and running and try out some templates quickly, you can or copy the
catalog locations from the [create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs).
## Audit Events
## Configuration
The Scaffolder backend emits audit events for various operations. Events are grouped logically by `eventId`, with `subEventId` providing further distinction when needed.
### Default Environment
**Template Events:**
The scaffolder supports a `defaultEnvironment` configuration that provides default parameters and secrets to all templates. This reduces template complexity and improves security by centralizing common values.
- **`template-parameter-schema`**: Retrieves template parameter schemas. (GET `/v2/templates/:namespace/:kind/:name/parameter-schema`)
```yaml
scaffolder:
defaultEnvironment:
parameters:
region: eu-west-1
organizationName: acme-corp
defaultRegistry: registry.acme-corp.com
secrets:
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY}
GITHUB_TOKEN: ${GITHUB_TOKEN}
DOCKER_REGISTRY_TOKEN: ${DOCKER_REGISTRY_TOKEN}
```
**Action Events:**
#### Default parameters
- **`action-fetch`**: Retrieves installed actions. (GET `/v2/actions`)
Default parameters are accessible via `${{ environment.parameters.* }}` in templates. Default parameters are isolated in their own context to avoid naming conflicts.
**Task Events:**
```yaml
parameters:
- title: Fill in some steps
required:
- organizationName
properties:
organizationName:
title: organizationName
type: string
description: Unique name of the organization
ui:autofocus: true
ui:options:
rows: 5
- **`task`**: Operations related to Scaffolder tasks.
steps:
- id: deploy
name: Deploy Application
action: aws:deploy
input:
region: ${{ environment.parameters.region }} # Resolves to defaultEnvironment.parameters.region
organization: ${{ parameters.organizationName }} # Resolves to frontend input value
otherOrganization: ${{ environment.parameters.organizationName }} # Resolves to defaultEnvironment.parameters.organizationName
```
Filter on `actionType`.
#### Secrets
- **`create`**: Creates a new task. (POST `/v2/tasks`)
- **`list`**: Fetches details of all tasks. (GET `/v2/tasks`)
- **`get`**: Fetches details of a specific task. (GET `/v2/tasks/:taskId`)
- **`cancel`**: Cancels a running task. (POST `/v2/tasks/:taskId/cancel`)
- **`retry`**: Retries a failed task. (POST `/v2/tasks/:taskId/retry`)
- **`stream`**: Retrieves a stream of task logs. (GET `/v2/tasks/:taskId/eventstream`)
- **`events`**: Retrieves a snapshot of task logs. (GET `/v2/tasks/:taskId/events`)
- **`dry-run`**: Creates a dry-run task. (POST `/v2/dry-run`) All audit logs for events associated with dry runs have the `meta.isDryLog` flag set to `true`.
- **`stale-cancel`**: Automated cancellation of stale tasks.
- **`execute`**: Tracks the initiation and completion of a real scaffolder task execution. This event will not occur during dry runs.
Default secrets are resolved from environment variables and accessible via `${{ environment.secrets.* }}` in template actions. Secrets are only available during action execution, not in frontend forms.
```yaml
- id: deploy
name: Deploy with credentials
action: aws:deploy
input:
accessKey: ${{ environment.secrets.AWS_ACCESS_KEY }} # Resolves to defaultEnvironment.secrets.AWS_ACCESS_KEY
```
**Security Note:** Secrets are automatically masked in logs and are only available to backend actions, never exposed to the frontend.
+21
View File
@@ -108,5 +108,26 @@ export interface Config {
auditor?: {
taskParameterMaxLength?: number;
};
/**
* Default environment variables and secrets available to all templates.
*/
defaultEnvironment?: {
/**
* Default parameters accessible via ${{ environment.parameters.* }} in templates.
*/
parameters?: {
[key: string]: string;
};
/**
* Secret values from environment variables accessible via ${{ environment.secrets.* }} in templates.
* Values should reference environment variables like ${SECRET_NAME}.
* @visibility secret
*/
secrets?: {
[key: string]: string;
};
};
};
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,13 +14,9 @@
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { createBackend } from '@backstage/backend-defaults';
/**
* @public
*/
export interface TaskSecretsAllOf {
backstageToken?: string;
}
const backend = createBackend();
backend.add(import('../src'));
backend.start();
-25
View File
@@ -1,27 +1,2 @@
# Knip report
## Unused dependencies (14)
| Name | Location | Severity |
| :--------------------------------------------------------------- | :----------- | :------- |
| @backstage/plugin-catalog-backend-module-scaffolder-entity-model | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-bitbucket-server | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-bitbucket-cloud | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-bitbucket | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-gerrit | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-github | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-gitlab | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-azure | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-scaffolder-backend-module-gitea | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-bitbucket-cloud-common | plugins/scaffolder-backend/package.json | error |
| @backstage/plugin-auth-node | plugins/scaffolder-backend/package.json | error |
| concat-stream | plugins/scaffolder-backend/package.json | error |
| p-limit | plugins/scaffolder-backend/package.json | error |
| tar | plugins/scaffolder-backend/package.json | error |
## Unused devDependencies (1)
| Name | Location | Severity |
| :------------------------- | :----------- | :------- |
| @backstage/backend-app-api | plugins/scaffolder-backend/package.json | error |
+10 -24
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "3.0.1-next.1",
"version": "3.4.0",
"description": "The Backstage backend plugin that helps you create new things",
"backstage": {
"role": "backend-plugin",
@@ -12,6 +12,9 @@
"@backstage/plugin-scaffolder-node",
"@backstage/plugin-scaffolder-node-test-utils",
"@backstage/plugin-scaffolder-react"
],
"peerModules": [
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model"
]
},
"publishConfig": {
@@ -62,60 +65,43 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-azure": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-bitbucket": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-bitbucket-server": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-gerrit": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-gitea": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-github": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/types": "workspace:^",
"@opentelemetry/api": "^1.9.0",
"@types/luxon": "^3.0.0",
"concat-stream": "^2.0.0",
"express": "^4.17.1",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"globby": "^11.0.0",
"isbinaryfile": "^5.0.0",
"isolated-vm": "^5.0.1",
"isolated-vm": "^6.0.1",
"jsonschema": "^1.5.0",
"knex": "^3.0.0",
"lodash": "^4.17.21",
"logform": "^2.3.2",
"luxon": "^3.0.0",
"nunjucks": "^3.2.3",
"p-limit": "^3.1.0",
"p-queue": "^6.6.2",
"prom-client": "^15.0.0",
"tar": "^6.1.12",
"triple-beam": "^1.4.1",
"uuid": "^11.0.0",
"winston": "^3.2.1",
"winston-transport": "^4.7.0",
"yaml": "^2.0.0",
"zen-observable": "^0.10.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.20.4"
"zod": "^3.25.76 || ^4.0.0",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
@@ -126,10 +112,10 @@
"@types/nunjucks": "^3.1.4",
"@types/supertest": "^2.0.8",
"@types/zen-observable": "^0.8.0",
"esbuild": "^0.25.0",
"esbuild": "^0.27.0",
"strip-ansi": "^7.1.0",
"supertest": "^7.0.0",
"wait-for-expect": "^3.0.2"
"wait-for-expect": "^4.0.0"
},
"configSchema": "config.d.ts"
}
@@ -7,6 +7,7 @@ spec:
targets:
- ./remote-templates.yaml
- ./notifications-demo/template.yaml
- ./conditional-output-demo/template.yaml
# For local development of a template, you can reference your local templates here.
# Examples:
#
@@ -21,7 +21,7 @@ spec:
allowedHosts:
- bitbucket.org
# The rest of these options are optional.
# You can read more at: https://backstage.io/docs/reference/plugin-scaffolder.repourlpickerfieldextension
# You can read more at: https://backstage.io/api/stable/variables/_backstage_plugin-scaffolder.index.RepoUrlPickerFieldExtension.html
# allowedOwners:
# - WORKSPACE1
# - WORKSPACE2
@@ -69,7 +69,7 @@ spec:
url: https://github.com/backstage/community/tree/main/backstage-community-sessions
- id: publish
name: Publish
action: publish:bitbucket
action: publish:bitbucketCloud
input:
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
@@ -81,10 +81,28 @@ spec:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
- id: create-pr-file
name: Create PR File
action: fetch:plain
input:
targetPath: ./pr-demo
url: https://github.com/backstage/community/blob/main/README.md
- id: create-pr
name: Create Pull Request
action: publish:bitbucketCloud:pull-request
input:
repoUrl: ${{ parameters.repoUrl }}
title: Test PR for ${{ parameters.name }}
description: This is a test pull request created by the scaffolder template to test OAuth support
sourceBranch: scaffolder-pr-${{ parameters.name }}
targetBranch: master
output:
links:
- title: Repository
url: ${{ steps['publish'].output.remoteUrl }}
- title: Pull Request
url: ${{ steps['create-pr'].output.pullRequestUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps['register'].output.entityRef }}
@@ -0,0 +1,62 @@
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: conditional-output-demo
title: Conditional Output Demo
description: Demonstrates conditional if on output links and text
spec:
owner: backstage/techdocs-core
type: service
parameters:
- title: Options
required:
- name
properties:
name:
title: Name
type: string
description: Unique name of the component
ui:autofocus: true
enableCI:
title: Enable CI
type: string
enum:
- 'Yes'
- 'No'
default: 'Yes'
showDetails:
title: Show Details
type: boolean
default: true
steps:
- id: log
name: Log
action: debug:log
input:
message: 'Creating ${{ parameters.name }}'
output:
links:
- title: Backstage Homepage
url: https://backstage.io
- if: ${{ parameters.enableCI === "Yes" }}
title: CI Dashboard
icon: dashboard
url: https://ci.example.com/${{ parameters.name }}
- if: ${{ parameters.enableCI === "Yes" }}
title: CI Docs
url: https://ci.example.com/docs
text:
- title: Summary
content: |
**Component:** `${{ parameters.name }}`
- if: ${{ parameters.showDetails }}
title: CI Details
content: |
**CI enabled:** ${{ parameters.enableCI }}
- if: ${{ parameters.enableCI === "Yes" }}
title: CI Setup Instructions
content: |
Run `ci-setup --name ${{ parameters.name }}` to configure CI.
@@ -1,4 +1,4 @@
FROM alpine:3.22
FROM alpine:3.23
RUN apk add --update \
git \
@@ -18,7 +18,7 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable @backstage/no-undeclared-imports */
const path = require('path');
const path = require('node:path');
const NUNJUCKS_LICENSE = `/**
* Copyright (c) 2012-2015, James Long
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IncomingMessage } from 'http';
import { IncomingMessage } from 'node:http';
import request from 'supertest';
import waitForExpect from 'wait-for-expect';
@@ -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],
@@ -23,6 +23,7 @@ import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import {
scaffolderActionsExtensionPoint,
scaffolderServiceRef,
TaskBroker,
TemplateAction,
} from '@backstage/plugin-scaffolder-node';
@@ -59,7 +60,12 @@ import {
convertFiltersToRecord,
convertGlobalsToRecord,
} from './util/templating';
import { actionsServiceRef } from '@backstage/backend-plugin-api/alpha';
import {
actionsServiceRef,
actionsRegistryServiceRef,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { createScaffolderActions } from './actions';
/**
* Scaffolder plugin
@@ -136,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,
@@ -144,6 +151,9 @@ export const scaffolderPlugin = createBackendPlugin({
catalog: catalogServiceRef,
events: eventsServiceRef,
actionsRegistry: actionsServiceRef,
actionsRegistryService: actionsRegistryServiceRef,
scaffolderService: scaffolderServiceRef,
metrics: metricsServiceRef,
},
async init({
logger,
@@ -156,9 +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);
@@ -211,6 +225,12 @@ export const scaffolderPlugin = createBackendPlugin({
`Starting scaffolder with the following actions enabled ${actionIds}`,
);
createScaffolderActions({
actionsRegistry: actionsRegistryService,
scaffolderService,
auth,
});
const router = await createRouter({
logger,
config,
@@ -224,11 +244,13 @@ export const scaffolderPlugin = createBackendPlugin({
auth,
httpAuth,
permissions,
permissionsRegistry,
autocompleteHandlers,
additionalWorkspaceProviders,
events,
auditor,
actionsRegistry,
metrics,
});
httpRouter.use(router);
},
@@ -0,0 +1,267 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
type DryRunTemplateOutput = {
valid: boolean;
message: string;
errors?: string[];
log?: Array<{ message: string; stepId?: string; status?: string }>;
output?: Record<string, unknown>;
steps?: Array<{ id: string; name: string; action: string }>;
};
const validTemplateYaml = `
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: test-template
namespace: default
title: Test Template
spec:
type: service
steps:
- id: step-1
name: Step One
action: debug:log
input:
message: hello
`;
const invalidYaml = `
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: test
invalid: yaml: syntax: error
spec:
type: service
steps: [
`;
describe('createDryRunTemplateAction', () => {
const mockScaffolderService = scaffolderServiceMock.mock();
beforeEach(() => {
jest.resetAllMocks();
});
it('should return success with logs when dry-run succeeds', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const dryRunResult = {
log: [
{
body: {
message: 'Step completed',
stepId: 'step-1',
status: 'completed' as const,
},
},
],
output: { result: 'ok' },
steps: [{ id: 'step-1', name: 'Step One', action: 'debug:log' }],
directoryContents: [],
};
mockScaffolderService.dryRun.mockResolvedValue(dryRunResult);
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: { templateYaml: validTemplateYaml },
});
expect(result.output).toEqual({
valid: true,
message: 'Template validation successful',
log: [
{
message: 'Step completed',
stepId: 'step-1',
status: 'completed',
},
],
output: { result: 'ok' },
steps: [{ id: 'step-1', name: 'Step One', action: 'debug:log' }],
});
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
{
template: expect.objectContaining({
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: expect.objectContaining({
name: 'test-template',
}),
}),
values: {},
directoryContents: [],
},
{ credentials: expect.anything() },
);
});
it('should pass values and files to the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.dryRun.mockResolvedValue({
log: [],
output: {},
steps: [],
directoryContents: [],
});
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const values = { name: 'my-app' };
const files = [
{
path: 'README.md',
content: 'hello',
},
];
await mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: {
templateYaml: validTemplateYaml,
values,
files,
},
});
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
{
template: expect.any(Object),
values,
directoryContents: [
{
path: 'README.md',
base64Content: Buffer.from('hello').toString('base64'),
},
],
},
{ credentials: expect.anything() },
);
});
it('should return validation errors when YAML is invalid', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: { templateYaml: invalidYaml },
});
expect(result.output).toEqual({
valid: false,
message: 'Failed to parse YAML template',
errors: expect.arrayContaining([
expect.stringContaining('YAML parsing error'),
]),
});
expect(mockScaffolderService.dryRun).not.toHaveBeenCalled();
});
it('should propagate errors from the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.dryRun.mockRejectedValue(
new Error('Authentication error'),
);
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: { templateYaml: validTemplateYaml },
}),
).rejects.toThrow('Authentication error');
});
it('should use default empty values and files when not provided', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.dryRun.mockResolvedValue({
log: [],
output: {},
steps: [],
directoryContents: [],
});
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: { templateYaml: validTemplateYaml },
});
expect(mockScaffolderService.dryRun).toHaveBeenCalledWith(
{
template: expect.any(Object),
values: {},
directoryContents: [],
},
{ credentials: expect.anything() },
);
});
it('should map log entries from body fields', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.dryRun.mockResolvedValue({
log: [{ body: { message: 'Plain log message' } }],
output: {},
steps: [],
directoryContents: [],
});
createDryRunTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:dry-run-template',
input: { templateYaml: validTemplateYaml },
});
const output = result.output as DryRunTemplateOutput;
expect(output.valid).toBe(true);
expect(output.log).toEqual([
{ message: 'Plain log message', stepId: undefined, status: undefined },
]);
});
});
@@ -0,0 +1,156 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { JsonObject } from '@backstage/types';
import yaml from 'yaml';
const MAX_CONTENT_SIZE = 64 * 1024;
function base64EncodeContent(content: string): string {
if (content.length > MAX_CONTENT_SIZE) {
return Buffer.from('<file too large>', 'utf8').toString('base64');
}
return Buffer.from(content, 'utf8').toString('base64');
}
export const createDryRunTemplateAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'dry-run-template',
title: 'Dry Run Scaffolder Template',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description:
'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',
schema: {
input: z =>
z.object({
templateYaml: z
.string()
.describe(
'The YAML content of the scaffolder template to validate',
),
values: z
.record(z.unknown())
.optional()
.describe('Input values for template parameters'),
files: z
.array(
z.object({
path: z
.string()
.describe('File path relative to template root'),
content: z.string().describe('File content'),
}),
)
.optional()
.describe('Files required for running the template'),
}),
output: z =>
z.object({
valid: z.boolean().describe('Whether the template is valid'),
message: z
.string()
.describe('Success message or validation error details'),
errors: z
.array(z.string())
.optional()
.describe('List of validation errors'),
log: z
.array(
z.object({
message: z.string(),
stepId: z.string().optional(),
status: z.string().optional(),
}),
)
.optional()
.describe('Execution log from dry-run'),
output: z
.record(z.unknown())
.optional()
.describe('Template output values'),
steps: z
.array(
z.object({
id: z.string(),
name: z.string(),
action: z.string(),
}),
)
.optional()
.describe('Parsed template steps'),
}),
},
action: async ({ input, credentials }) => {
const { templateYaml, values = {}, files = [] } = input;
let template;
try {
template = yaml.parse(templateYaml);
} catch (parseError) {
const yamlError = parseError as yaml.YAMLParseError;
return {
output: {
valid: false,
message: 'Failed to parse YAML template',
errors: [
`YAML parsing error: ${yamlError.message}`,
yamlError.linePos
? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`
: '',
].filter(Boolean),
},
};
}
const result = await scaffolderService.dryRun(
{
template,
values: values as JsonObject,
directoryContents: files.map(file => ({
path: file.path,
base64Content: base64EncodeContent(file.content),
})),
},
{ credentials },
);
return {
output: {
valid: true,
message: 'Template validation successful',
log: result.log?.map(entry => ({
message: entry.body.message,
stepId: entry.body.stepId,
status: entry.body.status,
})),
output: result.output,
steps: result.steps,
},
};
},
});
};
@@ -0,0 +1,132 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExecuteTemplateAction } from './createExecuteTemplateAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
describe('createExecuteTemplateAction', () => {
const mockScaffolderService = scaffolderServiceMock.mock();
beforeEach(() => {
jest.resetAllMocks();
});
it('should scaffold a template and return the taskId', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-abc-123',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app', owner: 'team-a' },
},
});
expect(result.output).toEqual({ taskId: 'task-abc-123' });
expect(mockScaffolderService.scaffold).toHaveBeenCalledWith(
{
templateRef: 'template:default/my-template',
values: { name: 'my-app', owner: 'team-a' },
},
{ credentials: expect.anything() },
);
});
it('should forward empty values and pass secrets to the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-with-secrets',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/secret-template',
values: {},
secrets: { apiKey: 'super-secret' },
},
});
expect(result.output).toEqual({ taskId: 'task-with-secrets' });
expect(mockScaffolderService.scaffold).toHaveBeenCalledWith(
{
templateRef: 'template:default/secret-template',
values: {},
secrets: { apiKey: 'super-secret' },
},
{ credentials: expect.anything() },
);
});
it('should not include secrets when not provided', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockResolvedValue({
taskId: 'task-no-secrets',
});
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app' },
},
});
const scaffoldCall = mockScaffolderService.scaffold.mock.calls[0][0];
expect(scaffoldCall).not.toHaveProperty('secrets');
});
it('should propagate errors from the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockScaffolderService.scaffold.mockRejectedValue(
new Error('Permission denied'),
);
createExecuteTemplateAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:execute-template',
input: {
templateRef: 'template:default/my-template',
values: { name: 'my-app' },
},
}),
).rejects.toThrow('Permission denied');
});
});
@@ -0,0 +1,81 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { JsonValue } from '@backstage/types';
export const createExecuteTemplateAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'execute-template',
title: 'Execute Scaffolder Template',
attributes: {
destructive: true,
readOnly: false,
idempotent: false,
},
description: `Executes a Scaffolder template with its template ref and input parameter values.
The template is run using the credentials provided to this action, and respects any RBAC permissions associated with those credentials.
Returns a taskId that can be used to track execution progress.
Use the catalog.get-catalog-entity action to fetch the Template entity and discover its parameter schema and secrets definition before calling this action.`,
schema: {
input: z =>
z.object({
templateRef: z
.string()
.describe(
'The template entity reference to execute, e.g. "template:default/my-template"',
),
values: z
.record(z.unknown())
.describe(
'Input parameter values required by the template. Use catalog.get-catalog-entity to discover the required parameters for the template.',
),
secrets: z
.record(z.string())
.optional()
.describe(
'Optional secrets to pass to the template execution. Use catalog.get-catalog-entity to discover the secrets definition on the Template entity.',
),
}),
output: z =>
z.object({
taskId: z
.string()
.describe(
'The task ID for the scaffolder execution. Use this to track progress or retrieve logs.',
),
}),
},
action: async ({ input, credentials }) => {
const { taskId } = await scaffolderService.scaffold(
{
templateRef: input.templateRef,
values: input.values as Record<string, JsonValue>,
...(input.secrets && { secrets: input.secrets }),
},
{ credentials },
);
return { output: { taskId } };
},
});
};
@@ -0,0 +1,148 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
import { LogEvent } from '@backstage/plugin-scaffolder-common';
import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction';
describe('createGetScaffolderTaskLogsAction', () => {
it('should return log events for a task', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
const mockEvents: LogEvent[] = [
{
id: 1,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:00Z',
type: 'log',
body: { message: 'Starting step', stepId: 'step-1' },
},
{
id: 2,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:01Z',
type: 'log',
body: { message: 'Step complete', stepId: 'step-1' },
},
{
id: 3,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:02Z',
type: 'completion',
body: { message: 'Task completed', status: 'completed' },
},
];
mockScaffolderService.getLogs.mockResolvedValue(mockEvents);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-1' },
});
expect(result.output).toEqual({
events: [
{
id: 1,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:00Z',
type: 'log',
body: {
message: 'Starting step',
stepId: 'step-1',
status: undefined,
},
},
{
id: 2,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:01Z',
type: 'log',
body: {
message: 'Step complete',
stepId: 'step-1',
status: undefined,
},
},
{
id: 3,
taskId: 'task-1',
createdAt: '2025-01-01T00:00:02Z',
type: 'completion',
body: {
message: 'Task completed',
stepId: undefined,
status: 'completed',
},
},
],
});
expect(mockScaffolderService.getLogs).toHaveBeenCalledWith(
{ taskId: 'task-1', after: undefined },
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should pass the after parameter through to the service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.getLogs.mockResolvedValue([]);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-2', after: 42 },
});
expect(result.output).toEqual({ events: [] });
expect(mockScaffolderService.getLogs).toHaveBeenCalledWith(
{ taskId: 'task-2', after: 42 },
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should throw when the service call fails', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.getLogs.mockRejectedValue(
new Error('Internal Server Error'),
);
createGetScaffolderTaskLogsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:get-scaffolder-task-logs',
input: { taskId: 'task-3' },
}),
).rejects.toThrow('Internal Server Error');
});
});
@@ -0,0 +1,112 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
export const createGetScaffolderTaskLogsAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'get-scaffolder-task-logs',
title: 'Get Scaffolder Task Logs',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `
Retrieve the log events for a scaffolder task.
Each log event has a type (log, completion, cancelled, or recovered), a body containing a message and optional step ID and status.
Use the after parameter to fetch only events after a specific event ID for incremental polling.
`,
schema: {
input: z =>
z.object({
taskId: z.string().describe('The ID of the scaffolder task'),
after: z
.number()
.int()
.min(0)
.optional()
.describe(
'Return only log events after this event ID for incremental polling',
),
}),
output: z =>
z
.object({
events: z
.array(
z.object({
id: z.number().describe('The event ID'),
taskId: z
.string()
.describe('The ID of the task this event belongs to'),
createdAt: z
.string()
.describe('Timestamp when the event was created'),
type: z
.string()
.describe(
'Event type: log, completion, cancelled, or recovered',
),
body: z
.object({
message: z.string().describe('The log message'),
stepId: z
.string()
.optional()
.describe('The step ID associated with this event'),
status: z
.string()
.optional()
.describe('The task status at the time of this event'),
})
.describe('The event body'),
}),
)
.describe('The list of log events for the task'),
})
.describe('Object containing the events array'),
},
action: async ({ input, credentials }) => {
const events = await scaffolderService.getLogs(
{ taskId: input.taskId, after: input.after },
{ credentials },
);
return {
output: {
events: events.map(event => ({
id: event.id,
taskId: event.taskId,
createdAt: event.createdAt,
type: event.type,
body: {
message: event.body.message,
stepId: event.body.stepId,
status: event.body.status,
},
})),
},
};
},
});
};
@@ -0,0 +1,164 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
import type { ListActionsResponse } from '@backstage/plugin-scaffolder-common';
type ListActionsOutput = {
actions: Array<{
id: string;
description: string;
schema: { input: object; output: object };
examples: Array<{ description: string; example: string }>;
}>;
};
describe('createListScaffolderActionsAction', () => {
it('should list all scaffolder actions sorted by id with full properties', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue(createMockActions());
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toHaveLength(3);
const actionIds = output.actions.map(a => a.id);
expect(actionIds).toEqual([
'catalog:register',
'debug:log',
'fetch:template',
]);
expect(output.actions[0]).toEqual({
id: 'catalog:register',
description: 'Registers entities in the catalog',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [{ description: 'Basic usage', example: 'register entity' }],
});
});
it('should handle actions without descriptions, schemas, or examples', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue([
{ id: 'minimal-action' },
]);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toHaveLength(1);
expect(output.actions[0]).toEqual({
id: 'minimal-action',
description: '',
schema: { input: {}, output: {} },
examples: [],
});
});
it('should return empty array when no actions are registered', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockResolvedValue([]);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
});
const output = result.output as ListActionsOutput;
expect(output.actions).toEqual([]);
});
it('should propagate errors from the scaffolder service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listActions.mockRejectedValue(
new Error('Service unavailable'),
);
createListScaffolderActionsAction({
actionsRegistry: mockActionsRegistry,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:list-scaffolder-actions',
input: {},
}),
).rejects.toThrow('Service unavailable');
});
});
function createMockActions(): ListActionsResponse {
return [
{
id: 'fetch:template',
description: 'Fetches a template',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [],
},
{
id: 'catalog:register',
description: 'Registers entities in the catalog',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [{ description: 'Basic usage', example: 'register entity' }],
},
{
id: 'debug:log',
description: 'Logs debug information',
schema: {
input: { type: 'object' },
output: { type: 'object' },
},
examples: [],
},
];
}
@@ -0,0 +1,83 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
export const createListScaffolderActionsAction = ({
actionsRegistry,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'list-scaffolder-actions',
title: 'List Scaffolder Actions',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `Lists all installed Scaffolder actions.
Each action includes:
- id: The action identifier
- description: What the action does
- schema: Input and output JSON schemas
- examples: Usage examples when available`,
schema: {
input: z => z.object({}).describe('No input is required'),
output: z =>
z.object({
actions: z.array(
z.object({
id: z.string(),
description: z.string(),
schema: z.object({
input: z
.object({})
.passthrough()
.describe('JSON Schema for input of Action'),
output: z
.object({})
.passthrough()
.describe('JSON Schema for output of Action'),
}),
examples: z.array(z.any()).optional(),
}),
),
}),
},
action: async ({ credentials }) => {
const actions = await scaffolderService.listActions(undefined, {
credentials,
});
const scaffolderActions = actions.map(action => ({
id: action.id,
description: action.description ?? '',
schema: {
input: { ...(action.schema?.input ?? {}) },
output: { ...(action.schema?.output ?? {}) },
},
examples: action.examples ?? [],
}));
return {
output: {
actions: scaffolderActions.sort((a, b) => a.id.localeCompare(b.id)),
},
};
},
});
};
@@ -0,0 +1,39 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { AuthService } from '@backstage/backend-plugin-api';
import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
import { createDryRunTemplateAction } from './createDryRunTemplateAction';
import { createListScaffolderActionsAction } from './createListScaffolderActionsAction';
import { createExecuteTemplateAction } from './createExecuteTemplateAction';
import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction';
export const createScaffolderActions = (options: {
actionsRegistry: ActionsRegistryService;
scaffolderService: ScaffolderService;
auth: AuthService;
}) => {
createListScaffolderTasksAction({
actionsRegistry: options.actionsRegistry,
auth: options.auth,
scaffolderService: options.scaffolderService,
});
createDryRunTemplateAction(options);
createListScaffolderActionsAction(options);
createExecuteTemplateAction(options);
createGetScaffolderTaskLogsAction(options);
};
@@ -0,0 +1,268 @@
/*
* 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 { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { mockServices, mockCredentials } from '@backstage/backend-test-utils';
import { NotAllowedError } from '@backstage/errors';
import { ScaffolderTask } from '@backstage/plugin-scaffolder-common';
import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils';
import { createListScaffolderTasksAction } from './listScaffolderTasksAction';
import { ListTasksResponse } from '../schema/openapi/generated/models/ListTasksResponse.model';
describe('createListScaffolderTasksAction', () => {
it('should list tasks successfully', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
const mockTasks = generateMockTasks();
mockScaffolderService.listTasks.mockResolvedValue({
items: mockTasks.tasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
})) as ScaffolderTask[],
totalItems: mockTasks.totalTasks ?? 0,
});
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: {},
});
const expectedTasks: ScaffolderTask[] = mockTasks.tasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
}));
expect(result.output).toEqual({
tasks: expectedTasks,
totalTasks: mockTasks.totalTasks ?? 0,
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{ createdBy: undefined, limit: undefined, offset: undefined },
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should pass limit and offset through to the API and return paginated results', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
const paginatedTasks = [
{
id: 'task-2',
spec: {},
status: 'completed',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:01:00Z',
},
{
id: 'task-3',
spec: {},
status: 'processing',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:02:00Z',
},
];
mockScaffolderService.listTasks.mockResolvedValue({
items: paginatedTasks as ScaffolderTask[],
totalItems: 10,
});
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: { limit: 2, offset: 1 },
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{ createdBy: undefined, limit: 2, offset: 1 },
expect.objectContaining({ credentials: expect.anything() }),
);
const expectedTasks = paginatedTasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
}));
expect(result.output).toEqual({
tasks: expectedTasks,
totalTasks: 10,
});
});
it('should throw an error if the service call fails', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockScaffolderService.listTasks.mockRejectedValue(
new Error('Internal Server Error'),
);
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: {},
}),
).rejects.toThrow('Internal Server Error');
});
it('should use createdBy filter when owned is true with user identity', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
const mockTasks = generateMockTasks();
mockAuth.isPrincipal.mockImplementation(
(creds, type) =>
type === 'user' &&
(creds?.principal as { type?: string })?.type === 'user' &&
typeof (creds.principal as { userEntityRef?: string }).userEntityRef ===
'string',
);
mockScaffolderService.listTasks.mockResolvedValue({
items: mockTasks.tasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
})) as ScaffolderTask[],
totalItems: mockTasks.totalTasks ?? 0,
});
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: { owned: true },
credentials: mockCredentials.user('user:default/alice'),
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{
createdBy: 'user:default/alice',
limit: undefined,
offset: undefined,
},
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should throw NotAllowedError when owned is true without user identity', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
mockAuth.isPrincipal.mockReturnValue(false);
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
await expect(
mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: { owned: true },
credentials: mockCredentials.service(),
}),
).rejects.toThrow(NotAllowedError);
expect(mockScaffolderService.listTasks).not.toHaveBeenCalled();
});
});
// Return a mocked ListTasksResponse that contains a number of different mocked tasks
function generateMockTasks(): ListTasksResponse {
return {
tasks: [
{
id: 'task-1',
spec: {},
status: 'completed',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:01:00Z',
createdBy: 'user:default/guest',
},
{
id: 'task-2',
spec: {},
status: 'completed',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:01:00Z',
createdBy: 'user:default/guest',
},
{
id: 'task-3',
spec: {},
status: 'processing',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:02:00Z',
createdBy: 'user:default/admin',
},
{
id: 'task-4',
spec: {},
status: 'failed',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:02:00Z',
createdBy: 'user:default/admin',
},
{
id: 'task-5',
spec: {},
status: 'cancelled',
createdAt: '2025-01-01T00:00:00Z',
lastHeartbeatAt: '2025-01-01T00:02:00Z',
createdBy: 'user:default/admin',
},
],
totalTasks: 5,
};
}
@@ -0,0 +1,133 @@
/*
* 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { AuthService } from '@backstage/backend-plugin-api';
import { NotAllowedError } from '@backstage/errors';
import { ScaffolderService } from '@backstage/plugin-scaffolder-node';
export const createListScaffolderTasksAction = ({
actionsRegistry,
auth,
scaffolderService,
}: {
actionsRegistry: ActionsRegistryService;
auth: AuthService;
scaffolderService: ScaffolderService;
}) => {
actionsRegistry.register({
name: 'list-scaffolder-tasks',
title: 'List Scaffolder Tasks',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `
This allows you to list scaffolder tasks that have been created.
Each task has a unique id, specification, and status (one of open, processing, completed, failed, cancelled, skipped).
Each task includes a timestamp for when it was created, and an optional last heartbeat timestamp indicating the most recent activity.
Set owned to true to return only tasks created by the current user; omit or set to false for all tasks the credentials can see.
Pagination is supported via limit and offset.
`,
schema: {
input: z =>
z.object({
owned: z
.boolean()
.optional()
.default(false)
.describe(
'If true, return only tasks created by the current user. Requires a user identity.',
),
limit: z
.number()
.int()
.min(1)
.max(1000)
.describe('The maximum number of tasks to return for pagination')
.optional(),
offset: z
.number()
.int()
.min(0)
.describe('The offset to start from for pagination')
.optional(),
}),
output: z =>
z
.object({
tasks: z
.array(
z.object({
id: z.string().describe('The task identifier'),
spec: z.unknown().describe('The task specification'),
status: z
.string()
.describe(
'Task status: open, processing, completed, failed, cancelled, or skipped',
),
createdAt: z
.string()
.describe('Timestamp when the task was created'),
lastHeartbeatAt: z
.string()
.optional()
.describe('Timestamp of the last heartbeat'),
}),
)
.describe('The list of scaffolder tasks'),
totalTasks: z
.number()
.describe('Total number of tasks matching the filter'),
})
.describe('Object containing a tasks array and totalTasks count'),
},
action: async ({ input, credentials }) => {
if (input.owned && !auth.isPrincipal(credentials, 'user')) {
throw new NotAllowedError(
'Filtering by owned tasks requires a user identity.',
);
}
const createdBy =
input.owned && auth.isPrincipal(credentials, 'user')
? credentials.principal.userEntityRef
: undefined;
const { items, totalItems } = await scaffolderService.listTasks(
{
createdBy,
limit: input.limit,
offset: input.offset,
},
{ credentials },
);
return {
output: {
tasks: items.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
})),
totalTasks: totalItems,
},
};
},
});
};
@@ -0,0 +1,87 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { resolveDefaultEnvironment } from './defaultEnvironment';
describe('defaultEnvironment', () => {
describe('resolveDefaultEnvironment', () => {
it('should return empty when no defaultEnvironment config is provided', () => {
const config = new ConfigReader({});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {},
secrets: {},
});
});
it('should resolve parameters and secrets from config', () => {
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
organizationName: 'acme-corp',
version: '1.0.0',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
DATABASE_PASSWORD: 'db-password',
LITERAL_SECRET: 'literal-value',
},
},
},
});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {
region: 'us-east-1',
organizationName: 'acme-corp',
version: '1.0.0',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
DATABASE_PASSWORD: 'db-password',
LITERAL_SECRET: 'literal-value',
},
});
});
it('should handle only parameters without secrets', () => {
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'eu-west-1',
},
},
},
});
const result = resolveDefaultEnvironment(config);
expect(result).toEqual({
parameters: {
region: 'eu-west-1',
},
secrets: {},
});
});
});
});
@@ -0,0 +1,66 @@
/*
* 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 { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
export interface DefaultEnvironmentConfig {
parameters?: Record<string, any>;
secrets?: Record<string, string>;
}
export interface ResolvedDefaultEnvironment {
parameters: JsonObject;
secrets: Record<string, string>;
}
export function resolveDefaultEnvironment(
config: Config,
): ResolvedDefaultEnvironment {
const defaultEnvConfig = config.getOptionalConfig(
'scaffolder.defaultEnvironment',
);
if (!defaultEnvConfig) {
return {
parameters: {},
secrets: {},
};
}
const parameters: JsonObject = {};
const secrets: Record<string, string> = {};
const parametersConfig = defaultEnvConfig.getOptionalConfig('parameters');
if (parametersConfig) {
for (const paramKey of parametersConfig.keys()) {
const paramValue = parametersConfig.getString(paramKey);
parameters[paramKey] = paramValue;
}
}
const secretsConfig = defaultEnvConfig.getOptionalConfig('secrets');
if (secretsConfig) {
for (const secretKey of secretsConfig.keys()) {
const secretValue = secretsConfig.getString(secretKey);
secrets[secretKey] = secretValue;
}
}
return {
parameters,
secrets,
};
}
@@ -93,7 +93,6 @@ export class DefaultTemplateActionRegistry implements TemplateActionRegistry {
ret.set(action.id, {
id: action.id,
description: action.description,
examples: [],
supportsDryRun:
action.attributes?.readOnly === true &&
action.attributes?.destructive === false,
@@ -22,10 +22,10 @@ const fsMock = fs as jest.Mocked<typeof fs>;
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { createCatalogWriteAction } from './write';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import * as yaml from 'yaml';
import { examples } from './write.examples';
import os from 'os';
import os from 'node:os';
describe('catalog:write', () => {
const action = createCatalogWriteAction();
@@ -20,11 +20,11 @@ jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
import os from 'os';
import os from 'node:os';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model';
import { createCatalogWriteAction } from './write';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import * as yaml from 'yaml';
describe('catalog:write', () => {
@@ -16,7 +16,7 @@
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { createDebugLogAction } from './log';
import { join } from 'path';
import { join } from 'node:path';
import yaml from 'yaml';
import { examples } from './log.examples';
import { createMockDirectory } from '@backstage/backend-test-utils';
@@ -17,9 +17,10 @@
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { Logger } from 'winston';
import { createDebugLogAction } from './log';
import { join } from 'path';
import { join } from 'node:path';
import yaml from 'yaml';
import { createMockDirectory } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
describe('debug:log', () => {
const logger = {
@@ -139,4 +140,30 @@ describe('debug:log', () => {
expect.stringContaining('Hello Backstage!'),
);
});
it('should handle symlinks to external paths', async () => {
const externalContent = 'external-file-content';
const externalPath = mockDir.resolve('external.txt');
await fs.writeFile(externalPath, externalContent);
const linkPath = join(mockContext.workspacePath, 'link');
await fs.symlink(externalPath, linkPath);
const context = {
...mockContext,
input: {
listWorkspace: 'with-contents' as const,
},
};
await action.handler(context);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('link'));
expect(logger.info).not.toHaveBeenCalledWith(
expect.stringContaining(externalContent),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('[skipped]'),
);
});
});
@@ -14,11 +14,12 @@
* limitations under the License.
*/
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import { readdir, stat } from 'fs-extra';
import { join, relative } from 'path';
import { join, relative } from 'node:path';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { examples } from './log.examples';
import fs from 'fs';
import fs from 'node:fs';
const id = 'debug:log';
@@ -66,8 +67,16 @@ export function createDebugLogAction() {
.map(f => {
const relativePath = relative(ctx.workspacePath, f);
if (ctx.input?.listWorkspace === 'with-contents') {
const content = fs.readFileSync(f, 'utf-8');
return ` - ${relativePath}:\n\n ${content}`;
try {
const safePath = resolveSafeChildPath(
ctx.workspacePath,
relativePath,
);
const content = fs.readFileSync(safePath, 'utf-8');
return ` - ${relativePath}:\n\n ${content}`;
} catch {
return ` - ${relativePath}: [skipped]`;
}
}
return ` - ${relativePath}`;
})
@@ -84,16 +84,14 @@ export function createWaitAction(options?: {
);
}
await new Promise(resolve => {
const controller = new AbortController();
await new Promise<void>(resolve => {
const timeoutHandle = setTimeout(abort, delayTime.toMillis());
ctx.signal?.addEventListener('abort', abort);
function abort() {
ctx.signal?.removeEventListener('abort', abort);
clearTimeout(timeoutHandle!);
controller.abort();
resolve('finished');
clearTimeout(timeoutHandle);
resolve();
}
});
},
@@ -16,7 +16,7 @@
import yaml from 'yaml';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createFetchPlainAction } from './plain';
@@ -19,7 +19,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => {
return { ...actual, fetchContents: jest.fn() };
});
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
@@ -22,7 +22,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => {
});
import yaml from 'yaml';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createFetchPlainFileAction } from './plainFile';
@@ -19,7 +19,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => {
return { ...actual, fetchFile: jest.fn() };
});
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { join as joinPath, sep as pathSep } from 'path';
import { join as joinPath, sep as pathSep } from 'node:path';
import fs from 'fs-extra';
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { resolvePackagePath } from '@backstage/backend-plugin-api';
@@ -19,7 +19,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => {
return { ...actual, fetchContents: jest.fn() };
});
import { join as joinPath, sep as pathSep } from 'path';
import { join as joinPath, sep as pathSep } from 'node:path';
import fs from 'fs-extra';
import {
UrlReaderService,
@@ -27,10 +27,10 @@ import {
import fs from 'fs-extra';
import globby from 'globby';
import { isBinaryFile } from 'isbinaryfile';
import { extname } from 'path';
import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters';
import { SecureTemplater } from '../../../../lib/templating/SecureTemplater';
import { convertFiltersToRecord } from '../../../../util/templating';
import { SecureTemplater } from '../../../../lib/templating/SecureTemplater';
import { extname } from 'node:path';
export type TemplateActionInput = {
targetPath?: string;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { join as joinPath } from 'path';
import { join as joinPath } from 'node:path';
import fs from 'fs-extra';
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { ScmIntegrations } from '@backstage/integration';
@@ -19,7 +19,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => {
return { ...actual, fetchFile: jest.fn() };
});
import { join as joinPath } from 'path';
import { join as joinPath } from 'node:path';
import fs from 'fs-extra';
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { ScmIntegrations } from '@backstage/integration';
@@ -22,7 +22,7 @@ import {
TemplateFilter,
TemplateGlobal,
} from '@backstage/plugin-scaffolder-node';
import path from 'path';
import path from 'node:path';
import { examples } from './templateFile.examples';
import { createTemplateFileActionHandler } from './templateFileActionHandler';
@@ -21,8 +21,10 @@ import {
TemplateGlobal,
} from '@backstage/plugin-scaffolder-node';
import fs from 'fs-extra';
import path from 'path';
import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters';
import { convertFiltersToRecord } from '../../../../util/templating';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import path from 'node:path';
import { SecureTemplater } from '../../../../lib/templating/SecureTemplater';
import { convertFiltersToRecord } from '../../../../util/templating';
@@ -23,7 +23,7 @@ import {
} from '@backstage/plugin-scaffolder-node';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import fs from 'fs-extra';
import { join as joinPath, sep as pathSep } from 'path';
import { join as joinPath, sep as pathSep } from 'node:path';
import { createWorkspaceTemplateAction } from './workspaceTemplate';
import { TemplateActionInput } from './templateActionHandler';
@@ -22,7 +22,7 @@ import {
} from '@backstage/plugin-scaffolder-node';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import fs from 'fs-extra';
import { join as joinPath } from 'path';
import { join as joinPath } from 'node:path';
import { createWorkspaceTemplateFileAction } from './workspaceTemplateFile';
import { TemplateFileActionInput } from './templateFileActionHandler';
@@ -16,7 +16,7 @@
import { createFilesystemDeleteAction } from './delete';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import fs from 'fs-extra';
import yaml from 'yaml';
import { examples } from './delete.examples';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createFilesystemDeleteAction } from './delete';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import fs from 'fs-extra';
@@ -229,4 +229,26 @@ describe('fs:delete', () => {
expect(fileExists).toBe(false);
});
});
it('should not delete files outside workspace via symlinks', async () => {
// Create an external file that should not be deleted
const externalDir = resolvePath(mockDir.path, 'external');
const externalFile = resolvePath(externalDir, 'config.yaml');
await fs.ensureDir(externalDir);
await fs.writeFile(externalFile, 'external content');
// Create a symlink inside workspace pointing to external directory
const linkPath = resolvePath(workspacePath, 'link');
await fs.symlink(externalDir, linkPath);
// Try to delete files through the symlink
await expect(() =>
action.handler({
...mockContext,
input: { files: ['link/**'] },
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
});
@@ -58,10 +58,11 @@ export const createFilesystemDeleteAction = () => {
for (const filepath of resolvedPaths) {
try {
await fs.remove(filepath);
ctx.logger.info(`File ${filepath} deleted successfully`);
const safePath = resolveSafeChildPath(ctx.workspacePath, filepath);
await fs.remove(safePath);
ctx.logger.info(`File ${safePath} deleted successfully`);
} catch (err) {
ctx.logger.error(`Failed to delete file ${filepath}:`, err);
ctx.logger.error(`Failed to delete file`, err);
throw err;
}
}
@@ -16,7 +16,7 @@
import { createFilesystemReadDirAction } from './read';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { examples } from './read.examples';
import yaml from 'yaml';
@@ -15,9 +15,9 @@
*/
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import fs from 'fs/promises';
import path from 'path';
import { z as zod } from 'zod';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z as zod } from 'zod/v3';
import { examples } from './read.examples';
const contentSchema = (z: typeof zod) =>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createFilesystemRenameAction } from './rename';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import fs from 'fs-extra';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath } from 'node:path';
import { createFilesystemRenameAction } from './rename';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import fs from 'fs-extra';
@@ -19,7 +19,9 @@ 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';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
@@ -38,8 +40,8 @@ import {
} from '@backstage/plugin-scaffolder-node';
import { JsonObject } from '@backstage/types';
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { v4 as uuid } from 'uuid';
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
@@ -79,6 +81,8 @@ export type TemplateTesterCreateOptions = {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionEvaluator;
config?: Config;
metrics: MetricsService;
};
/**
@@ -105,6 +109,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) {
},
}),
]),
config: options.config,
});
// Extracting contentsPath and dryRunId from the baseUrl
@@ -20,7 +20,7 @@ import {
TemplateActionRegistry,
} from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { JsonObject } from '@backstage/types';
import { JsonArray, JsonObject } from '@backstage/types';
import { ConfigReader } from '@backstage/config';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import {
@@ -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;
@@ -57,6 +60,16 @@ describe('NunjucksWorkflowRunner', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
},
},
},
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
@@ -219,12 +232,27 @@ describe('NunjucksWorkflowRunner', () => {
{ result: AuthorizeResult.ALLOW },
]);
const config = new ConfigReader({
scaffolder: {
defaultEnvironment: {
parameters: {
region: 'us-east-1',
},
secrets: {
AWS_ACCESS_KEY: 'test-secret-value',
},
},
},
});
runner = new NunjucksWorkflowRunner({
actionRegistry,
integrations,
workingDirectory: mockDir.path,
logger,
permissions: mockedPermissionApi,
config,
metrics: metricsServiceMock.mock(),
});
});
@@ -491,6 +519,136 @@ describe('NunjucksWorkflowRunner', () => {
});
});
describe('conditional output items', () => {
it('should include output links without an if condition', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }],
output: {
links: [{ title: 'Always', url: 'https://example.com' }],
},
});
const { output } = await runner.execute(task);
expect(output.links).toEqual([
{ title: 'Always', url: 'https://example.com' },
]);
});
it('should filter out output links where if is false', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }],
output: {
links: [
{ title: 'Always', url: 'https://example.com' },
{ if: false, title: 'Hidden', url: 'https://hidden.com' },
],
},
});
const { output } = await runner.execute(task);
expect(output.links).toEqual([
{ title: 'Always', url: 'https://example.com' },
]);
});
it('should include output links where if is true', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }],
output: {
links: [{ if: true, title: 'Visible', url: 'https://visible.com' }],
},
});
const { output } = await runner.execute(task);
expect(output.links).toEqual([
{ title: 'Visible', url: 'https://visible.com' },
]);
});
it('should filter output links based on templated if condition', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'output-action' }],
output: {
links: [
{
if: '${{ parameters.enableCI === "Yes" }}',
title: 'CI',
url: 'https://ci.example.com',
},
{
if: '${{ parameters.enableCI === "Yes" }}',
title: 'CI Docs',
url: 'https://ci.example.com/docs',
},
],
},
parameters: { enableCI: 'No' },
});
const { output } = await runner.execute(task);
expect(output.links).toEqual([]);
});
it('should include output links when templated if condition is truthy', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'output-action' }],
output: {
links: [
{
if: '${{ parameters.enableCI === "Yes" }}',
title: 'CI',
url: 'https://ci.example.com',
},
],
},
parameters: { enableCI: 'Yes' },
});
const { output } = await runner.execute(task);
expect(output.links).toEqual([
{ title: 'CI', url: 'https://ci.example.com' },
]);
});
it('should filter output text items based on if condition', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }],
output: {
text: [
{ title: 'Always', content: 'visible' },
{ if: false, title: 'Hidden', content: 'hidden' },
{
if: '${{ parameters.show }}',
title: 'Conditional',
content: 'conditional',
},
],
},
parameters: { show: true },
});
const { output } = await runner.execute(task);
expect(output.text).toEqual([
{ title: 'Always', content: 'visible' },
{ title: 'Conditional', content: 'conditional' },
]);
});
it('should strip the if field from output items that pass the condition', async () => {
const task = createMockTaskWithSpec({
steps: [{ id: 'test', name: 'test', action: 'jest-mock-action' }],
output: {
links: [{ if: true, title: 'Link', url: 'https://example.com' }],
text: [{ if: true, title: 'Text', content: 'content' }],
},
});
const { output } = await runner.execute(task);
expect((output.links as JsonArray)[0]).not.toHaveProperty('if');
expect((output.text as JsonArray)[0]).not.toHaveProperty('if');
});
});
describe('templating', () => {
it('should template the input to an action', async () => {
const task = createMockTaskWithSpec({
@@ -501,6 +659,7 @@ describe('NunjucksWorkflowRunner', () => {
action: 'jest-mock-action',
input: {
foo: '${{parameters.input | lower }}',
region: '${{environment.parameters.region}}',
},
},
],
@@ -512,7 +671,9 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: 'backstage' } }),
expect.objectContaining({
input: { foo: 'backstage', region: 'us-east-1' },
}),
);
});
@@ -784,6 +945,49 @@ describe('NunjucksWorkflowRunner', () => {
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets that are passed in the environment', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.secret);
},
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
},
{ secret: 'my-secret-value' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact meta fields properly', async () => {
actionRegistry.register({
@@ -826,6 +1030,405 @@ describe('NunjucksWorkflowRunner', () => {
expectTaskLog('info: *** {"thing":"***"}');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets that have been transformed with a replace filter', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.secret);
},
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: "${{ secrets.backstageToken | replace('.', '_DOT_') }}",
},
},
],
},
{ backstageToken: 'header.payload.signature' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets transformed with the upper filter', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.secret);
},
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: '${{ secrets.mySecret | upper }}',
},
},
],
},
{ mySecret: 'super-secret-token' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets embedded in a larger string with other text', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.message);
},
schema: {
input: {
type: 'object',
required: ['message'],
properties: {
message: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
message:
"scaffold-init:${{ secrets.backstageToken | replace('.', '_DOT_') }}",
},
},
],
},
{ backstageToken: 'header.payload.signature' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact environment secrets that have been transformed', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.secret);
},
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: '${{ environment.secrets.AWS_ACCESS_KEY | upper }}',
},
},
],
},
{},
);
await runner.execute(task);
expectTaskLog('info: ***');
});
it('should not redact non-secret values in rendered input', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.name);
},
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec({
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
name: '${{ parameters.serviceName }}',
},
},
],
parameters: { serviceName: 'my-service' },
});
await runner.execute(task);
expectTaskLog('info: my-service');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets in deeply nested input objects', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.nested.deep.secret);
},
schema: {
input: {
type: 'object',
required: ['nested'],
properties: {
nested: {
type: 'object',
properties: {
deep: {
type: 'object',
properties: {
secret: { type: 'string' },
},
},
},
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
nested: {
deep: {
secret: "${{ secrets.token | replace('.', '-') }}",
},
},
},
},
],
},
{ token: 'aaa.bbb.ccc' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact secrets in arrays within input', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(ctx.input.items[0]);
},
schema: {
input: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: { type: 'string' },
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
items: ['${{ secrets.token | upper }}', 'not-a-secret'],
},
},
],
},
{ token: 'my-secret' },
);
await runner.execute(task);
expectTaskLog('info: ***');
});
// eslint-disable-next-line jest/expect-expect
it('should redact multiple different transformed secrets in the same step', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: async ctx => {
ctx.logger.info(`${ctx.input.a} ${ctx.input.b}`);
},
schema: {
input: {
type: 'object',
required: ['a', 'b'],
properties: {
a: { type: 'string' },
b: { type: 'string' },
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
a: '${{ secrets.s1 | upper }}',
b: "${{ secrets.s2 | replace('.', '_') }}",
},
},
],
},
{ s1: 'first-secret', s2: 'second.secret' },
);
await runner.execute(task);
expectTaskLog('info: *** ***');
});
it('should still pass the correct transformed value to the action input', async () => {
actionRegistry.register({
id: 'log-secret',
description: 'Mock action for testing',
supportsDryRun: true,
handler: fakeActionHandler,
schema: {
input: {
type: 'object',
required: ['secret'],
properties: {
secret: {
type: 'string',
},
},
},
},
});
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'log-secret',
input: {
secret: "${{ secrets.backstageToken | replace('.', '_DOT_') }}",
},
},
],
},
{ backstageToken: 'header.payload.signature' },
);
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: { secret: 'header_DOT_payload_DOT_signature' },
}),
);
});
});
describe('each', () => {
@@ -996,6 +1599,50 @@ describe('NunjucksWorkflowRunner', () => {
}
});
it('should run a step repeatedly - only iterations where the "if" condition is truthy', async () => {
const truthyConditions = [true, 1, 'a', {}];
const falsyConditions = [false, 0, null, ''];
const conditions = [...truthyConditions, ...falsyConditions];
const task = createMockTaskWithSpec({
steps: [
{
id: 'test',
name: 'name',
each: '${{parameters.conditions}}',
action: 'jest-mock-action',
input: { condition: '${{each.value}}' },
if: '${{each.value}}',
},
],
parameters: {
conditions,
},
});
await runner.execute(task);
truthyConditions.forEach((condition, idx) => {
expectTaskLog(
`info: Running step each: {"key":"${idx}","value":"${condition}"}`,
);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { condition } }),
);
});
falsyConditions.forEach((condition, idx) => {
expectTaskLog(
`info: Skipping step each: {"key":"${
idx + truthyConditions.length
}","value":"${condition}"}`,
);
expect(fakeActionHandler).not.toHaveBeenCalledWith(
expect.objectContaining({ input: { condition } }),
);
});
expect(fakeActionHandler).toHaveBeenCalledTimes(truthyConditions.length);
});
it('should run a step repeatedly with validation of single-expression value', async () => {
const numbers = [5, 7, 9];
const task = createMockTaskWithSpec({
@@ -1090,7 +1737,9 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ secrets: { foo: 'bar' } }),
expect.objectContaining({
secrets: { foo: 'bar' },
}),
);
});
@@ -1104,6 +1753,7 @@ describe('NunjucksWorkflowRunner', () => {
action: 'jest-mock-action',
input: {
b: '${{ secrets.foo }}',
aws_key: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
@@ -1114,7 +1764,41 @@ describe('NunjucksWorkflowRunner', () => {
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { b: 'bar' } }),
expect.objectContaining({
input: { b: 'bar', aws_key: 'test-secret-value' },
}),
);
});
it('should separate task secrets from environment secrets', async () => {
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
b: '${{ secrets.foo }}',
aws_key: '${{ secrets.AWS_ACCESS_KEY }}',
env_aws_key: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
],
},
{ foo: 'bar', AWS_ACCESS_KEY: 'another-value-from-task' },
);
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: {
b: 'bar',
aws_key: 'another-value-from-task',
env_aws_key: 'test-secret-value',
},
}),
);
});
@@ -1133,6 +1817,7 @@ describe('NunjucksWorkflowRunner', () => {
],
output: {
b: '${{ secrets.foo }}',
c: '${{ environment.secrets.AWS_ACCESS_KEY }}',
},
},
{ foo: 'bar' },
@@ -1141,6 +1826,7 @@ describe('NunjucksWorkflowRunner', () => {
const executedTask = await runner.execute(task);
expect(executedTask.output.b).toBeUndefined();
expect(executedTask.output.c).toBeUndefined();
});
});
@@ -1501,6 +2187,42 @@ describe('NunjucksWorkflowRunner', () => {
expect(fakeActionHandler.mock.calls[0][0].step.id).toEqual('test');
expect(fakeActionHandler.mock.calls[0][0].step.name).toEqual('name');
});
it('should not pass environment secrets or task secrets to action inputs during dry-run', async () => {
const dryRunHandler = jest.fn();
actionRegistry.register(
createTemplateAction({
id: 'jest-dryrun-action',
description: 'Mock action with dry-run support',
supportsDryRun: true,
handler: dryRunHandler,
}),
);
const task = createMockTaskWithSpec(
{
steps: [
{
id: 'test',
name: 'name',
action: 'jest-dryrun-action',
input: {
envSecret: '${{ environment.secrets.AWS_ACCESS_KEY }}',
taskSecret: '${{ secrets.mySecret }}',
},
},
],
},
{ mySecret: 'task-secret-value', backstageToken: token },
true,
);
await runner.execute(task);
const handlerCall = dryRunHandler.mock.calls[0][0];
expect(handlerCall.input.envSecret).toBeUndefined();
expect(handlerCall.input.taskSecret).toBeUndefined();
});
});
describe('permissions', () => {
@@ -23,18 +23,21 @@ 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 path from 'node:path';
import nunjucks from 'nunjucks';
import path from 'path';
import * as winston from 'winston';
import {
SecureTemplater,
SecureTemplateRenderer,
} from '../../lib/templating/SecureTemplater';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import { generateExampleOutput, isTruthy } from './helper';
import {
filterConditionalItems,
generateExampleOutput,
isTruthy,
} from './helper';
import { TaskTrackType, WorkflowResponse, WorkflowRunner } from './types';
import type {
@@ -42,7 +45,9 @@ 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 { Config } from '@backstage/config';
import {
AuthorizeResult,
PolicyDecision,
@@ -51,6 +56,7 @@ import { createConditionAuthorizer } from '@backstage/plugin-permission-node';
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
import {
TaskContext,
TaskSecrets,
TemplateAction,
TemplateFilter,
TemplateGlobal,
@@ -59,11 +65,7 @@ import {
CheckpointContext,
CheckpointState,
} from '@backstage/plugin-scaffolder-node/alpha';
import { createDefaultFilters } from '../../lib/templating/filters/createDefaultFilters';
import { scaffolderActionRules } from '../../service/rules';
import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
import { convertFiltersToRecord } from '../../util/templating';
import { BackstageLoggerTransport, WinstonLogger } from './logger';
import { resolveDefaultEnvironment } from '../../lib/defaultEnvironment';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
@@ -74,10 +76,13 @@ type NunjucksWorkflowRunnerOptions = {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissions?: PermissionsService;
config?: Config;
metrics: MetricsService;
};
type TemplateContext = {
parameters: JsonObject;
environment: JsonObject;
EXPERIMENTAL_recovery?: TaskRecovery;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
@@ -103,10 +108,12 @@ const createStepLogger = ({
task,
step,
rootLogger,
redactions,
}: {
task: TaskContext;
step: TaskStep;
rootLogger: LoggerService;
redactions?: Record<string, string>;
}) => {
const taskLogger = WinstonLogger.create({
level: process.env.LOG_LEVEL || 'info',
@@ -117,11 +124,58 @@ const createStepLogger = ({
transports: [new BackstageLoggerTransport(rootLogger, task, step.id)],
});
taskLogger.addRedactions(Object.values(task.secrets ?? {}));
taskLogger.addRedactions(Object.values(redactions ?? {}));
return { taskLogger };
};
/**
* Recursively compares two rendered objects and returns string values from
* `withSecrets` that differ from their counterpart in `withoutSecrets`.
* These are values that were influenced by secret interpolation and should
* be added as log redactions.
*/
function collectSecretRedactions(
withSecrets: unknown,
withoutSecrets: unknown,
): string[] {
if (typeof withSecrets === 'string') {
return withSecrets !== withoutSecrets ? [withSecrets] : [];
}
if (Array.isArray(withSecrets)) {
const other = Array.isArray(withoutSecrets) ? withoutSecrets : [];
return withSecrets.flatMap((val, i) =>
collectSecretRedactions(val, other[i]),
);
}
if (withSecrets && typeof withSecrets === 'object') {
const other =
withoutSecrets && typeof withoutSecrets === 'object'
? (withoutSecrets as Record<string, unknown>)
: {};
return Object.entries(withSecrets as Record<string, unknown>).flatMap(
([key, val]) => collectSecretRedactions(val, other[key]),
);
}
return [];
}
/**
* Extracts all string values from a nested object structure.
* Used as a fallback when the comparison render fails.
*/
function extractStringValues(obj: unknown): string[] {
if (typeof obj === 'string') return [obj];
if (Array.isArray(obj)) return obj.flatMap(extractStringValues);
if (obj && typeof obj === 'object') {
return Object.entries(obj).flatMap(([key, val]) => [
key,
...extractStringValues(val),
]);
}
return [];
}
const isActionAuthorized = createConditionAuthorizer(
Object.values(scaffolderActionRules),
);
@@ -129,6 +183,12 @@ const isActionAuthorized = createConditionAuthorizer(
export class NunjucksWorkflowRunner implements WorkflowRunner {
private readonly defaultTemplateFilters: Record<string, TemplateFilter>;
private readonly options: NunjucksWorkflowRunnerOptions;
private environment: {
parameters: JsonObject;
secrets?: Record<string, string>;
} = { parameters: {}, secrets: {} };
private readonly tracker: ReturnType<typeof scaffoldingTracker>;
constructor(options: NunjucksWorkflowRunnerOptions) {
this.options = options;
@@ -137,9 +197,26 @@ 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;
}> {
if (this.options.config) {
const defaultEnvironment = resolveDefaultEnvironment(this.options.config);
return {
parameters: defaultEnvironment.parameters,
secrets: defaultEnvironment.secrets,
};
}
return {
parameters: {},
secrets: {},
};
}
private isSingleTemplateString(input: string) {
const { parser, nodes } = nunjucks as unknown as {
@@ -241,6 +318,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
if (
step.if === false ||
(typeof step.if === 'string' &&
step.each === undefined &&
!isTruthy(this.render(step.if, context, renderTemplate)))
) {
await stepTrack.skipFalsy();
@@ -254,26 +332,40 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
task,
step,
rootLogger: this.options.logger,
redactions: {
...task.secrets,
...this.environment?.secrets,
},
});
if (task.isDryRun) {
const redactedSecrets = Object.fromEntries(
Object.entries(task.secrets ?? {}).map(secret => [secret[0], '***']),
);
const redactedEnvironmentSecrets = Object.fromEntries(
Object.entries(this.environment?.secrets ?? {}).map(secret => [
secret[0],
'***',
]),
);
const debugInput =
(step.input &&
this.render(
step.input,
{
...context,
environment: {
parameters: this.environment?.parameters || {},
secrets: redactedEnvironmentSecrets,
},
secrets: redactedSecrets,
},
renderTemplate,
)) ??
{};
taskLogger.info(
`Running ${
action.id
`Running ${action.id
} in dry-run mode with inputs (secrets redacted): ${JSON.stringify(
debugInput,
undefined,
@@ -296,13 +388,18 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
}
}
const preIterationContext = {
...context,
environment: {
parameters: this.environment?.parameters ?? {},
secrets: task.isDryRun ? {} : this.environment?.secrets ?? {},
},
secrets: task.isDryRun ? {} : task.secrets ?? {},
};
const resolvedEach =
step.each &&
this.render(
step.each,
{ ...context, secrets: task.secrets ?? {} },
renderTemplate,
);
this.render(step.each, preIterationContext, renderTemplate);
if (step.each && !resolvedEach) {
throw new InputError(
@@ -313,24 +410,34 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const iterations = (
resolvedEach
? Object.entries(resolvedEach).map(([key, value]) => ({
each: { key, value },
}))
each: { key, value },
}))
: [{}]
).map(i => ({
...i,
// Secrets are only passed when templating the input to actions for security reasons
input: step.input
? this.render(
step.input,
{ ...context, secrets: task.secrets ?? {}, ...i },
renderTemplate,
)
: {},
}));
).map(i => {
const fullContext = { ...preIterationContext, ...i };
// Evaluate if condition once per iteration, only when using 'each'
const shouldRun =
!('each' in i) ||
!step.if ||
isTruthy(this.render(step.if, fullContext, renderTemplate));
return {
...i,
shouldRun,
// Secrets are only passed when templating the input to actions for security reasons
input: step.input
? this.render(step.input, fullContext, renderTemplate)
: {},
};
});
for (const iteration of iterations) {
const actionId = `${action.id}${
iteration.each ? `[${iteration.each.key}]` : ''
}`;
if (!iteration.shouldRun) {
// No need to check schema or authorization for iterations that will not run
continue;
}
const actionId = `${action.id}${iteration.each ? `[${iteration.each.key}]` : ''
}`;
if (action.schema?.input) {
const validateResult = validateJsonSchema(
@@ -365,15 +472,58 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
for (const iteration of iterations) {
if (iteration.each) {
if (!iteration.shouldRun) {
taskLogger.info(
`Skipping step each: ${JSON.stringify(
iteration.each,
(k, v) => (k ? String(v) : v),
0,
)}`,
);
continue;
}
taskLogger.info(
`Running step each: ${JSON.stringify(
iteration.each,
(k, v) => (k ? v.toString() : v),
(k, v) => (k ? String(v) : v),
0,
)}`,
);
}
// Redact any rendered values that were influenced by secrets.
// Re-render the input without secrets and diff against the real render
// to find values that changed due to secret interpolation.
if (step.input) {
const hasSecrets =
Object.keys(task.secrets ?? {}).length > 0 ||
Object.keys(this.environment?.secrets ?? {}).length > 0;
if (hasSecrets) {
try {
const contextNoSecrets = {
...preIterationContext,
...(iteration.each ? { each: iteration.each } : {}),
secrets: {},
environment: {
...preIterationContext.environment,
secrets: {},
},
};
const inputWithoutSecrets = this.render(
step.input,
contextNoSecrets,
renderTemplate,
);
taskLogger.addRedactions(
collectSecretRedactions(iteration.input, inputWithoutSecrets),
);
} catch {
taskLogger.addRedactions(extractStringValues(iteration.input));
}
}
}
await action.handler({
input: iteration.input,
task: {
@@ -485,6 +635,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const { additionalTemplateFilters, additionalTemplateGlobals } =
this.options;
this.environment = await this.getEnvironmentConfig();
const { render: renderTemplate, dispose } =
await SecureTemplater.loadRenderer({
templateFilters: {
@@ -502,6 +654,10 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const context: TemplateContext = {
parameters: task.spec.parameters,
environment: {
parameters: this.environment?.parameters || {},
secrets: {},
},
steps: {},
user: task.spec.user,
context: {
@@ -514,9 +670,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const [decision]: PolicyDecision[] =
this.options.permissions && task.spec.steps.length
? await this.options.permissions.authorizeConditional(
[{ permission: actionExecutePermission }],
{ credentials: await task.getInitiatorCredentials() },
)
[{ permission: actionExecutePermission }],
{ credentials: await task.getInitiatorCredentials() },
)
: [{ result: AuthorizeResult.ALLOW }];
for (const step of task.spec.steps) {
@@ -532,6 +688,15 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
}
const output = this.render(task.spec.output, context, renderTemplate);
// Filter output links and text items based on their `if` condition
if (Array.isArray(output?.links)) {
output.links = filterConditionalItems(output.links);
}
if (Array.isArray(output?.text)) {
output.text = filterConditionalItems(output.text);
}
await taskTrack.markSuccessful();
await task.cleanWorkspace?.();
@@ -549,7 +714,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',
@@ -572,23 +737,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) {
@@ -103,6 +103,22 @@ describe('StorageTaskBroker', () => {
expect(task.secrets).toEqual(fakeSecrets);
}, 10000);
it('should return secrets with priority over defaults', async () => {
const broker = new StorageTaskBroker(storage, logger);
await broker.dispatch(emptyTaskWithFakeSecretsSpec);
const task = await broker.claim();
expect(task.secrets).toEqual(fakeSecrets);
}, 10000);
it('should return all secrets', async () => {
const broker = new StorageTaskBroker(storage, logger);
await broker.dispatch(emptyTaskWithFakeSecretsSpec);
const task = await broker.claim();
expect(task.secrets).toEqual({ ...fakeSecrets });
}, 10000);
it('should complete a task', async () => {
const broker = new StorageTaskBroker(storage, logger);
const dispatchResult = await broker.dispatch(emptyTaskSpec);
@@ -232,7 +248,7 @@ describe('StorageTaskBroker', () => {
id: taskId,
}),
]),
totalTasks: 13,
totalTasks: 15,
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import os from 'os';
import os from 'node:os';
import { DatabaseManager } from '@backstage/backend-defaults/database';
import { ConfigReader } from '@backstage/config';
import { DatabaseTaskStore } from './DatabaseTaskStore';
@@ -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 {
@@ -28,7 +29,7 @@ import PQueue from 'p-queue';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
import { WorkflowRunner } from './types';
import { setTimeout } from 'timers/promises';
import { setTimeout } from 'node:timers/promises';
import { JsonObject } from '@backstage/types';
import { Config } from '@backstage/config';
@@ -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({
@@ -134,6 +137,8 @@ export class TaskWorker {
additionalTemplateFilters,
additionalTemplateGlobals,
permissions,
config,
metrics,
});
return new TaskWorker({
@@ -223,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 },
});
}
}
@@ -15,7 +15,7 @@
*/
import { Config, readDurationFromConfig } from '@backstage/config';
import { HumanDuration } from '@backstage/types';
import { HumanDuration, JsonObject, JsonValue } from '@backstage/types';
import { isArray } from 'lodash';
import { Schema } from 'jsonschema';
@@ -29,6 +29,25 @@ export function isTruthy(value: any): boolean {
return isArray(value) ? value.length > 0 : !!value;
}
function isPlainObject(item: JsonValue): item is JsonObject {
return typeof item === 'object' && item !== null && !Array.isArray(item);
}
/**
* Filters an output array by evaluating the `if` condition on each item,
* and strips the `if` field from items that pass. Non-plain-object items
* are passed through unchanged.
*/
export function filterConditionalItems<T>(items: readonly T[]): T[] {
return items.flatMap(item => {
if (!isPlainObject(item as JsonValue)) return [item];
const obj = item as JsonObject;
if ('if' in obj && !isTruthy(obj.if)) return [];
const { if: _if, ...rest } = obj;
return [rest as T];
});
}
export function generateExampleOutput(schema: Schema): unknown {
const { examples } = schema as { examples?: unknown };
if (examples && Array.isArray(examples)) {
@@ -84,9 +84,10 @@ export class BackstageLoggerTransport extends Transport {
break;
default:
this.backstageLogger.info(String(message));
break;
}
this.taskContext.emitLog(message, { stepId: this.stepId });
this.taskContext.emitLog(String(message), { stepId: this.stepId });
callback();
}
}
@@ -131,7 +132,9 @@ export class WinstonLogger implements RootLoggerService {
return obj;
}
obj[MESSAGE] = obj[MESSAGE]?.replace?.(redactionPattern, '***');
if (typeof obj[MESSAGE] === 'string') {
obj[MESSAGE] = obj[MESSAGE].replace(redactionPattern, '***');
}
return obj;
})(),
@@ -189,10 +192,13 @@ export class WinstonLogger implements RootLoggerService {
const level = info[LEVEL];
const fields = info[SPLAT];
const prefix = plugin || service;
const timestampColor = colorizer.colorize('timestamp', timestamp);
const prefixColor = colorizer.colorize('prefix', prefix);
const timestampColor = colorizer.colorize(
'timestamp',
String(timestamp),
);
const prefixColor = colorizer.colorize('prefix', String(prefix));
const extraFields = Object.entries(fields)
const extraFields = Object.entries(fields as any)
.map(
([key, value]) =>
`${colorizer.colorize('field', `${key}`)}=${value}`,
@@ -160,6 +160,10 @@ export type WorkflowResponse = { output: { [key: string]: JsonValue } };
export interface WorkflowRunner {
execute(task: TaskContext): Promise<WorkflowResponse>;
getEnvironmentConfig?(): Promise<{
parameters: JsonObject;
secrets?: TaskSecrets;
}>;
}
export type TaskTrackType = {
@@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.1.0
info:
title: scaffolder
version: '1'
@@ -35,7 +35,6 @@ components:
name: kind
in: path
required: true
allowReserved: true
schema:
type: string
limit:
@@ -51,14 +50,12 @@ components:
name: namespace
in: path
required: true
allowReserved: true
schema:
type: string
name:
name: name
in: path
required: true
allowReserved: true
schema:
type: string
offset:
@@ -94,7 +91,6 @@ components:
name: taskId
in: path
required: true
allowReserved: true
schema:
type: string
requestBodies: {}
@@ -113,7 +109,7 @@ components:
type: string
description:
type: string
"'examples'":
examples:
type: array
items:
$ref: '#/components/schemas/ActionExample'
@@ -224,8 +220,7 @@ components:
- type: boolean
- type: number
- type: string
- type: object
nullable: true
- type: 'null'
description: A type representing all allowed JSON primitive values.
JsonValue:
oneOf:
@@ -417,7 +412,7 @@ components:
$ref: '#/components/schemas/JsonObject'
output:
$ref: '#/components/schemas/JsonObject'
"'examples'":
examples:
type: array
items:
$ref: '#/components/schemas/ScaffolderUsageExample'
@@ -428,8 +423,9 @@ components:
description:
type: string
value:
type: object
nullable: true
anyOf:
- type: object
- type: 'null'
required:
- value
description: The response shape for a single global value in the `listTemplatingExtensions` call to the `scaffolder-backend`
@@ -449,7 +445,7 @@ components:
$ref: '#/components/schemas/JsonObject'
output:
$ref: '#/components/schemas/JsonObject'
"'examples'":
examples:
type: array
items:
$ref: '#/components/schemas/ScaffolderUsageExample'
@@ -690,6 +686,19 @@ paths:
type: string
required:
- id
'400':
description: Validation errors.
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
$ref: '#/components/schemas/ValidationError'
required:
- errors
security:
- {}
- JWT: []
@@ -860,13 +869,11 @@ paths:
- in: path
name: provider
required: true
allowReserved: true
schema:
type: string
- in: path
name: resource
required: true
allowReserved: true
schema:
type: string
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -116,7 +116,7 @@ export type Retry = {
taskId: string;
};
body: RetryRequest;
response: Scaffold201Response;
response: Scaffold201Response | Scaffold400Response;
};
/**
* @public
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,29 +0,0 @@
/*
* 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.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { DryRun200ResponseAllOfDirectoryContentsInner } from '../models/DryRun200ResponseAllOfDirectoryContentsInner.model';
import { DryRun200ResponseAllOfStepsInner } from '../models/DryRun200ResponseAllOfStepsInner.model';
/**
* @public
*/
export interface DryRun200ResponseAllOf {
steps: Array<DryRun200ResponseAllOfStepsInner>;
directoryContents?: Array<DryRun200ResponseAllOfDirectoryContentsInner>;
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
*/
export interface DryRun200ResponseAllOfStepsInner {
[key: string]: any;
id: string;
name: string;
action: string;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,29 +0,0 @@
/*
* 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.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { TaskStatus } from '../models/TaskStatus.model';
/**
* @public
*/
export interface DryRunResultLogInnerBodyAllOf {
message: string;
status?: TaskStatus;
stepId?: string;
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,4 +22,4 @@
* A type representing all allowed JSON primitive values.
* @public
*/
export type JsonPrimitive = any | boolean | number | string;
export type JsonPrimitive = boolean | number | string;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -26,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.

Some files were not shown because too many files have changed in this diff Show More