From aedf8c86d1160ab680c030f96edef39489de3229 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Tue, 4 Oct 2022 18:34:49 +0200 Subject: [PATCH 1/4] Add a contrib document to show how to test scaffolder templates locally Signed-off-by: Axel Hecht --- contrib/scaffolder/README.md | 16 ++ .../scaffolder/template-testing-dry-run.md | 168 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 contrib/scaffolder/README.md create mode 100644 contrib/scaffolder/template-testing-dry-run.md diff --git a/contrib/scaffolder/README.md b/contrib/scaffolder/README.md new file mode 100644 index 0000000000..8f9b229a30 --- /dev/null +++ b/contrib/scaffolder/README.md @@ -0,0 +1,16 @@ +# Scaffolder contrib tools + +## Testing Templates with Dry-run + +Scaffolder templates support anything that backstage.io and custom actions can do, so testing them is hard without actually running the instance of Backstage that they're designed for. + +The [commandline script](template-testing-dry-run.md) might offer a way for you to do so using the dry-run API used for the Template Editor. Run it against a running instance, either locally or remote, and use it like + +```sh +scaffolder-dry http://localhost:7007/ template-directory values.yml output-directory +``` + +If you're using backend-to-backend authentication, either + +- pass a front-end auth token from a current browser session via `--token $FRONTEND_TOKEN`, +- have the tool create a b2b token for a given base64 encoded backend secret via `--backend-secret $BACKEND_SECRET`. diff --git a/contrib/scaffolder/template-testing-dry-run.md b/contrib/scaffolder/template-testing-dry-run.md new file mode 100644 index 0000000000..89b92e22ef --- /dev/null +++ b/contrib/scaffolder/template-testing-dry-run.md @@ -0,0 +1,168 @@ +```js +/* + * Copyright 2022 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. + */ + +/** + * A CLI that helps you test Backstage software templates + * + * @packageDocumentation + */ + +import { dirname, join, relative } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import { ensureDir } from 'fs-extra'; +import { program } from 'commander'; +import { base64url, exportJWK, generateSecret, jwtVerify, SignJWT } from 'jose'; +import fetch from 'node-fetch'; +import readdir from 'recursive-readdir'; +import { parse } from 'yaml'; +import { version } from '../../../package.json'; +import type { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder'; + +const TOKEN_ALG = 'HS256'; +const TOKEN_SUB = 'backstage-server'; + +const loadDirectoryContents = async (template_path: string) => { + const files = await await readdir(template_path, ['.git']); + const contents = await Promise.all( + files.map(async p => { + return { + path: relative(template_path, p), + base64Content: (await readFile(p)).toString('base64'), + }; + }), + ); + return contents; +}; + +const writeResultContents = async ( + root: string, + directoryContents: ScaffolderDryRunResponse['directoryContents'], +) => { + const directories = new Set( + directoryContents.map(({ path }) => dirname(join(root, path))), + ); + await Promise.all(Array.from(directories).map(d => ensureDir(d))); + await Promise.all( + directoryContents.map(async ({ path, base64Content, executable }) => + writeFile(join(root, path), Buffer.from(base64Content, 'base64'), { + mode: executable ? 0o755 : 0o644, + }), + ), + ); +}; + +const getToken = async (backendSecret: string) => { + const signingKey = base64url.decode(backendSecret); + return await new SignJWT({}) + .setProtectedHeader({ alg: TOKEN_ALG }) + .setSubject(TOKEN_SUB) + .setExpirationTime('10min') + .sign(signingKey); +}; + +const api = async ( + bodyObj: Record, + { baseURL, token }: { baseURL: string; token: string | false }, +) => { + const body = gzipSync(JSON.stringify(bodyObj)); + return fetch(new URL('/api/scaffolder/v2/dry-run', baseURL), { + method: 'POST', + headers: { + Authorization: `Bearer ${token || ''}`, + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + }, + body, + }); +}; + +const handle = async ( + baseURL: string, + template_path: string, + data: string, + target: string, + { + token, + backendSecret, + }: { token: string | false; backendSecret: string | false }, +) => { + const directoryContents = await loadDirectoryContents(template_path); + const values = parse(await readFile(data, 'utf-8')); + const secrets = {}; + const template = parse( + await readFile(`${template_path}/template.yaml`, 'utf-8'), + ); + if (backendSecret) { + // eslint-disable-next-line no-param-reassign + token = await getToken(backendSecret); + } + const response = await api( + { + directoryContents, + values, + secrets, + template, + }, + { baseURL, token }, + ); + if (!response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType?.startsWith('application/json')) { + const responseData = await response.json(); + if (responseData.error) { + throw responseData.error; + } + if (responseData.errors) { + throw responseData.errors; + } + throw responseData; + } + throw await response.text(); + } + const { log, directoryContents: resultContents } = + (await response.json()) as ScaffolderDryRunResponse; + for (const logEntry of log) { + console.log(logEntry.body.message); + } + await writeResultContents(target, resultContents); +}; + +const main = async (argv: string[]) => { + program + .name('backstage-scaffolder') + .version(version) + .description('Creates a dry-run output of a given template') + .option('-t, --token ', 'JWT to use for auth') + .option('-b, --backend-secret ', 'Base64 encoded backend secret') + .argument('url', 'URL of your Backstage instance') + .argument('template-path', 'Source directory of template') + .argument('data-path', 'YAML file with input to render') + .argument('target', 'Output directory') + .action(handle); + + await program.parseAsync(argv); + process.exit(); +}; + +process.on('unhandledRejection', rejection => { + console.error(rejection); + process.exit(1); +}); + +main(process.argv); +``` From ac11b898ba9789520821060647e3b66a72271a80 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Tue, 4 Oct 2022 20:14:18 +0200 Subject: [PATCH 2/4] Fix spelling Signed-off-by: Axel Hecht --- contrib/scaffolder/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/scaffolder/README.md b/contrib/scaffolder/README.md index 8f9b229a30..55ffea290a 100644 --- a/contrib/scaffolder/README.md +++ b/contrib/scaffolder/README.md @@ -4,7 +4,7 @@ Scaffolder templates support anything that backstage.io and custom actions can do, so testing them is hard without actually running the instance of Backstage that they're designed for. -The [commandline script](template-testing-dry-run.md) might offer a way for you to do so using the dry-run API used for the Template Editor. Run it against a running instance, either locally or remote, and use it like +The [command line script](template-testing-dry-run.md) might offer a way for you to do so using the dry-run API used for the Template Editor. Run it against a running instance, either locally or remote, and use it like ```sh scaffolder-dry http://localhost:7007/ template-directory values.yml output-directory From 69f4c35c28e3466679c0dac7ceaffe1e3128be02 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Mon, 24 Oct 2022 18:01:15 +0200 Subject: [PATCH 3/4] Add reference to RFC Signed-off-by: Axel Hecht --- contrib/scaffolder/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/scaffolder/README.md b/contrib/scaffolder/README.md index 55ffea290a..088ad9c5d1 100644 --- a/contrib/scaffolder/README.md +++ b/contrib/scaffolder/README.md @@ -4,6 +4,8 @@ Scaffolder templates support anything that backstage.io and custom actions can do, so testing them is hard without actually running the instance of Backstage that they're designed for. +Please leave [feedback on the RFC](https://github.com/backstage/backstage/issues/14280) on your experience with this approach. + The [command line script](template-testing-dry-run.md) might offer a way for you to do so using the dry-run API used for the Template Editor. Run it against a running instance, either locally or remote, and use it like ```sh From 53b5069b022c21fecf211fcfa6c077c3f21361b6 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Wed, 2 Nov 2022 19:38:53 +0100 Subject: [PATCH 4/4] Remove backend token, add ora spinner to some steps Signed-off-by: Axel Hecht --- contrib/scaffolder/README.md | 5 +--- .../scaffolder/template-testing-dry-run.md | 26 +++---------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/contrib/scaffolder/README.md b/contrib/scaffolder/README.md index 088ad9c5d1..e22c4b391f 100644 --- a/contrib/scaffolder/README.md +++ b/contrib/scaffolder/README.md @@ -12,7 +12,4 @@ The [command line script](template-testing-dry-run.md) might offer a way for you scaffolder-dry http://localhost:7007/ template-directory values.yml output-directory ``` -If you're using backend-to-backend authentication, either - -- pass a front-end auth token from a current browser session via `--token $FRONTEND_TOKEN`, -- have the tool create a b2b token for a given base64 encoded backend secret via `--backend-secret $BACKEND_SECRET`. +If you're using backend permissions, pass a front-end auth token from a current browser session via `--token $FRONTEND_TOKEN`. diff --git a/contrib/scaffolder/template-testing-dry-run.md b/contrib/scaffolder/template-testing-dry-run.md index 89b92e22ef..70e0c572ab 100644 --- a/contrib/scaffolder/template-testing-dry-run.md +++ b/contrib/scaffolder/template-testing-dry-run.md @@ -26,17 +26,15 @@ import { readFile, writeFile } from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; import { ensureDir } from 'fs-extra'; import { program } from 'commander'; -import { base64url, exportJWK, generateSecret, jwtVerify, SignJWT } from 'jose'; import fetch from 'node-fetch'; +import ora from 'ora'; import readdir from 'recursive-readdir'; import { parse } from 'yaml'; import { version } from '../../../package.json'; import type { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder'; -const TOKEN_ALG = 'HS256'; -const TOKEN_SUB = 'backstage-server'; - const loadDirectoryContents = async (template_path: string) => { + const spinner = ora('Loading template').start(); const files = await await readdir(template_path, ['.git']); const contents = await Promise.all( files.map(async p => { @@ -46,6 +44,7 @@ const loadDirectoryContents = async (template_path: string) => { }; }), ); + spinner.succeed(); return contents; }; @@ -66,15 +65,6 @@ const writeResultContents = async ( ); }; -const getToken = async (backendSecret: string) => { - const signingKey = base64url.decode(backendSecret); - return await new SignJWT({}) - .setProtectedHeader({ alg: TOKEN_ALG }) - .setSubject(TOKEN_SUB) - .setExpirationTime('10min') - .sign(signingKey); -}; - const api = async ( bodyObj: Record, { baseURL, token }: { baseURL: string; token: string | false }, @@ -96,10 +86,7 @@ const handle = async ( template_path: string, data: string, target: string, - { - token, - backendSecret, - }: { token: string | false; backendSecret: string | false }, + { token }: { token: string | false }, ) => { const directoryContents = await loadDirectoryContents(template_path); const values = parse(await readFile(data, 'utf-8')); @@ -107,10 +94,6 @@ const handle = async ( const template = parse( await readFile(`${template_path}/template.yaml`, 'utf-8'), ); - if (backendSecret) { - // eslint-disable-next-line no-param-reassign - token = await getToken(backendSecret); - } const response = await api( { directoryContents, @@ -148,7 +131,6 @@ const main = async (argv: string[]) => { .version(version) .description('Creates a dry-run output of a given template') .option('-t, --token ', 'JWT to use for auth') - .option('-b, --backend-secret ', 'Base64 encoded backend secret') .argument('url', 'URL of your Backstage instance') .argument('template-path', 'Source directory of template') .argument('data-path', 'YAML file with input to render')