Skip to content

Commit

Permalink
Add to the module of Quarkus a template filter. #114 (#156)
Browse files Browse the repository at this point in the history
* Add to the module of Quarkus a template filter. #114

Signed-off-by: cmoulliard <cmoulliard@redhat.com>

* Move the utility functions to the util folder

Signed-off-by: cmoulliard <cmoulliard@redhat.com>

* Fix warning messages reported by yarn test

Signed-off-by: cmoulliard <cmoulliard@redhat.com>

* Remove import non needed

Signed-off-by: cmoulliard <cmoulliard@redhat.com>

---------

Signed-off-by: cmoulliard <cmoulliard@redhat.com>
  • Loading branch information
cmoulliard committed Jun 18, 2024
1 parent 229ad62 commit 725c168
Show file tree
Hide file tree
Showing 9 changed files with 324 additions and 13 deletions.
2 changes: 1 addition & 1 deletion plugins/quarkus-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test --passWithNoTests --coverage",
"test": "backstage-cli package test --passWithNoTests",
"tsc": "tsc"
},
"dependencies": {
Expand Down
5 changes: 4 additions & 1 deletion plugins/quarkus-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* A module for the scaffolder backend that lets you interact with code.quarkus.io to create application
* A module for the scaffolder backend
*
* It provides scaffolder actions like template filters
*
* @packageDocumentation
*/
export * from './scaffolder/actions/quarkus';
export * from './scaffolder/filters/version';
export { quarkusModule as default } from './module';


17 changes: 13 additions & 4 deletions plugins/quarkus-backend/src/module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import {
createBackendModule,
} from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import {
scaffolderActionsExtensionPoint,
scaffolderTemplatingExtensionPoint
} from '@backstage/plugin-scaffolder-node/alpha';
import { createQuarkusApp, cloneQuarkusQuickstart } from '@qshift/plugin-quarkus-backend';
import { extractVersionFromKey } from '@qshift/plugin-quarkus-backend';

/**
* @public
Expand All @@ -14,13 +18,18 @@ export const quarkusModule = createBackendModule({
register(env) {
env.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
scaffolderAction: scaffolderActionsExtensionPoint,
scaffolderFilter: scaffolderTemplatingExtensionPoint,
},
async init({ scaffolder}) {
scaffolder.addActions(
async init({ scaffolderAction, scaffolderFilter}) {
scaffolderAction.addActions(
createQuarkusApp(),
cloneQuarkusQuickstart(),
);
scaffolderFilter.addTemplateFilters({
extractVersionFromKey: (streamKey) => extractVersionFromKey(streamKey as string),
},
);
},
});
},
Expand Down
212 changes: 212 additions & 0 deletions plugins/quarkus-backend/src/scaffolder/filters/util/SecureTemplater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// TODO: Investigate if we could import ir from the plugin-scaffolder-backend package vs copy/paste the code here !

import { Isolate } from 'isolated-vm';
import { resolvePackagePath } from '@backstage/backend-plugin-api';
import {
TemplateFilter as _TemplateFilter,
TemplateGlobal as _TemplateGlobal,
} from '@backstage/plugin-scaffolder-node';
import fs from 'fs-extra';
import { JsonValue } from '@backstage/types';
import { getMajorNodeVersion, isNoNodeSnapshotOptionProvided } from './helpers';

// language=JavaScript
const mkScript = (nunjucksSource: string) => `
const { render, renderCompat } = (() => {
const module = {};
const process = { env: {} };
const require = (pkg) => { if (pkg === 'events') { return function (){}; }};
${nunjucksSource}
const env = module.exports.configure({
autoescape: false,
...JSON.parse(nunjucksConfigs),
tags: {
variableStart: '\${{',
variableEnd: '}}',
},
});
const compatEnv = module.exports.configure({
autoescape: false,
...JSON.parse(nunjucksConfigs),
tags: {
variableStart: '{{',
variableEnd: '}}',
},
});
compatEnv.addFilter('jsonify', compatEnv.getFilter('dump'));
for (const name of JSON.parse(availableTemplateFilters)) {
env.addFilter(name, (...args) => JSON.parse(callFilter(name, args)));
}
for (const [name, value] of Object.entries(JSON.parse(availableTemplateGlobals))) {
env.addGlobal(name, value);
}
for (const name of JSON.parse(availableTemplateCallbacks)) {
env.addGlobal(name, (...args) => JSON.parse(callGlobal(name, args)));
}
let uninstallCompat = undefined;
function render(str, values) {
try {
if (uninstallCompat) {
uninstallCompat();
uninstallCompat = undefined;
}
return env.renderString(str, JSON.parse(values));
} catch (error) {
// Make sure errors don't leak anything
throw new Error(String(error.message));
}
}
function renderCompat(str, values) {
try {
if (!uninstallCompat) {
uninstallCompat = module.exports.installJinjaCompat();
}
return compatEnv.renderString(str, JSON.parse(values));
} catch (error) {
// Make sure errors don't leak anything
throw new Error(String(error.message));
}
}
return { render, renderCompat };
})();
`;

/**
* @public
* @deprecated Import from `@backstage/plugin-scaffolder-node` instead.
*/
export type TemplateFilter = _TemplateFilter;

/**
* @public
* @deprecated Import from `@backstage/plugin-scaffolder-node` instead.
*/
export type TemplateGlobal = _TemplateGlobal;

interface SecureTemplaterOptions {
/* Enables jinja compatibility and the "jsonify" filter */
cookiecutterCompat?: boolean;
/* Extra user-provided nunjucks filters */
templateFilters?: Record<string, TemplateFilter>;
/* Extra user-provided nunjucks globals */
templateGlobals?: Record<string, TemplateGlobal>;
nunjucksConfigs?: { trimBlocks?: boolean; lstripBlocks?: boolean };
}

export type SecureTemplateRenderer = (
template: string,
values: unknown,
) => string;

export class SecureTemplater {
static async loadRenderer(options: SecureTemplaterOptions = {}) {
const {
cookiecutterCompat,
templateFilters = {},
templateGlobals = {},
nunjucksConfigs = {},
} = options;

const nodeVersion = getMajorNodeVersion();
if (nodeVersion >= 20 && !isNoNodeSnapshotOptionProvided()) {
throw new Error(
`When using Node.js version 20 or newer, the scaffolder backend plugin requires that it be started with the --no-node-snapshot option.
Please make sure that you have NODE_OPTIONS=--no-node-snapshot in your environment.`,
);
}

const isolate = new Isolate({ memoryLimit: 128 });
const context = await isolate.createContext();
const contextGlobal = context.global;

const nunjucksSource = await fs.readFile(
resolvePackagePath(
'@backstage/plugin-scaffolder-backend',
'assets/nunjucks.js.txt',
),
'utf-8',
);

const nunjucksScript = await isolate.compileScript(
mkScript(nunjucksSource),
);

await contextGlobal.set('nunjucksConfigs', JSON.stringify(nunjucksConfigs));

const availableFilters = Object.keys(templateFilters);

await contextGlobal.set(
'availableTemplateFilters',
JSON.stringify(availableFilters),
);

const globalCallbacks = [];
const globalValues: Record<string, unknown> = {};
for (const [name, value] of Object.entries(templateGlobals)) {
if (typeof value === 'function') {
globalCallbacks.push(name);
} else {
globalValues[name] = value;
}
}

await contextGlobal.set(
'availableTemplateGlobals',
JSON.stringify(globalValues),
);
await contextGlobal.set(
'availableTemplateCallbacks',
JSON.stringify(globalCallbacks),
);

await contextGlobal.set(
'callFilter',
(filterName: string, args: JsonValue[]) => {
if (!Object.hasOwn(templateFilters, filterName)) {
return '';
}
return JSON.stringify(templateFilters[filterName](...args));
},
);

await contextGlobal.set(
'callGlobal',
(globalName: string, args: JsonValue[]) => {
if (!Object.hasOwn(templateGlobals, globalName)) {
return '';
}
const global = templateGlobals[globalName];
if (typeof global !== 'function') {
return '';
}
return JSON.stringify(global(...args));
},
);

await nunjucksScript.run(context);

const render: SecureTemplateRenderer = (template, values) => {
if (!context) {
throw new Error('SecureTemplater has not been initialized');
}

contextGlobal.setSync('templateStr', String(template));
contextGlobal.setSync('templateValues', JSON.stringify(values));

if (cookiecutterCompat) {
return context.evalSync(`renderCompat(templateStr, templateValues)`);
}

return context.evalSync(`render(templateStr, templateValues)`);
};
return render;
}
}
37 changes: 37 additions & 0 deletions plugins/quarkus-backend/src/scaffolder/filters/util/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2024 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.
*/

export function isNoNodeSnapshotOptionProvided(): boolean {
return (
process.env.NODE_OPTIONS?.includes('--no-node-snapshot') ||
process.argv.includes('--no-node-snapshot')
);
}

/**
* Gets the major version of the currently running Node.js process.
*
* @remarks
* This function extracts the major version from `process.versions.node` (a string representing the Node.js version),
* which includes the major, minor, and patch versions. It splits this string by the `.` character to get an array
* of these versions, and then parses the first element of this array (the major version) to a number.
*
* @returns {number} The major version of the currently running Node.js process.
*/
export function getMajorNodeVersion(): number {
const version = process.versions.node;
return parseInt(version.split('.')[0], 10);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { extractVersionFromKey } from './version';
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SecureTemplater } from '../util/SecureTemplater';
import {extractVersionFromKey} from "./version";

describe('QuarkusStreamKey', () => {
it('should get an error as streamKey is not well formatted => io.quarkus.platform_3.9 ', async () => {
const renderWith = await SecureTemplater.loadRenderer({
templateFilters: {
extractVersionFromKey: (streamKey) => extractVersionFromKey(streamKey as string),
},
});

let ctx = {inputValue: 'io.quarkus.platform_3.9'};
expect(() => renderWith('${{ inputValue | extractVersionFromKey }}', ctx),).toThrow(
/Error: The streamKey is not formatted as: io.quarkus.platform:<version>/,
);
});

it('should not get an error as streamKey is well formatted => io.quarkus.platform:3.9 ', async () => {
const renderWith = await SecureTemplater.loadRenderer({
templateFilters: {
extractVersionFromKey: (streamKey) => extractVersionFromKey(streamKey as string),
},
});

let ctx = {inputValue: 'io.quarkus.platform:3.10'};
expect(renderWith('${{ inputValue | extractVersionFromKey }}', ctx)).toBe('3.10');
});
});
14 changes: 14 additions & 0 deletions plugins/quarkus-backend/src/scaffolder/filters/version/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function extractVersionFromKey(
streamKey: string,
): string {
if (!streamKey) {
throw new Error(`StreamKey to be processed cannot be empty`);
}

let streamKeyArr = streamKey.split(":")
if (streamKeyArr.length < 2) {
throw new Error(`The streamKey is not formatted as: io.quarkus.platform:\<version\>`);
} else {
return streamKeyArr[1]
}
}
21 changes: 14 additions & 7 deletions plugins/quarkus/src/scaffolder/QuarkusVersionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { renderInTestApp, TestApiProvider } from "@backstage/test-utils";
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { act } from "@testing-library/react";

describe('<QuarkusVersionList />', () => {
let entities: Entity[];
Expand Down Expand Up @@ -47,14 +48,20 @@ describe('<QuarkusVersionList />', () => {
});

it('should get the default value including (RECOMMENDED)', async () => {
const rendered = await renderInTestApp(
<Wrapper>
<QuarkusVersionList {...props}/>
</Wrapper>
);
// user.selectOptions()
const render = await renderInTestApp(
<Wrapper>
<QuarkusVersionList {...props}/>
</Wrapper>
);

// To fix error discussed here: https://stackoverflow.com/questions/71159702/jest-warning-you-called-actasync-without-await
// like this one: Warning: An update to ForwardRef(FormControl) inside a test was not wrapped in act(...).
act(() => {
// Unmount should be wrapped in an act, but don't use await
render.unmount();
});
await new Promise((r) => setTimeout(r, 2000));
expect(rendered.findByText(defaultVersionLabel));
expect(render.findByText(defaultVersionLabel));
});

});
Expand Down

0 comments on commit 725c168

Please sign in to comment.