diff --git a/.changeset/dull-rocks-warn.md b/.changeset/dull-rocks-warn.md new file mode 100644 index 0000000000..0ccf4f20cb --- /dev/null +++ b/.changeset/dull-rocks-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new property called `additionalTemplateGlobals` which allows you to add global functions to the scaffolder nunjucks templates. diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index a148999836..ca5f68294b 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -60,6 +60,8 @@ export const createBuiltinActions: ( // @public export interface CreateBuiltInActionsOptions { additionalTemplateFilters?: Record; + // (undocumented) + additionalTemplateGlobals?: Record; catalogClient: CatalogApi; config: Config; integrations: ScmIntegrations; @@ -108,6 +110,7 @@ export function createFetchTemplateAction(options: { reader: UrlReader; integrations: ScmIntegrations; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }): TemplateAction<{ url: string; targetPath?: string | undefined; @@ -450,6 +453,7 @@ export type CreateWorkerOptions = { workingDirectory: string; logger: Logger; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; // @public @@ -540,6 +544,8 @@ export interface RouterOptions { // (undocumented) additionalTemplateFilters?: Record; // (undocumented) + additionalTemplateGlobals?: Record; + // (undocumented) catalogClient: CatalogApi; // (undocumented) config: Config; @@ -595,6 +601,7 @@ export type ScaffolderPluginOptions = { taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; // @public @@ -815,4 +822,9 @@ export class TemplateActionRegistry { // @public (undocumented) export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; + +// @public (undocumented) +export type TemplateGlobal = + | ((...args: JsonValue[]) => JsonValue | undefined) + | JsonValue; ``` diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index b884802a1f..09d7bdcbb2 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -26,7 +26,7 @@ import { } from '@backstage/backend-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; -import { TemplateFilter } from './lib'; +import { TemplateFilter, TemplateGlobal } from './lib'; import { createBuiltinActions, TaskBroker, TemplateAction } from './scaffolder'; import { createRouter } from './service/router'; @@ -39,6 +39,7 @@ export type ScaffolderPluginOptions = { taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; /** @@ -101,7 +102,12 @@ export const scaffolderPlugin = createBackendPlugin({ httpRouter, catalogClient, }) { - const { additionalTemplateFilters, taskBroker, taskWorkers } = options; + const { + additionalTemplateFilters, + taskBroker, + taskWorkers, + additionalTemplateGlobals, + } = options; const log = loggerToWinstonLogger(logger); const actions = options.actions || [ @@ -112,6 +118,7 @@ export const scaffolderPlugin = createBackendPlugin({ reader, config, additionalTemplateFilters, + additionalTemplateGlobals, }), ]; @@ -130,6 +137,7 @@ export const scaffolderPlugin = createBackendPlugin({ taskBroker, taskWorkers, additionalTemplateFilters, + additionalTemplateGlobals, }); httpRouter.use(router); }, diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts index 8b8d7847ac..76643131f5 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts @@ -144,6 +144,27 @@ describe('SecureTemplater', () => { ['the input value', 'another extra arg'], ]); }); + it('should make additional globals available when requested', async () => { + const mockGlobal1 = jest.fn(() => 'awesome global function'); + const mockGlobal2 = 'foo'; + const mockGlobal3 = 123456; + const renderWith = await SecureTemplater.loadRenderer({ + additionalTemplateGlobals: { mockGlobal1, mockGlobal2, mockGlobal3 }, + }); + const renderWithout = await SecureTemplater.loadRenderer(); + + const ctx = {}; + + expect(renderWith('${{ mockGlobal1() }}', ctx)).toBe( + 'awesome global function', + ); + expect(renderWith('${{ mockGlobal2 }}', ctx)).toBe('foo'); + expect(renderWith('${{ mockGlobal3 }}', ctx)).toBe('123456'); + + expect(() => renderWithout('${{ mockGlobal1() }}', ctx)).toThrow( + /Error: Unable to call `mockGlobal1`/, + ); + }); it('should not allow helpers to be rewritten', async () => { const render = await SecureTemplater.loadRenderer({ diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index e82620a90d..7dc42658be 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -64,6 +64,16 @@ const { render, renderCompat } = (() => { } } + if (typeof additionalTemplateGlobals !== 'undefined') { + for (const [globalName, global] of Object.entries(additionalTemplateGlobals)) { + if (typeof global === 'function') { + env.addGlobal(globalName, (...args) => JSON.parse(global(...args))); + } else { + env.addGlobal(globalName, JSON.parse(global)); + } + } + } + let uninstallCompat = undefined; function render(str, values) { @@ -98,6 +108,11 @@ const { render, renderCompat } = (() => { /** @public */ export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; +/** @public */ +export type TemplateGlobal = + | ((...args: JsonValue[]) => JsonValue | undefined) + | JsonValue; + export interface SecureTemplaterOptions { /* Optional implementation of the parseRepoUrl filter */ parseRepoUrl?(repoUrl: string): RepoSpec; @@ -107,6 +122,8 @@ export interface SecureTemplaterOptions { /* Extra user-provided nunjucks filters */ additionalTemplateFilters?: Record; + /* Extra user-provided nunjucks globals */ + additionalTemplateGlobals?: Record; } export type SecureTemplateRenderer = ( @@ -116,8 +133,12 @@ export type SecureTemplateRenderer = ( export class SecureTemplater { static async loadRenderer(options: SecureTemplaterOptions = {}) { - const { parseRepoUrl, cookiecutterCompat, additionalTemplateFilters } = - options; + const { + parseRepoUrl, + cookiecutterCompat, + additionalTemplateFilters, + additionalTemplateGlobals, + } = options; const sandbox: Record = {}; if (parseRepoUrl) { @@ -134,7 +155,21 @@ export class SecureTemplater { ]), ); } - + if (additionalTemplateGlobals) { + sandbox.additionalTemplateGlobals = Object.fromEntries( + Object.entries(additionalTemplateGlobals) + .filter(([_, global]) => !!global) + .map(([globalName, global]) => { + if (typeof global === 'function') { + return [ + globalName, + (...args: JsonValue[]) => JSON.stringify(global(...args)), + ]; + } + return [globalName, JSON.stringify(global)]; + }), + ); + } const vm = new VM({ sandbox }); const nunjucksSource = await fs.readFile( diff --git a/plugins/scaffolder-backend/src/lib/templating/index.ts b/plugins/scaffolder-backend/src/lib/templating/index.ts index 29d77291a1..1f9e5eb855 100644 --- a/plugins/scaffolder-backend/src/lib/templating/index.ts +++ b/plugins/scaffolder-backend/src/lib/templating/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export type { TemplateFilter } from './SecureTemplater'; +export type { TemplateFilter, TemplateGlobal } from './SecureTemplater'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index fcddf127e2..e388a70f00 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -28,7 +28,7 @@ import { createCatalogWriteAction, } from './catalog'; -import { TemplateFilter } from '../../../lib'; +import { TemplateFilter, TemplateGlobal } from '../../../lib'; import { TemplateAction } from '../types'; import { createDebugLogAction } from './debug'; import { createFetchPlainAction, createFetchTemplateAction } from './fetch'; @@ -82,6 +82,7 @@ export interface CreateBuiltInActionsOptions { * Template Manifests and also template skeleton files when using `fetch:template`. */ additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; } /** @@ -100,6 +101,7 @@ export const createBuiltinActions = ( catalogClient, config, additionalTemplateFilters, + additionalTemplateGlobals, } = options; const githubCredentialsProvider: GithubCredentialsProvider = @@ -114,6 +116,7 @@ export const createBuiltinActions = ( integrations, reader, additionalTemplateFilters, + additionalTemplateGlobals, }), createPublishGerritAction({ integrations, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 1ead7ebcd6..d71997d496 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -26,6 +26,7 @@ import { isBinaryFile } from 'isbinaryfile'; import { TemplateFilter, SecureTemplater, + TemplateGlobal, } from '../../../../lib/templating/SecureTemplater'; /** @@ -39,8 +40,14 @@ export function createFetchTemplateAction(options: { reader: UrlReader; integrations: ScmIntegrations; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }) { - const { reader, integrations, additionalTemplateFilters } = options; + const { + reader, + integrations, + additionalTemplateFilters, + additionalTemplateGlobals, + } = options; return createTemplateAction<{ url: string; @@ -218,6 +225,7 @@ export function createFetchTemplateAction(options: { const renderTemplate = await SecureTemplater.loadRenderer({ cookiecutterCompat: ctx.input.cookiecutterCompat, additionalTemplateFilters, + additionalTemplateGlobals, }); for (const location of allEntriesInTemplate) { diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index f2c3c13509..17f9092bff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -25,7 +25,7 @@ import { SerializedFile, serializeDirectoryContents, } from '../../lib/files'; -import { TemplateFilter } from '../../lib/templating'; +import { TemplateFilter, TemplateGlobal } from '../../lib/templating'; import { createTemplateAction, TemplateActionRegistry } from '../actions'; import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner'; import { TaskSecrets } from '../tasks/types'; @@ -52,6 +52,7 @@ export type TemplateTesterCreateOptions = { actionRegistry: TemplateActionRegistry; workingDirectory: string; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; /** diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 009ea1357d..298ea16484 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -31,6 +31,7 @@ import { TemplateFilter, SecureTemplater, SecureTemplateRenderer, + TemplateGlobal, } from '../../lib/templating/SecureTemplater'; import { TaskSpec, @@ -45,6 +46,7 @@ type NunjucksWorkflowRunnerOptions = { integrations: ScmIntegrations; logger: winston.Logger; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; type TemplateContext = { @@ -197,6 +199,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { return parseRepoUrl(url, integrations); }, additionalTemplateFilters: this.options.additionalTemplateFilters, + additionalTemplateGlobals: this.options.additionalTemplateGlobals, }); try { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 9defd71c49..52ecd1a4dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -20,7 +20,10 @@ import { Logger } from 'winston'; import { TemplateActionRegistry } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; import { assertError } from '@backstage/errors'; -import { TemplateFilter } from '../../lib/templating/SecureTemplater'; +import { + TemplateFilter, + TemplateGlobal, +} from '../../lib/templating/SecureTemplater'; /** * TaskWorkerOptions @@ -46,6 +49,7 @@ export type CreateWorkerOptions = { workingDirectory: string; logger: Logger; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; }; /** @@ -64,6 +68,7 @@ export class TaskWorker { integrations, workingDirectory, additionalTemplateFilters, + additionalTemplateGlobals, } = options; const workflowRunner = new NunjucksWorkflowRunner({ @@ -72,6 +77,7 @@ export class TaskWorker { logger, workingDirectory, additionalTemplateFilters, + additionalTemplateGlobals, }); return new TaskWorker({ diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2e4549c8f1..eff7e95c50 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -36,7 +36,7 @@ import Router from 'express-promise-router'; import { validate } from 'jsonschema'; import { Logger } from 'winston'; import { z } from 'zod'; -import { TemplateFilter } from '../lib'; +import { TemplateFilter, TemplateGlobal } from '../lib'; import { createBuiltinActions, DatabaseTaskStore, @@ -70,6 +70,7 @@ export interface RouterOptions { taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; identity?: IdentityApi; } @@ -161,6 +162,7 @@ export async function createRouter( taskWorkers, scheduler, additionalTemplateFilters, + additionalTemplateGlobals, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -208,6 +210,7 @@ export async function createRouter( logger, workingDirectory, additionalTemplateFilters, + additionalTemplateGlobals, }); workers.push(worker); } @@ -220,6 +223,7 @@ export async function createRouter( reader, config, additionalTemplateFilters, + additionalTemplateGlobals, }); actionsToRegister.forEach(action => actionRegistry.register(action)); @@ -231,6 +235,7 @@ export async function createRouter( logger, workingDirectory, additionalTemplateFilters, + additionalTemplateGlobals, }); router