Merge branch 'master' of github.com:backstage/backstage
Signed-off-by: Justin Bryant <justintbry@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+6
-10
@@ -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();
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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', () => {
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+3
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+23
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) =>
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
|
||||
-29
@@ -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
-1
@@ -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
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
|
||||
-29
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
Reference in New Issue
Block a user