Merge pull request #6016 from angeliski/rails-generate

feat: added rails templater to scaffolder-backend
This commit is contained in:
Ben Lambert
2021-07-07 15:36:43 +02:00
committed by GitHub
40 changed files with 1510 additions and 53 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -18,7 +18,7 @@ export interface RouterOptions {
config: Config;
disableConfigInjection?: boolean;
// (undocumented)
logger: Logger;
logger: Logger_2;
staticFallbackHandler?: express.Handler;
}
+3 -3
View File
@@ -9,7 +9,7 @@ import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { JSONWebKey } from 'jose';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Profile } from 'passport';
@@ -23,7 +23,7 @@ export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
logger: Logger_2;
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
@@ -206,7 +206,7 @@ export interface RouterOptions {
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
logger: Logger_2;
// (undocumented)
providerFactories?: ProviderFactories;
}
@@ -11,7 +11,7 @@ import { Config } from '@backstage/config';
import { GroupEntity } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
import { LocationSpec } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { SearchEntry } from 'ldapjs';
import { SearchOptions } from 'ldapjs';
import { UserEntity } from '@backstage/catalog-model';
@@ -38,7 +38,7 @@ export const LDAP_UUID_ANNOTATION = "backstage.io/ldap-uuid";
export class LdapClient {
constructor(client: Client);
// (undocumented)
static create(logger: Logger, target: string, bind?: BindConfig): Promise<LdapClient>;
static create(logger: Logger_2, target: string, bind?: BindConfig): Promise<LdapClient>;
getRootDSE(): Promise<SearchEntry | undefined>;
getVendor(): Promise<LdapVendor>;
search(dn: string, options: SearchOptions): Promise<SearchEntry[]>;
@@ -48,13 +48,13 @@ export class LdapClient {
export class LdapOrgReaderProcessor implements CatalogProcessor {
constructor(options: {
providers: LdapProviderConfig[];
logger: Logger;
logger: Logger_2;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
});
// (undocumented)
static fromConfig(config: Config, options: {
logger: Logger;
logger: Logger_2;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
}): LdapOrgReaderProcessor;
@@ -80,7 +80,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[];
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
logger: Logger;
logger: Logger_2;
}): Promise<{
users: UserEntity[];
groups: GroupEntity[];
@@ -9,7 +9,7 @@ import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { GroupEntity } from '@backstage/catalog-model';
import { LocationSpec } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import * as msal from '@azure/msal-node';
import { UserEntity } from '@backstage/catalog-model';
@@ -70,12 +70,12 @@ export class MicrosoftGraphClient {
export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor {
constructor(options: {
providers: MicrosoftGraphProviderConfig[];
logger: Logger;
logger: Logger_2;
groupTransformer?: GroupTransformer;
});
// (undocumented)
static fromConfig(config: Config, options: {
logger: Logger;
logger: Logger_2;
groupTransformer?: GroupTransformer;
}): MicrosoftGraphOrgReaderProcessor;
// (undocumented)
@@ -107,7 +107,7 @@ export function readMicrosoftGraphOrg(client: MicrosoftGraphClient, tenantId: st
userFilter?: string;
groupFilter?: string;
groupTransformer?: GroupTransformer;
logger: Logger;
logger: Logger_2;
}): Promise<{
users: UserEntity[];
groups: GroupEntity[];
+2 -2
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import { GraphQLModule } from '@graphql-modules/core';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createModule(options: ModuleOptions): Promise<GraphQLModule>;
@@ -16,7 +16,7 @@ export interface ModuleOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
logger: Logger_2;
}
+2 -2
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
@@ -32,7 +32,7 @@ export interface RouterOptions {
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
logger: Logger_2;
// (undocumented)
urlReader: UrlReader;
}
+2 -2
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -16,7 +16,7 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
logger: Logger_2;
}
+1 -1
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
+3 -3
View File
@@ -9,7 +9,7 @@ import express from 'express';
import { FetchResponse } from '@backstage/plugin-kubernetes-common';
import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export interface ClusterDetails {
@@ -68,7 +68,7 @@ export interface KubernetesServiceLocator {
}
// @public (undocumented)
export const makeRouter: (logger: Logger, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router;
export const makeRouter: (logger: Logger_2, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router;
// @public (undocumented)
export interface ObjectFetchParams {
@@ -91,7 +91,7 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
logger: Logger_2;
}
// @public (undocumented)
+1 -1
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
// @public (undocumented)
+3 -3
View File
@@ -6,7 +6,7 @@
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -20,7 +20,7 @@ export function getRequestHeaders(token: string): {
// @public (undocumented)
export class RollbarApi {
constructor(accessToken: string, logger: Logger);
constructor(accessToken: string, logger: Logger_2);
// (undocumented)
getActivatedCounts(projectName: string, options?: {
environment: string;
@@ -49,7 +49,7 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
logger: Logger_2;
// (undocumented)
rollbarApi?: RollbarApi;
}
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,247 @@
# scaffolder-backend-module-rails
Welcome to the Rails Module for Scaffolder.
Here you can find all Rails related features to improve your scaffolder:
- Rails Action to use the `new` command
- More features are coming
## Getting started
You need to configure the action in your backend:
## From your Backstage root directory
```
cd packages/backend
yarn add @backstage/plugin-scaffolder-backend-module-rails
```
Configure the action (you can check
the [docs](https://backstage.io/docs/features/software-templates/writing-custom-actions#registering-custom-actions) to
see all options):
```typescript
const actions = [
createFetchRailsAction({
integrations,
reader,
containerRunner,
}),
];
return await createRouter({
containerRunner,
logger,
config,
database,
catalogClient,
reader,
actions,
});
```
After that you can use the action in your template:
```yaml
apiVersion: backstage.io/v1beta2
kind: Template
metadata:
name: rails-demo
title: Rails template
description: scaffolder Rails app
spec:
owner: backstage/techdocs-core
type: service
parameters:
- title: Fill in some steps
required:
- name
- owner
properties:
name:
title: Name
type: string
description: Unique name of the component
ui:autofocus: true
ui:options:
rows: 5
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
system:
title: System
type: string
description: System of the component
ui:field: EntityPicker
ui:options:
allowedKinds:
- System
defaultKind: System
- title: Choose a location
required:
- repoUrl
- dryRun
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
dryRun:
title: Only perform a dry run, don't publish anything
type: boolean
default: false
railsArguments:
title: arguments to run the rails new command
type: object
properties:
minimal:
title: minimal
description: Preconfigure a minimal rails app
type: boolean
skipBundle:
title: skipBundle
description: Don't run bundle install
type: boolean
skipWebpackInstall:
title: skipWebpackInstall
description: Don't run Webpack install
type: boolean
api:
title: api
description: Preconfigure smaller stack for API only apps
type: boolean
template:
title: template
description: Path to some application template (can be a filesystem path or URL)
type: string
default: './rails-template-file.rb'
webpacker:
title: webpacker
description:
'Preconfigure Webpack with a particular framework (options: react,
vue, angular, elm, stimulus)'
type: string
enum:
- react
- vue
- angular
- elm
- stimulus
database:
title: database
description: 'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)'
type: string
enum:
- mysql
- postgresql
- sqlite3
- oracle
- sqlserver
- jdbcmysql
- jdbcsqlite3
- jdbcpostgresql
- jdbc
railsVersion:
title: Rails version in Gemfile
description:
'Set up the application with Gemfile pointing to a specific version
(options: dev, edge, master)'
type: string
enum:
- dev
- edge
- master
steps:
- id: fetch-base
name: Fetch Base
action: fetch:rails
input:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
system: '{{ parameters.system }}'
railsArguments: '{{ json parameters.railsArguments }}'
- name: Write Catalog information
action: catalog:write
input:
component:
apiVersion: 'backstage.io/v1alpha1'
kind: Component
metadata:
name: '{{ parameters.name }}'
annotations:
github.com/project-slug: '{{ projectSlug parameters.repoUrl }}'
spec:
type: service
lifecycle: production
owner: '{{ parameters.owner }}'
- id: publish
if: '{{ not parameters.dryRun }}'
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
- id: register
if: '{{ not parameters.dryRun }}'
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
- name: Results
if: '{{ parameters.dryRun }}'
action: debug:log
input:
listWorkspace: true
output:
links:
- title: Repository
url: '{{ steps.publish.output.remoteUrl }}'
- title: Open in catalog
icon: 'catalog'
entityRef: '{{ steps.register.output.entityRef }}'
```
### What you need to run that action
The environment need to have a [rails](https://github.com/rails/rails#getting-started) installation, or you can build and provide a docker image in your template.
We have a [Dockerfile](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend-module-rails/Rails.dockerfile) that you can use to build your image.
If you choose to provide a docker image, you need to update your template with `imageName` parameter:
```yaml
steps:
- id: fetch-base
name: Fetch Base
action: fetch:rails
input:
url: ./template
imageName: repository/rails:tag
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
system: '{{ parameters.system }}'
railsArguments: '{{ json parameters.railsArguments }}'
```
@@ -0,0 +1,7 @@
FROM ruby:3.0
RUN apt-get update -qq && \
apt-get install -y nodejs postgresql-client git && \
rm -rf /var/lib/apt/lists/
RUN gem install rails
@@ -0,0 +1,22 @@
## API Report File for "@backstage/plugin-scaffolder-backend-module-rails"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ContainerRunner } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '@backstage/plugin-scaffolder-backend';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export function createFetchRailsAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
containerRunner: ContainerRunner;
}): TemplateAction<any>;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,44 @@
{
"name": "@backstage/plugin-scaffolder-backend-module-rails",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.8.3",
"@backstage/plugin-scaffolder-backend": "^0.12.2",
"@backstage/config": "^0.1.5",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.6",
"command-exists": "^1.2.9",
"fs-extra": "^9.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.1",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/command-exists": "^1.2.0",
"@types/fs-extra": "^9.0.1",
"@types/mock-fs": "^4.13.0",
"jest-when": "^3.1.0",
"mock-fs": "^4.13.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './rails';
@@ -0,0 +1,151 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
const mockRailsTemplater = { run: jest.fn() };
jest.mock('@backstage/plugin-scaffolder-backend', () => ({
...jest.requireActual('@backstage/plugin-scaffolder-backend'),
fetchContents: jest.fn(),
}));
jest.mock('./railsNewRunner', () => {
return {
RailsNewRunner: jest.fn().mockImplementation(() => {
return mockRailsTemplater;
}),
};
});
import {
ContainerRunner,
getVoidLogger,
UrlReader,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import mockFs from 'mock-fs';
import os from 'os';
import { resolve as resolvePath } from 'path';
import { PassThrough } from 'stream';
import { createFetchRailsAction } from './index';
import { fetchContents } from '@backstage/plugin-scaffolder-backend';
describe('fetch:rails', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
}),
);
const mockTmpDir = os.tmpdir();
const mockContext = {
input: {
url: 'https://rubyonrails.org/generator',
targetPath: 'something',
values: {
help: 'me',
},
},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
const mockReader: UrlReader = {
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
const containerRunner: ContainerRunner = {
runContainer: jest.fn(),
};
const action = createFetchRailsAction({
integrations,
reader: mockReader,
containerRunner,
});
beforeEach(() => {
mockFs({ [`${mockContext.workspacePath}/result`]: {} });
jest.restoreAllMocks();
});
afterEach(() => {
mockFs.restore();
});
it('should call fetchContents with the correct values', async () => {
await action.handler(mockContext);
expect(fetchContents).toHaveBeenCalledWith({
reader: mockReader,
integrations,
baseUrl: mockContext.baseUrl,
fetchUrl: mockContext.input.url,
outputPath: resolvePath(mockContext.workspacePath),
});
});
it('should execute the rails templater with the correct values', async () => {
await action.handler(mockContext);
expect(mockRailsTemplater.run).toHaveBeenCalledWith({
workspacePath: mockTmpDir,
logStream: mockContext.logStream,
values: mockContext.input.values,
});
});
it('should execute the rails templater with optional inputs if they are present and valid', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
imageName: 'foo/rails-custom-image',
},
});
expect(mockRailsTemplater.run).toHaveBeenCalledWith({
workspacePath: mockTmpDir,
logStream: mockContext.logStream,
values: {
...mockContext.input.values,
imageName: 'foo/rails-custom-image',
},
});
});
it('should throw if the target directory is outside of the workspace path', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
targetPath: '/foo',
},
}),
).rejects.toThrow(
/targetPath may not specify a path outside the working directory/,
);
});
});
@@ -0,0 +1,183 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ContainerRunner, UrlReader } from '@backstage/backend-common';
import { JsonObject } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import fs from 'fs-extra';
import {
createTemplateAction,
fetchContents,
} from '@backstage/plugin-scaffolder-backend';
import { resolve as resolvePath } from 'path';
import { RailsNewRunner } from './railsNewRunner';
export function createFetchRailsAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
containerRunner: ContainerRunner;
}) {
const { reader, integrations, containerRunner } = options;
return createTemplateAction<{
url: string;
targetPath?: string;
values: JsonObject;
imageName?: string;
}>({
id: 'fetch:rails',
description:
'Downloads a template from the given URL into the workspace, and runs a rails new generator on it.',
schema: {
input: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Fetch URL',
description:
'Relative path or absolute URL pointing to the directory tree to fetch',
type: 'string',
},
targetPath: {
title: 'Target Path',
description:
'Target path within the working directory to download the contents to.',
type: 'string',
},
values: {
title: 'Template Values',
description: 'Values to pass on to rails for templating',
type: 'object',
properties: {
railsArguments: {
title: 'Arguments to pass to new command',
description:
'You can provide some arguments to create a custom app',
type: 'object',
properties: {
minimal: {
title: 'minimal',
description: 'Preconfigure a minimal rails app',
type: 'boolean',
},
skipBundle: {
title: 'skipBundle',
description: "Don't run bundle install",
type: 'boolean',
},
skipWebpackInstall: {
title: 'skipWebpackInstall',
description: "Don't run Webpack install",
type: 'boolean',
},
api: {
title: 'api',
description: 'Preconfigure smaller stack for API only apps',
type: 'boolean',
},
template: {
title: 'template',
description:
'Path to some application template (can be a filesystem path or URL)',
type: 'string',
},
webpacker: {
title: 'webpacker',
description:
'Preconfigure Webpack with a particular framework (options: react, vue, angular, elm, stimulus)',
type: 'string',
enum: ['react', 'vue', 'angular', 'elm', 'stimulus'],
},
database: {
title: 'database',
description:
'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)',
type: 'string',
enum: [
'mysql',
'postgresql',
'sqlite3',
'oracle',
'sqlserver',
'jdbcmysql',
'jdbcsqlite3',
'jdbcpostgresql',
'jdbc',
],
},
railsVersion: {
title: 'Rails version in Gemfile',
description:
'Set up the application with Gemfile pointing to a specific version (options: fromImage, dev, edge, master)',
type: 'string',
enum: ['dev', 'edge', 'master', 'fromImage'],
},
},
},
},
},
imageName: {
title: 'Rails Docker image',
description:
'Specify a Docker image to run rails new. Used only when a local rails is not found.',
type: 'string',
},
},
},
},
async handler(ctx) {
ctx.logger.info('Fetching and then templating using rails');
const workDir = await ctx.createTemporaryDirectory();
const resultDir = resolvePath(workDir, 'result');
await fetchContents({
reader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.input.url,
outputPath: workDir,
});
const templateRunner = new RailsNewRunner({ containerRunner });
const values = {
...ctx.input.values,
imageName: ctx.input.imageName,
};
// Will execute the template in ./template and put the result in ./result
await templateRunner.run({
workspacePath: workDir,
logStream: ctx.logStream,
values,
});
// Finally move the template result into the task workspace
const targetPath = ctx.input.targetPath ?? './';
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
`Fetch action targetPath may not specify a path outside the working directory`,
);
}
await fs.copy(resultDir, outputPath);
},
});
}
@@ -0,0 +1,55 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { railsArgumentResolver } from './railsArgumentResolver';
import { sep as separatorPath } from 'path';
import os from 'os';
describe('railsArgumentResolver', () => {
describe('when provide the parameter', () => {
const root = os.platform() === 'win32' ? 'C:\\' : '/';
test.each([
[{}, []],
[{ minimal: true }, ['--minimal']],
[{ api: true }, ['--api']],
[{ skipBundle: true }, ['--skip-bundle']],
[{ skipWebpackInstall: true }, ['--skip-webpack-install']],
[{ webpacker: 'vue' }, ['--webpack', 'vue']],
[{ database: 'postgresql' }, ['--database', 'postgresql']],
[{ railsVersion: 'dev' }, ['--dev']],
[
{ template: `.${separatorPath}rails.rb` },
['--template', `${root}${separatorPath}rails.rb`],
],
])(
'should include the argument to execution %p -> %p',
(passedArguments: object, expected: Array<string>) => {
// that step is to ensure the validation between the TemplaterValues and the resolver
const values = {
owner: 'r',
storePath: '',
railsArguments: passedArguments,
};
const { railsArguments } = values;
const argumentsToRun = railsArgumentResolver(root, railsArguments);
expect(argumentsToRun).toEqual(expected);
},
);
});
});
@@ -0,0 +1,112 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { sep as separatorPath } from 'path';
enum Webpacker {
react = 'react',
vue = 'vue',
angular = 'angular',
elm = 'elm',
stimulus = 'stimulus',
}
enum Database {
mysql = 'mysql',
postgresql = 'postgresql',
sqlite3 = 'sqlite3',
oracle = 'oracle',
sqlserver = 'sqlserver',
jdbcmysql = 'jdbcmysql',
jdbcsqlite3 = 'jdbcsqlite3',
jdbcpostgresql = 'jdbcpostgresql',
jdbc = 'jdbc',
}
enum RailsVersion {
dev = 'dev',
edge = 'edge',
master = 'master',
fromImage = 'fromImage',
}
export type RailsRunOptions = {
minimal?: boolean;
api?: boolean;
template?: string;
webpacker?: Webpacker;
database?: Database;
railsVersion?: RailsVersion;
skipBundle?: boolean;
skipWebpackInstall?: boolean;
};
export const railsArgumentResolver = (
projectRoot: string,
options: RailsRunOptions,
): string[] => {
const argumentsToRun: string[] = [];
if (options?.minimal) {
argumentsToRun.push('--minimal');
}
if (options?.api) {
argumentsToRun.push('--api');
}
if (options?.skipBundle) {
argumentsToRun.push('--skip-bundle');
}
if (options?.skipWebpackInstall) {
argumentsToRun.push('--skip-webpack-install');
}
if (
options?.webpacker &&
Object.values(Webpacker).includes(options?.webpacker as Webpacker)
) {
argumentsToRun.push('--webpack');
argumentsToRun.push(options.webpacker);
}
if (
options?.database &&
Object.values(Database).includes(options?.database as Database)
) {
argumentsToRun.push('--database');
argumentsToRun.push(options.database);
}
if (
options?.railsVersion !== RailsVersion.fromImage &&
Object.values(RailsVersion).includes(options?.railsVersion as RailsVersion)
) {
argumentsToRun.push(`--${options.railsVersion}`);
}
if (options?.template) {
argumentsToRun.push('--template');
argumentsToRun.push(
options.template.replace(
`.${separatorPath}`,
`${projectRoot}${separatorPath}`,
),
);
}
return argumentsToRun;
};
@@ -0,0 +1,263 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
const runCommand = jest.fn();
const commandExists = jest.fn();
jest.mock('@backstage/plugin-scaffolder-backend', () => ({ runCommand }));
jest.mock('command-exists', () => commandExists);
jest.mock('fs-extra');
import { ContainerRunner } from '@backstage/backend-common';
import fs from 'fs-extra';
import path from 'path';
import { PassThrough } from 'stream';
import { RailsNewRunner } from './railsNewRunner';
describe('Rails Templater', () => {
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('when running on docker', () => {
it('should run the correct bindings for the volumes', async () => {
const logStream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
jest
.spyOn(fs, 'realpath')
.mockImplementation(x => Promise.resolve(x.toString()));
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith({
imageName: 'foo/rails-custom-image',
command: 'rails',
args: ['new', '/output/rails-project'],
envVars: { HOME: '/tmp' },
mountDirs: {
['tempdir']: '/input',
[path.join('tempdir', 'intermediate')]: '/output',
},
workingDir: '/input',
logStream: logStream,
});
});
it('should use the provided imageName', async () => {
const logStream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith(
expect.objectContaining({
imageName: 'foo/rails-custom-image',
}),
);
});
it('should pass through the streamer to the run docker helper', async () => {
const stream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream: stream,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith({
imageName: 'foo/rails-custom-image',
command: 'rails',
args: ['new', '/output/rails-project'],
envVars: { HOME: '/tmp' },
mountDirs: {
['tempdir']: '/input',
[path.join('tempdir', 'intermediate')]: '/output',
},
workingDir: '/input',
logStream: stream,
});
});
it('update the template path to correct location', async () => {
const logStream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
railsArguments: { template: './something.rb' },
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
jest
.spyOn(fs, 'realpath')
.mockImplementation(x => Promise.resolve(x.toString()));
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream,
});
expect(containerRunner.runContainer).toHaveBeenCalledWith({
imageName: 'foo/rails-custom-image',
command: 'rails',
args: [
'new',
'/output/rails-project',
'--template',
'/input/something.rb',
],
envVars: { HOME: '/tmp' },
mountDirs: {
['tempdir']: '/input',
[path.join('tempdir', 'intermediate')]: '/output',
},
workingDir: '/input',
logStream: logStream,
});
});
});
describe('when rails is available', () => {
it('use the binary', async () => {
const stream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
commandExists.mockImplementationOnce(() => () => true);
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream: stream,
});
expect(runCommand).toHaveBeenCalledWith({
command: 'rails',
args: expect.arrayContaining([
'new',
path.join('tempdir', 'intermediate', 'rails-project'),
]),
logStream: stream,
});
});
it('update the template path to correct location', async () => {
const stream = new PassThrough();
const values = {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
railsArguments: { template: './something.rb' },
imageName: 'foo/rails-custom-image',
};
jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any);
commandExists.mockImplementationOnce(() => () => true);
const templater = new RailsNewRunner({ containerRunner });
await templater.run({
workspacePath: 'tempdir',
values,
logStream: stream,
});
expect(runCommand).toHaveBeenCalledWith({
command: 'rails',
args: expect.arrayContaining([
'new',
path.join('tempdir', 'intermediate', 'rails-project'),
'--template',
path.join('tempdir', './something.rb'),
]),
logStream: stream,
});
});
});
describe('when nothing was generated', () => {
it('throws an error', async () => {
const stream = new PassThrough();
jest
.spyOn(fs, 'readdir')
.mockImplementationOnce(() => Promise.resolve([]));
const templater = new RailsNewRunner({ containerRunner });
await expect(
templater.run({
workspacePath: 'tempdir',
values: {
owner: 'angeliski',
storePath: 'https://github.com/angeliski/rails-project',
name: 'rails-project',
imageName: 'foo/rails-custom-image',
},
logStream: stream,
}),
).rejects.toThrow(/No data generated by rails/);
});
});
});
@@ -0,0 +1,104 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ContainerRunner } from '@backstage/backend-common';
import fs from 'fs-extra';
import path from 'path';
import { runCommand } from '@backstage/plugin-scaffolder-backend';
import commandExists from 'command-exists';
import {
railsArgumentResolver,
RailsRunOptions,
} from './railsArgumentResolver';
import { JsonObject } from '@backstage/config';
import { Writable } from 'stream';
export class RailsNewRunner {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
public async run({
workspacePath,
values,
logStream,
}: {
workspacePath: string;
values: JsonObject;
logStream: Writable;
}): Promise<void> {
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
const { name, imageName, railsArguments } = values;
// Directories to bind on container
const mountDirs = {
[workspacePath]: '/input',
[intermediateDir]: '/output',
};
const baseCommand = 'rails';
const baseArguments = ['new'];
const commandExistsToRun = await commandExists(baseCommand);
if (commandExistsToRun) {
const arrayExtraArguments = railsArgumentResolver(
workspacePath,
railsArguments as RailsRunOptions,
);
await runCommand({
command: baseCommand,
args: [
...baseArguments,
`${intermediateDir}/${name}`,
...arrayExtraArguments,
],
logStream,
});
} else {
const arrayExtraArguments = railsArgumentResolver(
'/input',
railsArguments as RailsRunOptions,
);
await this.containerRunner.runContainer({
imageName: imageName as string,
command: baseCommand,
args: [...baseArguments, `/output/${name}`, ...arrayExtraArguments],
mountDirs,
workingDir: '/input',
// Set the home directory inside the container as something that applications can
// write to, otherwise they will just fail trying to write to /
envVars: { HOME: '/tmp' },
logStream,
});
}
// if command was successful, intermediateDir should contain
// exactly one directory.
const [generated] = await fs.readdir(intermediateDir);
if (generated === undefined) {
throw new Error(`No data generated by ${baseCommand}`);
}
await fs.move(path.join(intermediateDir, generated), resultDir);
}
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './fetch';
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './actions';
+15 -3
View File
@@ -11,7 +11,7 @@ import { createPullRequest } from 'octokit-plugin-create-pull-request';
import express from 'express';
import { JsonObject } from '@backstage/config';
import { JsonValue } from '@backstage/config';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { Schema } from 'jsonschema';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -23,7 +23,7 @@ import { Writable } from 'stream';
// @public (undocumented)
export type ActionContext<Input extends InputBase> = {
baseUrl?: string;
logger: Logger;
logger: Logger_2;
logStream: Writable;
token?: string | undefined;
workspacePath: string;
@@ -118,6 +118,15 @@ export const createTemplateAction: <Input extends Partial<{
[name: string]: JsonValue| Partial<JsonObject> | undefined;
}>>(templateAction: TemplateAction<Input>) => TemplateAction<any>;
// @public (undocumented)
export function fetchContents({ reader, integrations, baseUrl, fetchUrl, outputPath, }: {
reader: UrlReader;
integrations: ScmIntegrations;
baseUrl?: string;
fetchUrl?: JsonValue;
outputPath: string;
}): Promise<void>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
@@ -131,13 +140,16 @@ export interface RouterOptions {
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
logger: Logger;
logger: Logger_2;
// (undocumented)
reader: UrlReader;
// (undocumented)
taskWorkers?: number;
}
// @public (undocumented)
export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise<void>;
// @public (undocumented)
export type TemplateAction<Input extends InputBase> = {
id: string;
@@ -0,0 +1,174 @@
apiVersion: backstage.io/v1beta2
kind: Template
metadata:
name: rails-demo
title: Rails template
description: scaffolder Rails app
spec:
owner: backstage/techdocs-core
type: service
parameters:
- title: Fill in some steps
required:
- name
- owner
properties:
name:
title: Name
type: string
description: Unique name of the component
ui:autofocus: true
ui:options:
rows: 5
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
system:
title: System
type: string
description: System of the component
ui:field: EntityPicker
ui:options:
allowedKinds:
- System
defaultKind: System
- title: Choose a location
required:
- repoUrl
- dryRun
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
dryRun:
title: Only perform a dry run, don't publish anything
type: boolean
default: false
railsArguments:
title: arguments to run the rails new command
type: object
properties:
minimal:
title: minimal
description: Preconfigure a minimal rails app
type: boolean
skipBundle:
title: skipBundle
description: Don't run bundle install
type: boolean
skipWebpackInstall:
title: skipWebpackInstall
description: Don't run Webpack install
type: boolean
api:
title: api
description: Preconfigure smaller stack for API only apps
type: boolean
template:
title: template
description: Path to some application template (can be a filesystem path or URL)
type: string
default: './rails-template-file.rb'
webpacker:
title: webpacker
description: 'Preconfigure Webpack with a particular framework (options: react,
vue, angular, elm, stimulus)'
type: string
enum:
- react
- vue
- angular
- elm
- stimulus
database:
title: database
description: 'Preconfigure for selected database (options: mysql/postgresql/sqlite3/oracle/sqlserver/jdbcmysql/jdbcsqlite3/jdbcpostgresql/jdbc)'
type: string
enum:
- mysql
- postgresql
- sqlite3
- oracle
- sqlserver
- jdbcmysql
- jdbcsqlite3
- jdbcpostgresql
- jdbc
railsVersion:
title: Rails version in Gemfile
description: 'Set up the application with Gemfile pointing to a specific version
(options: dev, edge, master)'
type: string
enum:
- dev
- edge
- master
steps:
- id: fetch-base
name: Fetch Base
action: fetch:rails
input:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
system: '{{ parameters.system }}'
railsArguments: '{{ json parameters.railsArguments }}'
- name: Write Catalog information
action: catalog:write
input:
component:
apiVersion: 'backstage.io/v1alpha1'
kind: Component
metadata:
name: '{{ parameters.name }}'
annotations:
github.com/project-slug: '{{ projectSlug parameters.repoUrl }}'
spec:
type: service
lifecycle: production
owner: '{{ parameters.owner }}'
- id: publish
if: '{{ not parameters.dryRun }}'
name: Publish
action: publish:github
input:
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
- id: register
if: '{{ not parameters.dryRun }}'
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
- name: Results
if: '{{ parameters.dryRun }}'
action: debug:log
input:
listWorkspace: true
output:
links:
- title: Repository
url: '{{ steps.publish.output.remoteUrl }}'
- title: Open in catalog
icon: 'catalog'
entityRef: '{{ steps.register.output.entityRef }}'
@@ -0,0 +1,14 @@
gem_group :development, :test do
gem "rspec"
gem "rspec-rails"
end
rakefile("example.rake") do
<<-TASK
namespace :example do
task :backstage do
puts "i like backstage!"
end
end
TASK
end
@@ -16,3 +16,4 @@
export { createFetchPlainAction } from './plain';
export { createFetchCookiecutterAction } from './cookiecutter';
export { fetchContents } from './helpers';
@@ -20,3 +20,4 @@ export * from './debug';
export * from './fetch';
export * from './filesystem';
export * from './publish';
export { runCommand } from './helpers';
+4 -4
View File
@@ -7,7 +7,7 @@
import { DocumentCollator } from '@backstage/search-common';
import { DocumentDecorator } from '@backstage/search-common';
import { IndexableDocument } from '@backstage/search-common';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { default as lunr_2 } from 'lunr';
import { SearchQuery } from '@backstage/search-common';
import { SearchResultSet } from '@backstage/search-common';
@@ -27,14 +27,14 @@ export class IndexBuilder {
// @public (undocumented)
export class LunrSearchEngine implements SearchEngine {
constructor({ logger }: {
logger: Logger;
logger: Logger_2;
});
// (undocumented)
protected docStore: Record<string, IndexableDocument>;
// (undocumented)
index(type: string, documents: IndexableDocument[]): void;
// (undocumented)
protected logger: Logger;
protected logger: Logger_2;
// (undocumented)
protected lunrIndices: Record<string, lunr_2.Index>;
// (undocumented)
@@ -48,7 +48,7 @@ export class LunrSearchEngine implements SearchEngine {
// @public
export class Scheduler {
constructor({ logger }: {
logger: Logger;
logger: Logger_2;
});
addToSchedule(task: Function, interval: number): void;
start(): void;
+1 -1
View File
@@ -5,7 +5,7 @@
```ts
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
// @public (undocumented)
+1 -1
View File
@@ -8,7 +8,7 @@ import { Config } from '@backstage/config';
import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { Knex } from 'knex';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PreparerBuilder } from '@backstage/techdocs-common';
import { PublisherBase } from '@backstage/techdocs-common';
+1 -1
View File
@@ -8,7 +8,7 @@ import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
import express from 'express';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { ScmIntegrations } from '@backstage/integration';
import { UrlReader } from '@backstage/backend-common';