Merge pull request #7355 from backstage/feat/blam/nunjucks-template

Add support for `scaffolder.backstage.io/v1beta3` Software Templates
This commit is contained in:
Ben Lambert
2021-10-14 13:52:25 +02:00
committed by GitHub
16 changed files with 1719 additions and 1138 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Introduce the new `scaffolder.backstage.io/v1beta3` template kind with nunjucks support 🥋
@@ -620,9 +620,6 @@ The following describes the following entity kind:
| `apiVersion` | `backstage.io/v1beta2` |
| `kind` | `Template` |
If you're looking for docs on `v1alpha1` you can find them
[here](../software-templates/legacy.md)
A template definition describes both the parameters that are rendered in the
frontend part of the scaffolding wizard, and the steps that are executed when
scaffolding that component.
-117
View File
@@ -1,117 +0,0 @@
---
id: template-legacy
title: Writing Templates (Legacy)
# prettier-ignore
description: Old documentation describing the backstage.io/v1alpha1 format of the Template Schema
---
## Kind: Template
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `Template` |
A Template describes a skeleton for use with the Scaffolder. It is used for
describing what templating library is supported, and also for documenting the
variables that the template requires using
[JSON Forms Schema](https://jsonforms.io/).
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: react-ssr-template
title: React SSR Template
description:
Next.js application skeleton for creating isomorphic web applications.
tags:
- recommended
- react
spec:
owner: web@example.com
templater: cookiecutter
type: website
path: '.'
schema:
required:
- component_id
- description
properties:
component_id:
title: Name
type: string
description: Unique name of the component
description:
title: Description
type: string
description: Description of the component
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `Template`, respectively.
### `metadata.title` [required]
The nice display name for the template as a string, e.g. `React SSR Template`.
This field is required as is used to reference the template to the user instead
of the `metadata.name` field.
### `metadata.tags` [optional]
A list of strings that can be associated with the template, e.g.
`['recommended', 'react']`.
This list will also be used in the frontend to display to the user so you can
potentially search and group templates by these tags.
### `spec.type` [optional]
The type of component as a string, e.g. `website`. This field is optional but
recommended.
The software catalog accepts any type value, but an organization should take
great care to establish a proper taxonomy for these. Tools including Backstage
itself may read this field and behave differently depending on its value. For
example, a website type component may present tooling in the Backstage interface
that is specific to just websites.
The current set of well-known and common values for this field is:
- `service` - a backend service, typically exposing an API
- `website` - a website
- `library` - a software library, such as an npm module or a Java library
### `spec.templater` [required]
The templating library that is supported by the template skeleton as a string,
e.g `cookiecutter`.
Different skeletons will use different templating syntax, so it's common that
the template will need to be run with a particular piece of software.
This key will be used to identify the correct templater which is registered into
the `TemplatersBuilder`.
The values which are available by default are:
- `cookiecutter` - [cookiecutter](https://github.com/cookiecutter/cookiecutter).
### `spec.path` [optional]
The string location where the templater should be run if it is not on the same
level as the `template.yaml` definition, e.g. `./cookiecutter/skeleton`.
This will set the `cwd` when running the templater to the folder path that you
specify relative to the `template.yaml` definition.
This is also particularly useful when you have multiple template definitions in
the same repository but only a single `template.yaml` registered in backstage.
@@ -1,333 +0,0 @@
---
id: migrating-from-v1alpha1-to-v1beta2
title: Migrating to v1beta2 templates
# prettier-ignore
description: How to move your old templates from v1alpha1 to the more declarative v1beta2
---
# What's new?
Previously, the scaffolder was very restricted in what you could do when
creating new software components from templates. There were three scaffolding
steps which was pretty hard to extend and add new functionality to, difficult to
re-use logic between templates. There used to be a fixed pipeline of
`preparers`, `templaters`, and `publishers`, which were defined by the backend
and needed to be run for each template. This is now changed, to give the
template total control over what should be executed as part of the templating
run. This makes templates a little more declarative as you can now register
different `actions` or `functions` with the `scaffolder-backend` which you then
can decide how, and in what order, to run using the template definition YAML
file.
We've also made some improvements, and added some helpers to work with
cookiecutter. The skeleton for a template can now be stored in a different place
to where your entity definition is: previously you needed to have your
`template.yaml` next to the skeleton source (`{{cookiecutter.component_id}}`
directory), but now that's not the case. Part of the changes with the `v1beta2`
syntax is that you can grab your template source from any repository, and re-use
them between templates.
We've also renamed the `schema` property to `parameters` as this makes more
sense when using them as parameters to the actions or steps that you've setup
for your templates. There's the added benefit that you can now assign an array
to the `parameters` property, which will then give you multiple steps in the UI,
so you can split apart your input parameters and group them as needed rather
than having one long list of input fields.
## The `parameters` property
The `schema` key has now been renamed to `parameters` with a few more features.
You can pass an array now to break apart the input form into different steps in
the UI. You can also specify `ui:schema` fields that are passed along to
[`react-jsonschema-form`](https://rjsf-team.github.io/react-jsonschema-form/)
inline with the JSON schema.
```yaml
spec:
parameters:
- title: Fill in some steps
required:
- name
properties:
name:
title: Name
type: string
description: Unique name of the component
ui:autofocus: true
ui:options:
rows: 5
```
## The `steps` property
`v1beta2` template syntax introduces the new `steps` property, which is an array
of `actions` that the scaffolder will run in combination with the user input
that is declared in the `schema`. Actions look like the following:
```yaml
spec:
steps:
- id: publish # a unique id for the step, can be anything you like
name: Publish # a user friendly name for the step, this is what is shown in the frontend
action: publish:github # the action ID that has been registered with the scaffolder-backend
input: # parameters that are passed as input to the action handler function
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}' # handlebars templating is supported with the values from the parameters section in the same file.
repoUrl: '{{ parameters.repoUrl }}'
```
# Migrating a `v1alpha1` template
## The template definition (.yaml)
### `parameters`
Because of the changes to invert the control to the `template.yaml` definition
for running the workflow, we need to adjust the `schema` property and we also
now need to define what the template is actually going to do as part of the
template run.
A simple migration would move the following yaml:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: react-ssr-template
title: React SSR Template
description: Create a website powered with Next.js
tags:
- recommended
- react
spec:
owner: web@example.com
templater: cookiecutter
type: website
path: '.'
schema:
required:
- component_id
- description
properties:
component_id:
title: Name
type: string
description: Unique name of the component
description:
title: Description
type: string
description: Help others understand what this website is for.
```
To something that looks like the following:
```yaml
apiVersion: backstage.io/v1beta2
kind: Template
metadata:
name: react-ssr-template
title: React SSR Template
description: Create a website powered with Next.js
tags:
- recommended
- react
spec:
owner: web@example.com
type: website
parameters:
- title: Add some input
required:
- component_id
- description
properties:
component_id:
title: Name
type: string
description: Unique name of the component
description:
title: Description
type: string
description: Help others understand what this website is for.
- title: Some more additional info that was previously provided automatically
required:
- owner
- repoUrl
properties:
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Choose a location
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
```
There are a few things to note here. On the `alpha` version, the second step of
the template flow in the frontend was provided by Backstage for free, so we used
to collect the user input for the `owner` field and the `repositoryUrl` that you
were going to publish to. Now because `actions` can have any workflow they like,
it doesn't make sense to still provide these fields for every scaffolding
workflow, as you might not need these anymore. That's why we now manually add
those fields back into the template parameters that are shown to the user:
```yaml
- title: Some more additional info that was previously provided automatically
required:
- owner
- repoUrl
properties:
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Choose a location
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
```
Maybe you also don't need to publish to `github.com`, you should replace this
with your VCS provider URL that is listed in your `integrations` config instead.
### `steps`
So now we should have all the required information that we need from the user in
a much more extensible way. We now need to tell the scaffolder what to do with
these parameters and what to do with the user input.
We've made templating using `cookiecutter` a little simpler. You don't need to
store the `cookiecutter` skeleton in the same directory as the `template.yaml`
definition, it can live wherever you like - maybe a shared repository somewhere
so you can re-use the skeletons but apply different actions for different
templates depending on your use case.
We also no longer need to have a directory called
`{{cookiecutter.component_id}}`. This is because now we can't ensure that
`component_id` will be a parameter that is provided from the frontend, this
could break `cookiecutter`. If your directory structure used to look like this:
```
my-awesome-template
-> {{cookiecutter.component_id}}
-> file.txt
-> some_more_files.ts
-> hooks
-> post_gen_project.sh
-> template.yaml
```
We now recommend that you move to the following structure:
```
my-awesome-template
-> skeleton
-> file.txt
-> some_more_files.ts
-> template.yaml
```
This migration renames the skeleton folder to something more semantic, and also
drops support for `cookiecutter` hooks. We've dropped support for `cookiecutter`
hooks for now, as hopefully everything that is stored in these hooks can be
moved to `actions` instead, and for security reasons, it's more secure to run
trusted code that you ship with Backstage as an action rather than some script
that can be pulled in from anywhere which doesn't get vetted first. It's a
pretty big security risk that those scripts will be run on Backstage instances
inside your infrastructure, especially `.sh` files.
If you really need hooks and can't find a suitable solution by using actions
please reach out to us through a ticket and we'll see what we can do to assist
:)
You'll notice that we removed the `templater` property from the `spec`
definition in the template `yaml`, so there's no way to define that this is a
`cookiecutter` `templater`.
We've created a built-in action that you can use which will when run, go grab a
directory from anywhere and run `cookiecutter` on top of it, and then extract
the contents into the working directory for the scaffolder.
Adding the `steps` for a simple template should look something like the
following:
```yaml
spec:
steps:
# this action will go use cookiecutter to template some files into the working directory
- id: template # an ID for the templating step
name: Create skeleton # A user friendly name for the action
action: fetch:cookiecutter
input:
url: ./skeleton # this is the directory for your skeleton files.
# If it's located next to the `template.yaml` then you can use a relative path,
# otherwise you can use absolute URLs that point at the VCS: https://github.com/backstage/backstage/tree/master/some_folder_somewhere
values:
# for each value that you need to pass to cookiecutter, they should be listed here and set in this values object.
# You can use the handlebars templating syntax to pull them from the input parameters listed in the same file
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
destination: '{{ parseRepoUrl parameters.repoUrl }}'
# this action is for publishing the working directory to the VCS
- id: publish
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
# this action will then register the created component in Backstage
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
```
### `output`
Steps can output values, and so can the template itself. This is good for
returning values to the frontend, so we can make the buttons like
`Go to catalog` and `Go to repo` work correctly. You can add the following to
your `template.yaml` to make sure you return the right values from the steps:
```yaml
spec:
output:
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
entityRef: '{{ steps.register.output.entityRef }}'
```
Or you can return a `links` array with text and a URL explicitly:
```yaml
spec:
output:
links:
- url: '{{steps.publish.output.remoteUrl}}'
title: 'Go to Repo'
```
## Questions?
If you have any questions or feedback, please reach out to us on GitHub or
Discord and we will do our best to help!
@@ -0,0 +1,156 @@
---
id: migrating-from-v1beta2-to-v1beta3
title: Migrating to v1beta3 templates
# prettier-ignore
description: How to migrate your existing templates to beta3 syntax
---
# What's new?
Well then, here we are! 🚀
Backstage has had many forms of templating languages throughout different
plugins and different systems. We've had `cookiecutter` syntax in templates, and
we also had `handlebars` templating in the `kind: Template`. Then we wanted to
remove the additional dependency on `cookiecutter` for Software Templates out of
the box, so we introduced `nunjucks` as an alternative in `fetch:template`
action which is based on the `jinja2` syntax so they're pretty similar. In an
effort to reduce confusion and unify on to one templating language, we're
officially deprecating support for `handlebars` templating in the
`kind: Template` entities with `apiVersion` `scaffolder.backstage.io/v1beta3`
and moving to using `nunjucks` instead.
This provides us a lot of built in `filters` (`handlebars` helpers), that as
Template authors will give you much more flexibility out of the box, and also
open up sharing of filters in the Entity and the actual `skeleton` too, and
removing the slight differences between the two languages.
We've also removed a lot of the built in helpers that we shipped with
`handlebars`, as they're now supported as first class citizens by either
`nunjucks` or the new `scaffolder` when using `scaffolder.backstage.io/v1beta3`
`apiVersion`
The migration path is pretty simple, and we've removed some of the pain points
from writing the `handlebars` templates too. Let's go through what's new and how
to upgrade.
## `backstage.io/v1beta2` -> `scaffolder.backstage.io/v1beta3`
The most important change is that you'll need to switch over the `apiVersion` in
your templates to the new one.
```diff
kind: Template
- apiVersion: backstage.io/v1beta2
+ apiVersion: scaffolder.backstage.io/v1beta3
```
## `${{ }}` instead of `"{{ }}"`
One really big readability issue and cause for confusion was the fact that with
`handlebars` and `yaml` you always had to wrap your templating strings in quotes
in `yaml` so that it didn't try to parse it as a `json` object and fail. This
was pretty annoying, as it also meant that all things look like strings. Now
that's no longer the case, you can now remove the `""` and take advantage of
writing nice `yaml` files that just work.
```diff
spec:
steps:
input:
allowedHosts: ['github.com']
- description: 'This is {{ parameters.name }}'
+ description: This is ${{ parameters.name }}
- repoUrl: '{{ parameters.repoUrl }}'
+ repoUrl: ${{ parameters.repoUrl }}
```
## No more `eq` or `not` helpers
These helpers are no longer needed with the more expressive `api` that
`nunjucks` provides. You can simply use the built-in `nunjucks` and `jinja2`
style operators.
```diff
spec:
steps:
input:
- if: '{{ eq parameters.value "backstage" }}'
+ if: ${{ parameters.value === "backstage" }}
...
```
And then for the `not`
```diff
spec:
steps:
input:
- if: '{{ not parameters.value "backstage" }}'
+ if: ${{ parameters.value !== "backstage" }}
...
```
Much better right? ✨
## No more `json` helper
This helper is no longer needed, as we've added support for complex values and
supporting the additional primitive values now rather than everything being a
`string`. This means that now that you can pass around `parameters` and it
should all work as expected and keep the type that has been declared in the
input schema.
```diff
spec:
parameters:
test:
type: number
name: Test Number
address:
type: object
required:
- line1
properties:
line1:🙏
type: string
name: Line 1
line2:
type: string
name: Line 2
steps:
- id: test step
action: run:something
input:
- address: '{{ json parameters.address }}'
+ address: ${{ parameters.address }}
- number: '{{ parameters.number }}'
+ number: ${{ parameters.number }} # this will now make sure that the type of number is a number 🙏
```
## `parseRepoUrl` is now a `filter`
All calls to `parseRepoUrl` are now a `jinja2` `filter`, which means you'll need
to update the syntax.
```diff
spec:
steps:
input:
- repoUrl: '{{ parseRepoUrl parameters.repoUrl }}'
+ repoUrl: ${{ parameters.repoUrl | parseRepoUrl }}
...
```
Now we have complex value support here too, expect that this `filter` will go
away in future versions and the `RepoUrlPicker` will return an object so
`parameters.repoUrl` will already be a
`{ host: string; owner: string; repo: string }` 🚀
### Summary
Of course, we're always available on [discord](https://discord.gg/MUpMjP2) if
you're stuck or something's not working as expected. You can also
[raise an issue](https://github.com/backstage/backstage/issues/new/choose) with
feedback or bugs!
+1 -2
View File
@@ -78,8 +78,7 @@
"features/software-templates/builtin-actions",
"features/software-templates/writing-custom-actions",
"features/software-templates/writing-custom-field-extensions",
"features/software-templates/template-legacy",
"features/software-templates/migrating-from-v1alpha1-to-v1beta2"
"features/software-templates/migrating-from-v1beta2-to-v1beta3"
]
},
{
@@ -0,0 +1,35 @@
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: test-v1beta3
title: Test v1beta3
description: Test V1 Beta 3 Demo Templates
spec:
type: website
owner: team-a
parameters:
- name: Enter some stuff
description: Enter some stuff
properties:
inputString:
type: string
title: string input test
inputObject:
type: object
title: object input test
description: a little nested thing never hurt anyone right?
properties:
first:
type: string
title: first
second:
type: number
title: second
steps:
- id: debug
if: ${{ true === true }}
name: Debug
action: debug:log
input:
message: ${{ parameters.inputString }}
extra: ${{ parameters.inputObject }}
@@ -39,10 +39,15 @@ export function createDebugLogAction() {
title: 'List all files in the workspace, if true.',
type: 'boolean',
},
extra: {
title: 'Extra info',
},
},
},
},
async handler(ctx) {
ctx.logger.info(JSON.stringify(ctx.input, null, 2));
if (ctx.input?.message) {
ctx.logStream.write(ctx.input.message);
}
@@ -0,0 +1,402 @@
/*
* Copyright 2021 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 os from 'os';
import { getVoidLogger } from '@backstage/backend-common';
import { DefaultWorkflowRunner } from './DefaultWorkflowRunner';
import { TemplateActionRegistry } from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { Task, TaskSpec } from './types';
describe('DefaultWorkflowRunner', () => {
const workingDirectory = os.tmpdir();
const logger = getVoidLogger();
let actionRegistry = new TemplateActionRegistry();
let runner: DefaultWorkflowRunner;
let fakeActionHandler: jest.Mock;
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const createMockTaskWithSpec = (spec: TaskSpec): Task => ({
spec,
complete: async () => {},
done: false,
emitLog: async () => {},
getWorkspaceName: () => Promise.resolve('test-workspace'),
});
beforeEach(() => {
jest.resetAllMocks();
actionRegistry = new TemplateActionRegistry();
fakeActionHandler = jest.fn();
actionRegistry.register({
id: 'jest-mock-action',
description: 'Mock action for testing',
handler: fakeActionHandler,
});
actionRegistry.register({
id: 'jest-validated-action',
description: 'Mock action for testing',
handler: fakeActionHandler,
schema: {
input: {
type: 'object',
required: ['foo'],
properties: {
foo: {
type: 'number',
},
},
},
},
});
actionRegistry.register({
id: 'output-action',
description: 'Mock action for testing',
handler: async ctx => {
ctx.output('mock', 'backstage');
ctx.output('shouldRun', true);
},
});
runner = new DefaultWorkflowRunner({
actionRegistry,
integrations,
workingDirectory,
logger,
});
});
it('should throw an error if the action does not exist', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [{ id: 'test', name: 'name', action: 'does-not-exist' }],
});
await expect(runner.execute(task)).rejects.toThrowError(
"Template action with ID 'does-not-exist' is not registered.",
);
});
describe('validation', () => {
it('should throw an error if the action has a schema and the input does not match', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [{ id: 'test', name: 'name', action: 'jest-validated-action' }],
});
await expect(runner.execute(task)).rejects.toThrowError(
/Invalid input passed to action jest-validated-action, instance requires property \"foo\"/,
);
});
it('should run the action when the validation passes', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
name: 'name',
action: 'jest-validated-action',
input: { foo: 1 },
},
],
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledTimes(1);
});
});
describe('conditionals', () => {
it('should execute steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
id: 'conditional',
name: 'conditional',
action: 'output-action',
if: '${{ steps.test.output.shouldRun }}',
},
],
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('backstage');
});
it('should skips steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
id: 'conditional',
name: 'conditional',
action: 'output-action',
if: '${{ not steps.test.output.shouldRun}}',
},
],
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBeUndefined();
});
it('should skips steps using the negating equals operator', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
id: 'conditional',
name: 'conditional',
action: 'output-action',
if: '${{ steps.test.output.mock !== "backstage"}}',
},
],
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBeUndefined();
});
});
describe('templating', () => {
it('should template the input to an action', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
foo: '${{parameters.input | lower }}',
},
},
],
output: {},
parameters: {
input: 'BACKSTAGE',
},
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: 'backstage' } }),
);
});
it('should keep the original types for the input and not parse things that arent meant to be parsed', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
number: '${{parameters.number}}',
string: '${{parameters.string}}',
},
},
],
output: {},
parameters: {
number: 0,
string: '1',
},
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { number: 0, string: '1' } }),
);
});
it('should template complex values into the action', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
foo: '${{parameters.complex}}',
},
},
],
output: {},
parameters: {
complex: { bar: 'BACKSTAGE' },
},
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: { bar: 'BACKSTAGE' } } }),
);
});
it('supports really complex structures', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
foo: '${{parameters.complex.baz.something}}',
},
},
],
output: {},
parameters: {
complex: {
bar: 'BACKSTAGE',
baz: { something: 'nested', here: 'yas' },
},
},
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: 'nested' } }),
);
});
it('supports numbers as first class too', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'jest-mock-action',
input: {
foo: '${{parameters.complex.baz.number}}',
},
},
],
output: {},
parameters: {
complex: {
bar: 'BACKSTAGE',
baz: { number: 1 },
},
},
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledWith(
expect.objectContaining({ input: { foo: 1 } }),
);
});
it('should template the output from simple actions', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'output-action',
input: {},
},
],
output: {
foo: '${{steps.test.output.mock | upper}}',
},
parameters: {},
});
const { output } = await runner.execute(task);
expect(output.foo).toEqual('BACKSTAGE');
});
});
describe('filters', () => {
it('provides the parseRepoUrl filter', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'output-action',
input: {},
},
],
output: {
foo: '${{ parameters.repoUrl | parseRepoUrl }}',
},
parameters: {
repoUrl: 'github.com?repo=repo&owner=owner',
},
});
const { output } = await runner.execute(task);
expect(output.foo).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo',
});
});
});
});
@@ -0,0 +1,272 @@
/*
* Copyright 2021 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 { ScmIntegrations } from '@backstage/integration';
import {
Task,
TaskSpec,
TaskSpecV1beta3,
TaskStep,
WorkflowResponse,
WorkflowRunner,
} from './types';
import * as winston from 'winston';
import nunjucks from 'nunjucks';
import fs from 'fs-extra';
import path from 'path';
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { PassThrough } from 'stream';
import { isTruthy } from './helper';
import { validate as validateJsonSchema } from 'jsonschema';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { TemplateActionRegistry } from '../actions';
type Options = {
workingDirectory: string;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
logger: winston.Logger;
};
type TemplateContext = {
parameters: JsonObject;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
};
const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => {
return taskSpec.apiVersion === 'scaffolder.backstage.io/v1beta3';
};
const createStepLogger = ({ task, step }: { task: Task; step: TaskStep }) => {
const metadata = { stepId: step.id };
const taskLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: {},
});
const streamLogger = new PassThrough();
streamLogger.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, metadata);
}
});
taskLogger.add(new winston.transports.Stream({ stream: streamLogger }));
return { taskLogger, streamLogger };
};
export class DefaultWorkflowRunner implements WorkflowRunner {
private readonly nunjucks: nunjucks.Environment;
private readonly nunjucksOptions: nunjucks.ConfigureOptions = {
autoescape: false,
tags: {
variableStart: '${{',
variableEnd: '}}',
},
};
constructor(private readonly options: Options) {
this.nunjucks = nunjucks.configure(this.nunjucksOptions);
// TODO(blam): let's work out how we can deprecate these.
// We shouldn't really need to be exposing these now we can deal with
// objects in the params block.
// Maybe we can expose a new RepoUrlPicker with secrets for V3 that provides an object already.
this.nunjucks.addFilter('parseRepoUrl', repoUrl => {
return parseRepoUrl(repoUrl, this.options.integrations);
});
this.nunjucks.addFilter('projectSlug', repoUrl => {
const { owner, repo } = parseRepoUrl(repoUrl, this.options.integrations);
return `${owner}/${repo}`;
});
}
private isSingleTemplateString(input: string) {
const { parser, nodes } = require('nunjucks');
const parsed = parser.parse(input, {}, this.nunjucksOptions);
return (
parsed.children.length === 1 &&
!(parsed.children[0] instanceof nodes.TemplateData)
);
}
private render<T>(input: T, context: TemplateContext): T {
return JSON.parse(JSON.stringify(input), (_key, value) => {
try {
if (typeof value === 'string') {
try {
if (this.isSingleTemplateString(value)) {
// Lets convert ${{ parameters.bob }} to ${{ (parameters.bob) | dump }} so we can keep the input type
const wrappedDumped = value.replace(
/\${{(.+)}}/g,
'${{ ( $1 ) | dump }}',
);
// Run the templating
const templated = this.nunjucks.renderString(
wrappedDumped,
context,
);
// If there's an empty string returned, then it's undefined
if (templated === '') {
return undefined;
}
// Reparse the dumped string
return JSON.parse(templated);
}
} catch (ex) {
this.options.logger.error(
`Failed to parse template string: ${value} with error ${ex.message}`,
);
}
// Fallback to default behaviour
const templated = this.nunjucks.renderString(value, context);
if (templated === '') {
return undefined;
}
return templated;
}
} catch {
return value;
}
return value;
});
}
async execute(task: Task): Promise<WorkflowResponse> {
if (!isValidTaskSpec(task.spec)) {
throw new InputError(
'Wrong template version executed with the workflow engine',
);
}
const workspacePath = path.join(
this.options.workingDirectory,
await task.getWorkspaceName(),
);
try {
await fs.ensureDir(workspacePath);
await task.emitLog(
`Starting up task with ${task.spec.steps.length} steps`,
);
const context: TemplateContext = {
parameters: task.spec.parameters,
steps: {},
};
for (const step of task.spec.steps) {
try {
if (step.if) {
const ifResult = await this.render(step.if, context);
if (!isTruthy(ifResult)) {
await task.emitLog(
`Skipping step ${step.id} because it's if condition was false`,
{ stepId: step.id, status: 'skipped' },
);
continue;
}
}
await task.emitLog(`Beginning step ${step.name}`, {
stepId: step.id,
status: 'processing',
});
const action = this.options.actionRegistry.get(step.action);
const { taskLogger, streamLogger } = createStepLogger({ task, step });
const input = (step.input && this.render(step.input, context)) ?? {};
if (action.schema?.input) {
const validateResult = validateJsonSchema(
input,
action.schema.input,
);
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid input passed to action ${action.id}, ${errors}`,
);
}
}
const tmpDirs = new Array<string>();
const stepOutput: { [outputName: string]: JsonValue } = {};
await action.handler({
baseUrl: task.spec.baseUrl,
input,
logger: taskLogger,
logStream: streamLogger,
workspacePath,
createTemporaryDirectory: async () => {
const tmpDir = await fs.mkdtemp(
`${workspacePath}_step-${step.id}-`,
);
tmpDirs.push(tmpDir);
return tmpDir;
},
output(name: string, value: JsonValue) {
stepOutput[name] = value;
},
});
// Remove all temporary directories that were created when executing the action
for (const tmpDir of tmpDirs) {
await fs.remove(tmpDir);
}
context.steps[step.id] = { output: stepOutput };
await task.emitLog(`Finished step ${step.name}`, {
stepId: step.id,
status: 'completed',
});
} catch (err) {
await task.emitLog(String(err.stack), {
stepId: step.id,
status: 'failed',
});
throw err;
}
}
const output = this.render(task.spec.output, context);
return { output };
} finally {
if (workspacePath) {
await fs.remove(workspacePath);
}
}
}
}
@@ -0,0 +1,400 @@
/*
* Copyright 2021 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 { createTemplateAction, TemplateActionRegistry } from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { LegacyWorkflowRunner } from './LegacyWorkflowRunner';
import os from 'os';
import { Task, TaskSpec } from './types';
import { RepoSpec } from '../actions/builtin/publish/util';
describe('LegacyWorkflowRunner', () => {
let runner: LegacyWorkflowRunner;
const workingDirectory = os.tmpdir();
const logger = getVoidLogger();
let actionRegistry = new TemplateActionRegistry();
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const createMockTaskWithSpec = (spec: TaskSpec): Task => ({
spec,
complete: async () => {},
done: false,
emitLog: async () => {},
getWorkspaceName: () => Promise.resolve('test-workspace'),
});
beforeEach(() => {
actionRegistry = new TemplateActionRegistry();
actionRegistry.register({
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'mockOutputData');
ctx.output('badOutput', false);
},
});
runner = new LegacyWorkflowRunner({
actionRegistry,
integrations,
workingDirectory,
logger,
});
});
it('should fail when the action does not exist', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
await expect(() => runner.execute(task)).rejects.toThrow(
/Template action with ID 'not-found-action' is not registered/,
);
});
describe('templating', () => {
it('should template the output', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'test-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should template the input', async () => {
const inputAction = createTemplateAction<{
name: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
title: 'name',
description: 'Enter name',
type: 'string',
},
},
},
},
async handler(ctx) {
if (ctx.input.name !== 'mockOutputData') {
throw new Error(
`expected name to be "mockOutputData" got ${ctx.input.name}`,
);
}
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
name: '{{ steps.test.output.testOutput }}',
},
},
],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
});
describe('conditionals', () => {
it('should execute steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.testOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should execute steps conditionally with eq helper', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ eq steps.test.output.testOutput "mockOutputData" }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should skip test conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.badOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBeUndefined();
});
});
describe('parsing', () => {
it('should parse strings as objects if possible', async () => {
const inputAction = createTemplateAction<{
address: { line1: string };
list: string[];
address2: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['address'],
properties: {
address: {
title: 'address',
description: 'Enter name',
type: 'object',
properties: {
line1: {
type: 'string',
},
},
},
address2: {
type: 'string',
},
list: {
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (ctx.input.list.length !== 1) {
throw new Error(
`expected list to have length "1" got ${ctx.input.list.length}`,
);
}
if (ctx.input.address.line1 !== 'line 1') {
throw new Error(
`expected address.line1 to be "line 1" got ${ctx.input.address.line1}`,
);
}
if (ctx.input.address2 !== '{"not valid"}') {
throw new Error(
`expected address2 to be "{"not valid"}" got ${ctx.input.address2}`,
);
}
ctx.output('address', ctx.input.address.line1);
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
address: JSON.stringify({ line1: 'line 1' }),
list: JSON.stringify(['hey!']),
address2: '{"not valid"}',
},
},
],
output: {
result: '{{ steps.test-input.output.address }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('line 1');
});
it('should provide a parseRepoUrl helper', async () => {
const inputAction = createTemplateAction<{
destination: RepoSpec;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['destination'],
properties: {
destination: {
title: 'destination',
type: 'object',
properties: {
repo: {
type: 'string',
},
host: {
type: 'string',
},
owner: {
type: 'string',
},
organization: {
type: 'string',
},
workspace: {
type: 'string',
},
project: {
type: 'string',
},
},
},
},
},
},
async handler(ctx) {
ctx.output('host', ctx.input.destination.host);
ctx.output('repo', ctx.input.destination.repo);
if (ctx.input.destination.owner) {
ctx.output('owner', ctx.input.destination.owner);
}
if (ctx.input.destination.host !== 'github.com') {
throw new Error(
`expected host to be "github.com" got ${ctx.input.destination.host}`,
);
}
if (ctx.input.destination.repo !== 'repo') {
throw new Error(
`expected repo to be "repo" got ${ctx.input.destination.repo}`,
);
}
if (
ctx.input.destination.owner &&
ctx.input.destination.owner !== 'owner'
) {
throw new Error(
`expected repo to be "owner" got ${ctx.input.destination.owner}`,
);
}
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
destination: '{{ parseRepoUrl parameters.repoUrl }}',
},
},
],
output: {
host: '{{ steps.test-input.output.host }}',
repo: '{{ steps.test-input.output.repo }}',
owner: '{{ steps.test-input.output.owner }}',
},
values: {
repoUrl: 'github.com?repo=repo&owner=owner',
},
});
const { output } = await runner.execute(task);
expect(output.host).toBe('github.com');
expect(output.repo).toBe('repo');
expect(output.owner).toBe('owner');
});
});
});
@@ -0,0 +1,309 @@
/*
* Copyright 2021 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 {
Task,
WorkflowRunner,
WorkflowResponse,
TaskSpecV1beta2,
TaskSpec,
} from './types';
import * as Handlebars from 'handlebars';
import { TemplateActionRegistry } from '..';
import { ScmIntegrations } from '@backstage/integration';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { isTruthy } from './helper';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { Logger } from 'winston';
import path from 'path';
import fs from 'fs-extra';
import { validate as validateJsonSchema } from 'jsonschema';
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
type Options = {
workingDirectory: string;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
logger: Logger;
};
const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta2 =>
taskSpec.apiVersion === 'backstage.io/v1beta2';
/**
* This is the legacy workflow runner, which supports handlebars. This entire implementation will be replaced
* with the default workflow runner interface in the future so this entire thing can go bye bye.
*/
export class LegacyWorkflowRunner implements WorkflowRunner {
private readonly handlebars: typeof Handlebars;
constructor(private readonly options: Options) {
this.handlebars = Handlebars.create();
// TODO(blam): this should be a public facing API but it's a little
// scary right now, so we're going to lock it off like the component API is
// in the frontend until we can work out a nice way to do it.
this.handlebars.registerHelper('parseRepoUrl', repoUrl => {
return JSON.stringify(parseRepoUrl(repoUrl, this.options.integrations));
});
this.handlebars.registerHelper('projectSlug', repoUrl => {
const { owner, repo } = parseRepoUrl(repoUrl, this.options.integrations);
return `${owner}/${repo}`;
});
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
this.handlebars.registerHelper('not', value => !isTruthy(value));
this.handlebars.registerHelper('eq', (a, b) => a === b);
}
async execute(task: Task): Promise<WorkflowResponse> {
if (!isValidTaskSpec(task.spec)) {
throw new InputError(`Task spec is not a valid v1beta2 task spec`);
}
const { actionRegistry } = this.options;
const workspacePath = path.join(
this.options.workingDirectory,
await task.getWorkspaceName(),
);
try {
await fs.ensureDir(workspacePath);
await task.emitLog(
`Starting up task with ${task.spec.steps.length} steps`,
);
const templateCtx: {
parameters: JsonObject;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
} = { parameters: task.spec.values, steps: {} };
for (const step of task.spec.steps) {
const metadata = { stepId: step.id };
try {
const taskLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: {},
});
const stream = new PassThrough();
stream.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, metadata);
}
});
taskLogger.add(new winston.transports.Stream({ stream }));
if (step.if !== undefined) {
// Support passing values like false to disable steps
let skip = !step.if;
// Evaluate strings as handlebar templates
if (typeof step.if === 'string') {
const condition = JSON.parse(
JSON.stringify(step.if),
(_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
try {
return JSON.parse(templated);
} catch {
return templated;
}
}
return value;
},
);
skip = !isTruthy(condition);
}
if (skip) {
await task.emitLog(`Skipped step ${step.name}`, {
...metadata,
status: 'skipped',
});
continue;
}
}
await task.emitLog(`Beginning step ${step.name}`, {
...metadata,
status: 'processing',
});
const action = actionRegistry.get(step.action);
if (!action) {
throw new Error(`Action '${step.action}' does not exist`);
}
const input =
step.input &&
JSON.parse(JSON.stringify(step.input), (_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
});
if (action.schema?.input) {
const validateResult = validateJsonSchema(
input,
action.schema.input,
);
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid input passed to action ${action.id}, ${errors}`,
);
}
}
const stepOutputs: { [name: string]: JsonValue } = {};
// Keep track of all tmp dirs that are created by the action so we can remove them after
const tmpDirs = new Array<string>();
this.options.logger.debug(`Running ${action.id} with input`, {
input: JSON.stringify(input, null, 2),
});
await action.handler({
baseUrl: task.spec.baseUrl,
logger: taskLogger,
logStream: stream,
input,
token: task.secrets?.token,
workspacePath,
async createTemporaryDirectory() {
const tmpDir = await fs.mkdtemp(
`${workspacePath}_step-${step.id}-`,
);
tmpDirs.push(tmpDir);
return tmpDir;
},
output(name: string, value: JsonValue) {
stepOutputs[name] = value;
},
});
// Remove all temporary directories that were created when executing the action
for (const tmpDir of tmpDirs) {
await fs.remove(tmpDir);
}
templateCtx.steps[step.id] = { output: stepOutputs };
await task.emitLog(`Finished step ${step.name}`, {
...metadata,
status: 'completed',
});
} catch (error) {
await task.emitLog(String(error.stack), {
...metadata,
status: 'failed',
});
throw error;
}
}
const output = JSON.parse(
JSON.stringify(task.spec.output),
(_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
},
);
return { output };
} finally {
if (workspacePath) {
await fs.remove(workspacePath);
}
}
}
}
@@ -14,15 +14,13 @@
* limitations under the License.
*/
import os from 'os';
import { getVoidLogger, DatabaseManager } from '@backstage/backend-common';
import { ConfigReader, JsonObject } from '@backstage/config';
import { createTemplateAction, TemplateActionRegistry } from '../actions';
import { RepoSpec } from '../actions/builtin/publish/util';
import { ConfigReader } from '@backstage/config';
import { DatabaseTaskStore } from './DatabaseTaskStore';
import { StorageTaskBroker } from './StorageTaskBroker';
import { TaskWorker } from './TaskWorker';
import { ScmIntegrations } from '@backstage/integration';
import { WorkflowRunner } from './types';
import { LegacyWorkflowRunner } from './LegacyWorkflowRunner';
async function createStore(): Promise<DatabaseTaskStore> {
const manager = DatabaseManager.fromConfig(
@@ -40,138 +38,95 @@ async function createStore(): Promise<DatabaseTaskStore> {
describe('TaskWorker', () => {
let storage: DatabaseTaskStore;
let actionRegistry = new TemplateActionRegistry();
const workflowRunner: WorkflowRunner = {
execute: jest.fn(),
} as unknown as WorkflowRunner;
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const legacyWorkflowRunner: LegacyWorkflowRunner = {
execute: jest.fn(),
} as unknown as LegacyWorkflowRunner;
beforeAll(async () => {
storage = await createStore();
});
beforeEach(() => {
actionRegistry = new TemplateActionRegistry();
actionRegistry.register({
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'winning');
ctx.output('badOutput', false);
},
});
jest.resetAllMocks();
});
const logger = getVoidLogger();
it('should fail when action does not exist', async () => {
it('should call the legacy workflow runner when the apiVersion is not beta3', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
runners: {
legacyWorkflowRunner,
workflowRunner,
},
});
const { taskId } = await broker.dispatch({
await broker.dispatch({
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.error as JsonObject)?.message).toBe(
"Template action with ID 'not-found-action' is not registered.",
);
expect(legacyWorkflowRunner.execute).toHaveBeenCalled();
});
it('should template output', async () => {
it('should call the default workflow runner when the apiVersion is beta3', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
runners: {
legacyWorkflowRunner,
workflowRunner,
},
});
const { taskId } = await broker.dispatch({
steps: [{ id: 'test', name: 'test', action: 'test-action' }],
await broker.dispatch({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
parameters: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
expect(workflowRunner.execute).toHaveBeenCalled();
});
it('should template input', async () => {
const inputAction = createTemplateAction<{
name: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
title: 'name',
description: 'Enter name',
type: 'string',
},
},
},
},
async handler(ctx) {
if (ctx.input.name !== 'winning') {
throw new Error(
`expected name to be "winning" got ${ctx.input.name}`,
);
}
},
it('should save the output to the task', async () => {
(workflowRunner.execute as jest.Mock).mockResolvedValue({
output: { testOutput: 'testmockoutput' },
});
actionRegistry.register(inputAction);
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
runners: {
legacyWorkflowRunner,
workflowRunner,
},
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
name: '{{ steps.test.output.testOutput }}',
},
},
],
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
parameters: {},
});
const task = await broker.claim();
@@ -179,312 +134,6 @@ describe('TaskWorker', () => {
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should execute steps conditionally', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.testOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should execute steps conditionally with eq helper', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ eq steps.test.output.testOutput "winning" }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should skip steps conditionally', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.badOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBeUndefined();
});
it('should parse strings as objects if possible', async () => {
const inputAction = createTemplateAction<{
address: { line1: string };
list: string[];
address2: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['address'],
properties: {
address: {
title: 'address',
description: 'Enter name',
type: 'object',
properties: {
line1: {
type: 'string',
},
},
},
address2: {
type: 'string',
},
list: {
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (ctx.input.list.length !== 1) {
throw new Error(
`expected list to have length "1" got ${ctx.input.list.length}`,
);
}
if (ctx.input.address.line1 !== 'line 1') {
throw new Error(
`expected address.line1 to be "line 1" got ${ctx.input.address.line1}`,
);
}
if (ctx.input.address2 !== '{"not valid"}') {
throw new Error(
`expected address2 to be "{"not valid"}" got ${ctx.input.address2}`,
);
}
ctx.output('address', ctx.input.address.line1);
},
});
actionRegistry.register(inputAction);
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
});
const { taskId } = await broker.dispatch({
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
address: JSON.stringify({ line1: 'line 1' }),
list: JSON.stringify(['hey!']),
address2: '{"not valid"}',
},
},
],
output: {
result: '{{ steps.test-input.output.address }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('line 1');
});
// TODO(blam): Can delete this test when we make the helpers a public API
it('should provide a repoUrlParse helper for the templates', async () => {
const inputAction = createTemplateAction<{
destination: RepoSpec;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['destination'],
properties: {
destination: {
title: 'destination',
type: 'object',
properties: {
repo: {
type: 'string',
},
host: {
type: 'string',
},
owner: {
type: 'string',
},
organization: {
type: 'string',
},
workspace: {
type: 'string',
},
project: {
type: 'string',
},
},
},
},
},
},
async handler(ctx) {
ctx.output('host', ctx.input.destination.host);
ctx.output('repo', ctx.input.destination.repo);
if (ctx.input.destination.owner) {
ctx.output('owner', ctx.input.destination.owner);
}
if (ctx.input.destination.host !== 'github.com') {
throw new Error(
`expected host to be "github.com" got ${ctx.input.destination.host}`,
);
}
if (ctx.input.destination.repo !== 'repo') {
throw new Error(
`expected repo to be "repo" got ${ctx.input.destination.repo}`,
);
}
if (
ctx.input.destination.owner &&
ctx.input.destination.owner !== 'owner'
) {
throw new Error(
`expected repo to be "owner" got ${ctx.input.destination.owner}`,
);
}
},
});
actionRegistry.register(inputAction);
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
integrations,
});
const { taskId } = await broker.dispatch({
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
destination: '{{ parseRepoUrl parameters.repoUrl }}',
},
},
],
output: {
host: '{{ steps.test-input.output.host }}',
repo: '{{ steps.test-input.output.repo }}',
owner: '{{ steps.test-input.output.owner }}',
},
values: {
repoUrl: 'github.com?repo=repo&owner=owner',
},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).host).toBe('github.com');
expect((event?.body?.output as JsonObject).repo).toBe('repo');
expect((event?.body?.output as JsonObject).owner).toBe('owner');
expect(event?.body.output).toEqual({ testOutput: 'testmockoutput' });
});
});
@@ -13,55 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
import fs from 'fs-extra';
import * as Handlebars from 'handlebars';
import { validate as validateJsonSchema } from 'jsonschema';
import path from 'path';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { Logger } from 'winston';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import { isTruthy } from './helper';
import { Task, TaskBroker } from './types';
import { ScmIntegrations } from '@backstage/integration';
import { Task, TaskBroker, WorkflowRunner } from './types';
import { LegacyWorkflowRunner } from './LegacyWorkflowRunner';
type Options = {
logger: Logger;
taskBroker: TaskBroker;
workingDirectory: string;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
runners: {
legacyWorkflowRunner: LegacyWorkflowRunner;
workflowRunner: WorkflowRunner;
};
};
export class TaskWorker {
private readonly handlebars: typeof Handlebars;
constructor(private readonly options: Options) {
this.handlebars = Handlebars.create();
// TODO(blam): this should be a public facing API but it's a little
// scary right now, so we're going to lock it off like the component API is
// in the frontend until we can work out a nice way to do it.
this.handlebars.registerHelper('parseRepoUrl', repoUrl => {
return JSON.stringify(parseRepoUrl(repoUrl, options.integrations));
});
this.handlebars.registerHelper('projectSlug', repoUrl => {
const { owner, repo } = parseRepoUrl(repoUrl, options.integrations);
return `${owner}/${repo}`;
});
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
this.handlebars.registerHelper('not', value => !isTruthy(value));
this.handlebars.registerHelper('eq', (a, b) => a === b);
}
constructor(private readonly options: Options) {}
start() {
(async () => {
for (;;) {
@@ -72,238 +36,17 @@ export class TaskWorker {
}
async runOneTask(task: Task) {
let workspacePath: string | undefined = undefined;
try {
const { actionRegistry } = this.options;
workspacePath = path.join(
this.options.workingDirectory,
await task.getWorkspaceName(),
);
await fs.ensureDir(workspacePath);
await task.emitLog(
`Starting up task with ${task.spec.steps.length} steps`,
);
const templateCtx: {
parameters: JsonObject;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
} = { parameters: task.spec.values, steps: {} };
for (const step of task.spec.steps) {
const metadata = { stepId: step.id };
try {
const taskLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: {},
});
const stream = new PassThrough();
stream.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, metadata);
}
});
taskLogger.add(new winston.transports.Stream({ stream }));
if (step.if !== undefined) {
// Support passing values like false to disable steps
let skip = !step.if;
// Evaluate strings as handlebar templates
if (typeof step.if === 'string') {
const condition = JSON.parse(
JSON.stringify(step.if),
(_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
try {
return JSON.parse(templated);
} catch {
return templated;
}
}
return value;
},
);
skip = !isTruthy(condition);
}
if (skip) {
await task.emitLog(`Skipped step ${step.name}`, {
...metadata,
status: 'skipped',
});
continue;
}
}
await task.emitLog(`Beginning step ${step.name}`, {
...metadata,
status: 'processing',
});
const action = actionRegistry.get(step.action);
if (!action) {
throw new Error(`Action '${step.action}' does not exist`);
}
const input =
step.input &&
JSON.parse(JSON.stringify(step.input), (_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
});
if (action.schema?.input) {
const validateResult = validateJsonSchema(
input,
action.schema.input,
);
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid input passed to action ${action.id}, ${errors}`,
);
}
}
const stepOutputs: { [name: string]: JsonValue } = {};
// Keep track of all tmp dirs that are created by the action so we can remove them after
const tmpDirs = new Array<string>();
this.options.logger.debug(`Running ${action.id} with input`, {
input: JSON.stringify(input, null, 2),
});
await action.handler({
baseUrl: task.spec.baseUrl,
logger: taskLogger,
logStream: stream,
input,
token: task.secrets?.token,
workspacePath,
async createTemporaryDirectory() {
const tmpDir = await fs.mkdtemp(
`${workspacePath}_step-${step.id}-`,
);
tmpDirs.push(tmpDir);
return tmpDir;
},
output(name: string, value: JsonValue) {
stepOutputs[name] = value;
},
});
// Remove all temporary directories that were created when executing the action
for (const tmpDir of tmpDirs) {
await fs.remove(tmpDir);
}
templateCtx.steps[step.id] = { output: stepOutputs };
await task.emitLog(`Finished step ${step.name}`, {
...metadata,
status: 'completed',
});
} catch (error) {
await task.emitLog(String(error.stack), {
...metadata,
status: 'failed',
});
throw error;
}
}
const output = JSON.parse(
JSON.stringify(task.spec.output),
(_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
},
);
const { output } =
task.spec.apiVersion === 'scaffolder.backstage.io/v1beta3'
? await this.options.runners.workflowRunner.execute(task)
: await this.options.runners.legacyWorkflowRunner.execute(task);
await task.complete('completed', { output });
} catch (error) {
await task.complete('failed', {
error: { name: error.name, message: error.message },
});
} finally {
if (workspacePath) {
await fs.remove(workspacePath);
}
}
}
}
@@ -43,7 +43,8 @@ export type DbTaskEventRow = {
createdAt: string;
};
export type TaskSpec = {
export interface TaskSpecV1beta2 {
apiVersion: 'backstage.io/v1beta2';
baseUrl?: string;
values: JsonObject;
steps: Array<{
@@ -54,7 +55,24 @@ export type TaskSpec = {
if?: string | boolean;
}>;
output: { [name: string]: string };
};
}
export interface TaskStep {
id: string;
name: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}
export interface TaskSpecV1beta3 {
apiVersion: 'scaffolder.backstage.io/v1beta3';
baseUrl?: string;
parameters: JsonObject;
steps: TaskStep[];
output: { [name: string]: JsonValue };
}
export type TaskSpec = TaskSpecV1beta2 | TaskSpecV1beta3;
export type TaskSecrets = {
token: string | undefined;
@@ -122,3 +140,8 @@ export interface TaskStore {
after,
}: TaskStoreGetEventsOptions): Promise<{ events: DbTaskEventRow[] }>;
}
export type WorkflowResponse = { output: { [key: string]: JsonValue } };
export interface WorkflowRunner {
execute(task: Task): Promise<WorkflowResponse>;
}
@@ -35,9 +35,14 @@ import {
import { InputError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../scaffolder/actions';
import { createBuiltinActions } from '../scaffolder/actions/builtin/createBuiltinActions';
import { LegacyWorkflowRunner } from '../scaffolder/tasks/LegacyWorkflowRunner';
import { DefaultWorkflowRunner } from '../scaffolder/tasks/DefaultWorkflowRunner';
import { TaskSpec } from '../scaffolder/tasks/types';
export interface RouterOptions {
logger: Logger;
@@ -50,10 +55,13 @@ export interface RouterOptions {
containerRunner: ContainerRunner;
}
function isBeta2Template(
entity: TemplateEntityV1beta2,
): entity is TemplateEntityV1beta2 {
return entity.apiVersion === 'backstage.io/v1beta2';
function isSupportedTemplate(
entity: TemplateEntityV1beta2 | TemplateEntityV1beta3,
) {
return (
entity.apiVersion === 'backstage.io/v1beta2' ||
entity.apiVersion === 'scaffolder.backstage.io/v1beta3'
);
}
export async function createRouter(
@@ -83,14 +91,28 @@ export async function createRouter(
);
const taskBroker = new StorageTaskBroker(databaseTaskStore, logger);
const actionRegistry = new TemplateActionRegistry();
const legacyWorkflowRunner = new LegacyWorkflowRunner({
logger,
actionRegistry,
integrations,
workingDirectory,
});
const workflowRunner = new DefaultWorkflowRunner({
actionRegistry,
integrations,
logger,
workingDirectory,
});
const workers = [];
for (let i = 0; i < (taskWorkers || 1); i++) {
const worker = new TaskWorker({
logger,
taskBroker,
actionRegistry,
workingDirectory,
integrations,
runners: {
legacyWorkflowRunner,
workflowRunner,
},
});
workers.push(worker);
}
@@ -128,7 +150,7 @@ export async function createRouter(
const template = await entityClient.findTemplate(name, {
token: getBearerToken(req.headers.authorization),
});
if (isBeta2Template(template)) {
if (isSupportedTemplate(template)) {
const parameters = [template.spec.parameters ?? []].flat();
res.json({
title: template.metadata.title ?? template.metadata.name,
@@ -164,9 +186,9 @@ export async function createRouter(
token,
});
let taskSpec;
let taskSpec: TaskSpec;
if (isBeta2Template(template)) {
if (isSupportedTemplate(template)) {
for (const parameters of [template.spec.parameters ?? []].flat()) {
const result = validate(values, parameters);
@@ -178,16 +200,30 @@ export async function createRouter(
const baseUrl = getEntityBaseUrl(template);
taskSpec = {
baseUrl,
values,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
};
taskSpec =
template.apiVersion === 'backstage.io/v1beta2'
? {
apiVersion: template.apiVersion,
baseUrl,
values,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
}
: {
apiVersion: template.apiVersion,
baseUrl,
parameters: values,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
};
} else {
throw new InputError(
`Unsupported apiVersion field in schema entity, ${