Merge remote-tracking branch 'origin/master' into mob/nunjucks-renderer

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
Mike Lewis
2021-07-07 16:07:07 +01:00
150 changed files with 2868 additions and 6105 deletions
+68
View File
@@ -0,0 +1,68 @@
---
'@backstage/catalog-model': minor
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-backend': minor
'@backstage/create-app': patch
---
Support for `Template` kinds with version `backstage.io/v1alpha1` has now been removed. This means that the old method of running templates with `Preparers`, `Templaters` and `Publishers` has also been removed. If you had any logic in these abstractions, they should now be moved to `actions` instead, and you can find out more about those in the [documentation](https://backstage.io/docs/features/software-templates/writing-custom-actions)
If you need any help migrating existing templates, there's a [migration guide](https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2). Reach out to us on Discord in the #support channel if you're having problems.
The `scaffolder-backend` now no longer requires these `Preparers`, `Templaters`, and `Publishers` to be passed in, now all it needs is the `containerRunner`.
Please update your `packages/backend/src/plugins/scaffolder.ts` like the following
```diff
- import {
- DockerContainerRunner,
- SingleHostDiscovery,
- } from '@backstage/backend-common';
+ import { DockerContainerRunner } from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
- import {
- CookieCutter,
- CreateReactAppTemplater,
- createRouter,
- Preparers,
- Publishers,
- Templaters,
- } from '@backstage/plugin-scaffolder-backend';
+ import { createRouter } from '@backstage/plugin-scaffolder-backend';
import Docker from 'dockerode';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default async function createPlugin({
config,
database,
reader,
+ discovery,
}: PluginEnvironment): Promise<Router> {
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
- const cookiecutterTemplater = new CookieCutter({ containerRunner });
- const craTemplater = new CreateReactAppTemplater({ containerRunner });
- const templaters = new Templaters();
- templaters.register('cookiecutter', cookiecutterTemplater);
- templaters.register('cra', craTemplater);
-
- const preparers = await Preparers.fromConfig(config, { logger });
- const publishers = await Publishers.fromConfig(config, { logger });
- const discovery = SingleHostDiscovery.fromConfig(config);
const catalogClient = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
- preparers,
- templaters,
- publishers,
+ containerRunner,
logger,
config,
database,
```
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-scaffolder': patch
---
Added a `context` parameter to validator functions, letting them have access to
the API holder.
If you have implemented custom validators and use `createScaffolderFieldExtension`,
your `validation` function can now optionally accept a third parameter,
`context: { apiHolder: ApiHolder }`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Change catalog page layout to use Grid components to improve responsiveness
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-test-utils': patch
'@backstage/create-app': patch
'@backstage/plugin-catalog-backend': patch
---
bump sqlite3 to 5.0.1
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-backend-module-ldap': minor
---
Add extension points to the `LdapOrgReaderProcessor` to make it possible to do more advanced modifications
of the ingested users and groups.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Show scroll bar of the sidebar wrapper only on hover
+24
View File
@@ -0,0 +1,24 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Adding `config: Config` as a required argument to `createBuiltinActions` and downstream methods in order to support configuration of the default git author used for Scaffolder commits.
The affected methods are:
- `createBuiltinActions`
- `createPublishGithubAction`
- `createPublishGitlabAction`
- `createPublishBitbucketAction`
- `createPublishAzureAction`
Call sites to these methods will need to be migrated to include the new `config` argument. See `createRouter` in `plugins/scaffolder-backend/src/service/router.ts` for an example of adding this new argument.
To configure the default git author, use the `defaultAuthor` key under `scaffolder` in `app-config.yaml`:
```yaml
scaffolder:
defaultAuthor:
name: Example
email: example@example.com
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
add support for uiSchema on dependent form fields
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Handle empty code blocks in markdown files so they don't fail rendering
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
bump azure-devops-node to 10.2.2
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Pass through the `idToken` in `Authorization` Header for `listActions` request
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Export the `fetchContents` from scaffolder-backend
+4
View File
@@ -250,6 +250,10 @@ catalog:
target: ../catalog-model/examples/acme-corp.yaml
scaffolder:
# Use to customize default commit author info used when new components are created
# defaultAuthor:
# name: Scaffolder
# email: scaffolder@backstage.io
github:
token: ${GITHUB_TOKEN}
visibility: public # or 'internal' or 'private'
+1 -1
View File
@@ -23,7 +23,7 @@ describe('Catalog', () => {
cy.visit('/catalog');
cy.contains('Owned (8)').should('be.visible');
cy.contains('Owned (10)').should('be.visible');
});
});
});
+9 -3
View File
@@ -27,8 +27,10 @@ describe('Integrations', () => {
type: 'url',
});
cy.wait(5000);
cy.visit('/catalog');
cy.contains('All').click();
cy.get('[data-testid="user-picker-all"]').click();
cy.get('table').should('contain', 'github-repo');
cy.get('table').should('contain', 'github-repo-nested');
});
@@ -52,8 +54,10 @@ describe('Integrations', () => {
type: 'url',
});
cy.wait(5000);
cy.visit('/catalog');
cy.contains('All').click();
cy.get('[data-testid="user-picker-all"]').click();
cy.get('table').should('contain', 'gitlab-repo');
cy.get('table').should('contain', 'gitlab-repo-nested');
});
@@ -67,8 +71,10 @@ describe('Integrations', () => {
type: 'url',
});
cy.wait(5000);
cy.visit('/catalog');
cy.contains('All').click();
cy.get('[data-testid="user-picker-all"]').click();
cy.get('table').should('contain', 'bitbucket-repo');
cy.get('table').should('contain', 'bitbucket-repo-nested');
});
+38 -6
View File
@@ -122,8 +122,8 @@ The DN under which users are stored, e.g.
#### users.options
The search options to use when sending the query to the server, when reading all
users. All of the options are shown below, with their default values, but they
are all optional.
users. All the options are shown below, with their default values, but they are
all optional.
```yaml
options:
@@ -158,8 +158,8 @@ set:
Mappings from well known entity fields, to LDAP attribute names. This is where
you are able to define how to interpret the attributes of each LDAP result item,
and to move them into the corresponding entity fields. All of the options are
shown below, with their default values, but they are all optional.
and to move them into the corresponding entity fields. All the options are shown
below, with their default values, but they are all optional.
If you leave out an optional mapping, it will still be copied using that default
value. For example, even if you do not put in the field `displayName` in your
@@ -204,8 +204,8 @@ The DN under which groups are stored, e.g.
#### groups.options
The search options to use when sending the query to the server, when reading all
groups. All of the options are shown below, with their default values, but they
are all optional.
groups. All the options are shown below, with their default values, but they are
all optional.
```yaml
options:
@@ -282,3 +282,35 @@ map:
# the spec.children field of the entity.
members: member
```
## Customize the Processor
In case you want to customize the ingested entities, the
`LdapOrgReaderProcessor` allows to pass transformers for users and groups.
1. Create a transformer:
```ts
export async function myGroupTransformer(
vendor: LdapVendor,
config: GroupConfig,
group: SearchEntry,
): Promise<GroupEntity | undefined> {
// Transformations may change namespace, change entity naming pattern, fill
// profile with more or other details...
// Create the group entity on your own, or wrap the default transformer
return await defaultGroupTransformer(vendor, config, group);
}
```
2. Configure the processor with the transformer:
```ts
builder.addProcessor(
LdapOrgReaderProcessor.fromConfig(config, {
logger,
groupTransformer: myGroupTransformer,
}),
);
```
@@ -0,0 +1,9 @@
---
title: Scaffolder Backend Module Rails
author: Rogerio Angeliski
authorUrl: https://angeliski.com.br/
category: Scaffolder
description: Here you can find all Rails related features to improve your scaffolder.
documentation: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend-module-rails/README.md
iconUrl: img/rails-icon.png
npmPackageName: '@backstage/plugin-scaffolder-backend-module-rails'
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+5 -5
View File
@@ -19,7 +19,7 @@ import * as http from 'http';
import { isChildPath } from '@backstage/cli-common';
import { JsonValue } from '@backstage/config';
import { Knex } from 'knex';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { MergeResult } from 'isomorphic-git';
import { PushResult } from 'isomorphic-git';
import { Readable } from 'stream';
@@ -129,7 +129,7 @@ export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler
// @public (undocumented)
export type ErrorHandlerOptions = {
showStackTraces?: boolean;
logger?: Logger;
logger?: Logger_2;
logClientErrors?: boolean;
};
@@ -185,7 +185,7 @@ export class Git {
static fromAuth: ({ username, password, logger, }: {
username?: string | undefined;
password?: string | undefined;
logger?: Logger | undefined;
logger?: Logger_2 | undefined;
}) => Git;
// (undocumented)
init({ dir, defaultBranch, }: {
@@ -301,7 +301,7 @@ export type ReadTreeResponseFile = {
};
// @public
export function requestLoggingHandler(logger?: Logger): RequestHandler;
export function requestLoggingHandler(logger?: Logger_2): RequestHandler;
// @public
export function resolvePackagePath(name: string, ...paths: string[]): string;
@@ -337,7 +337,7 @@ export type ServiceBuilder = {
loadConfig(config: ConfigReader): ServiceBuilder;
setPort(port: number): ServiceBuilder;
setHost(host: string): ServiceBuilder;
setLogger(logger: Logger): ServiceBuilder;
setLogger(logger: Logger_2): ServiceBuilder;
enableCors(options: cors.CorsOptions): ServiceBuilder;
setHttpsSettings(settings: HttpsSettings): ServiceBuilder;
addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
+1 -1
View File
@@ -36,7 +36,7 @@
"knex": "^0.95.1",
"mysql2": "^2.2.5",
"pg": "^8.3.0",
"sqlite3": "^5.0.0",
"sqlite3": "^5.0.1",
"testcontainers": "^7.10.0",
"uuid": "^8.0.0"
},
+4 -2
View File
@@ -31,6 +31,7 @@
"@backstage/catalog-client": "^0.3.15",
"@backstage/catalog-model": "^0.8.2",
"@backstage/config": "^0.1.5",
"@backstage/integration": "^0.5.6",
"@backstage/plugin-app-backend": "^0.3.13",
"@backstage/plugin-auth-backend": "^0.3.15",
"@backstage/plugin-badges-backend": "^0.1.6",
@@ -42,13 +43,14 @@
"@backstage/plugin-proxy-backend": "^0.2.9",
"@backstage/plugin-rollbar-backend": "^0.1.11",
"@backstage/plugin-scaffolder-backend": "^0.12.4",
"@backstage/plugin-scaffolder-backend-module-rails": "^0.1.1",
"@backstage/plugin-search-backend": "^0.2.0",
"@backstage/plugin-search-backend-node": "^0.2.0",
"@backstage/plugin-techdocs-backend": "^0.8.5",
"@backstage/plugin-todo-backend": "^0.1.6",
"@gitbeaker/node": "^30.2.0",
"@octokit/rest": "^18.5.3",
"azure-devops-node-api": "^10.1.1",
"azure-devops-node-api": "^10.2.2",
"dockerode": "^3.2.1",
"example-app": "^0.2.32",
"express": "^4.17.1",
@@ -56,7 +58,7 @@
"knex": "^0.95.1",
"pg": "^8.3.0",
"pg-connection-string": "^2.3.0",
"sqlite3": "^5.0.0",
"sqlite3": "^5.0.1",
"winston": "^3.2.1"
},
"devDependencies": {
+4 -26
View File
@@ -14,19 +14,9 @@
* limitations under the License.
*/
import {
DockerContainerRunner,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { DockerContainerRunner } from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import {
CookieCutter,
CreateReactAppTemplater,
createRouter,
Preparers,
Publishers,
Templaters,
} from '@backstage/plugin-scaffolder-backend';
import { createRouter } from '@backstage/plugin-scaffolder-backend';
import Docker from 'dockerode';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
@@ -36,27 +26,15 @@ export default async function createPlugin({
config,
database,
reader,
discovery,
}: PluginEnvironment): Promise<Router> {
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
const cookiecutterTemplater = new CookieCutter({ containerRunner });
const craTemplater = new CreateReactAppTemplater({ containerRunner });
const templaters = new Templaters();
templaters.register('cookiecutter', cookiecutterTemplater);
templaters.register('cra', craTemplater);
const preparers = await Preparers.fromConfig(config, { logger });
const publishers = await Publishers.fromConfig(config, { logger });
const discovery = SingleHostDiscovery.fromConfig(config);
const catalogClient = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
preparers,
templaters,
publishers,
containerRunner,
logger,
config,
database,
-23
View File
@@ -492,29 +492,6 @@ export { SystemEntityV1alpha1 }
// @public (undocumented)
export const systemEntityV1alpha1Validator: KindValidator;
// @public (undocumented)
interface TemplateEntityV1alpha1 extends Entity {
// (undocumented)
apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
// (undocumented)
kind: 'Template';
// (undocumented)
spec: {
type: string;
templater: string;
path?: string;
schema: JSONSchema;
owner?: string;
};
}
export { TemplateEntityV1alpha1 as TemplateEntity }
export { TemplateEntityV1alpha1 }
// @public (undocumented)
export const templateEntityV1alpha1Validator: KindValidator;
// @public (undocumented)
export interface TemplateEntityV1beta2 extends Entity {
// (undocumented)
@@ -1,106 +0,0 @@
/*
* Copyright 2020 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 {
TemplateEntityV1alpha1,
templateEntityV1alpha1Validator as validator,
} from './TemplateEntityV1alpha1';
describe('templateEntityV1alpha1Validator', () => {
let entity: TemplateEntityV1alpha1;
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
name: 'test',
},
spec: {
type: 'website',
templater: 'cookiecutter',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-a@example.com',
},
};
});
it('happy path: accepts valid data', async () => {
await expect(validator.check(entity)).resolves.toBe(true);
});
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
await expect(validator.check(entity)).resolves.toBe(true);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
await expect(validator.check(entity)).resolves.toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
await expect(validator.check(entity)).resolves.toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('accepts any other type', async () => {
(entity as any).spec.type = 'hallo';
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
await expect(validator.check(entity)).rejects.toThrow(/type/);
});
it('rejects missing templater', async () => {
(entity as any).spec.templater = '';
await expect(validator.check(entity)).rejects.toThrow(/templater/);
});
it('accepts missing owner', async () => {
delete (entity as any).spec.owner;
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects wrong type owner', async () => {
(entity as any).spec.owner = 5;
await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
});
@@ -1,36 +0,0 @@
/*
* Copyright 2020 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 type { Entity } from '../entity/Entity';
import schema from '../schema/kinds/Template.v1alpha1.schema.json';
import type { JSONSchema } from '../types';
import { ajvCompiledJsonSchemaValidator } from './util';
export interface TemplateEntityV1alpha1 extends Entity {
apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
kind: 'Template';
spec: {
type: string;
templater: string;
path?: string;
schema: JSONSchema;
owner?: string;
};
}
export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
schema,
);
@@ -50,11 +50,6 @@ export type {
SystemEntityV1alpha1 as SystemEntity,
SystemEntityV1alpha1,
} from './SystemEntityV1alpha1';
export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1';
export type {
TemplateEntityV1alpha1 as TemplateEntity,
TemplateEntityV1alpha1,
} from './TemplateEntityV1alpha1';
export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2';
export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2';
export type { KindValidator } from './types';
@@ -1,99 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "TemplateV1alpha1",
"description": "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.",
"examples": [
{
"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": "artist-relations-team",
"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"
}
}
}
}
}
],
"allOf": [
{
"$ref": "Entity"
},
{
"type": "object",
"required": ["spec"],
"properties": {
"apiVersion": {
"enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
},
"kind": {
"enum": ["Template"]
},
"metadata": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.",
"examples": ["React SSR Template"],
"minLength": 1
}
}
},
"spec": {
"type": "object",
"required": ["type", "templater", "schema"],
"properties": {
"type": {
"type": "string",
"description": "The type of component created by the template. 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.",
"examples": ["service", "website", "library"],
"minLength": 1
},
"templater": {
"type": "string",
"description": "The templating library that is supported by the template skeleton.",
"examples": ["cookiecutter"],
"minLength": 1
},
"path": {
"type": "string",
"description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.",
"examples": ["./cookiecutter/skeleton"],
"minLength": 1
},
"schema": {
"type": "object",
"description": "The JSONSchema describing the inputs for the template."
},
"owner": {
"type": "string",
"description": "The user (or group) owner of the template",
"minLength": 1
}
}
}
}
}
]
}
@@ -17,6 +17,7 @@
import React from 'react';
import { Button, makeStyles, Typography } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import { Link } from '../Link';
import { EmptyState } from './EmptyState';
import { CodeSnippet } from '../CodeSnippet';
@@ -73,9 +74,9 @@ export const MissingAnnotationEmptyState = ({ annotation }: Props) => {
/>
</div>
<Button
variant="contained"
color="primary"
href="https://backstage.io/docs/features/software-catalog/well-known-annotations"
component={Link}
to="https://backstage.io/docs/features/software-catalog/well-known-annotations"
>
Read more
</Button>
@@ -65,8 +65,8 @@ type Props = {
};
const renderers = {
code: ({ language, value }: { language: string; value: string }) => {
return <CodeSnippet language={language} text={value} />;
code: ({ language, value }: { language: string; value?: string }) => {
return <CodeSnippet language={language} text={value ?? ''} />;
},
};
@@ -21,8 +21,10 @@ import {
makeStyles,
styled,
TextField,
Theme,
Typography,
} from '@material-ui/core';
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
import SearchIcon from '@material-ui/icons/Search';
import clsx from 'clsx';
import React, {
@@ -291,14 +293,8 @@ export const SidebarDivider = styled('hr')({
margin: '12px 0px',
});
export const SidebarScrollWrapper = styled('div')(({ theme }) => ({
flex: '0 1 auto',
overflowX: 'hidden',
// 5px space to the right of the scrollbar
width: 'calc(100% - 5px)',
// Display at least one item in the container
// Question: Can this be a config/theme variable - if so, which? :/
minHeight: '48px',
const styledScrollbar = (theme: Theme): CreateCSSProperties => ({
overflowY: 'auto',
'&::-webkit-scrollbar': {
backgroundColor: theme.palette.background.default,
width: '5px',
@@ -308,4 +304,20 @@ export const SidebarScrollWrapper = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.text.hint,
borderRadius: '5px',
},
}));
});
export const SidebarScrollWrapper = styled('div')(({ theme }) => {
const scrollbarStyles = styledScrollbar(theme);
return {
flex: '0 1 auto',
overflowX: 'hidden',
// 5px space to the right of the scrollbar
width: 'calc(100% - 5px)',
// Display at least one item in the container
// Question: Can this be a config/theme variable - if so, which? :/
minHeight: '48px',
overflowY: 'hidden',
'@media (hover: none)': scrollbarStyles,
'&:hover': scrollbarStyles,
};
});
@@ -40,7 +40,7 @@
"pg": "^8.3.0",
{{/if}}
{{#if dbTypeSqlite}}
"sqlite3": "^5.0.0",
"sqlite3": "^5.0.1",
{{/if}}
"winston": "^3.2.1"
},
@@ -4,12 +4,7 @@ import {
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import {
CookieCutter,
CreateReactAppTemplater,
createRouter,
Preparers,
Publishers,
Templaters,
} from '@backstage/plugin-scaffolder-backend';
import Docker from 'dockerode';
import { Router } from 'express';
@@ -24,23 +19,11 @@ export default async function createPlugin({
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
const cookiecutterTemplater = new CookieCutter({ containerRunner });
const craTemplater = new CreateReactAppTemplater({ containerRunner });
const templaters = new Templaters();
templaters.register('cookiecutter', cookiecutterTemplater);
templaters.register('cra', craTemplater);
const preparers = await Preparers.fromConfig(config, { logger });
const publishers = await Publishers.fromConfig(config, { logger });
const discovery = SingleHostDiscovery.fromConfig(config);
const catalogClient = new CatalogClient({ discoveryApi: discovery });
return await createRouter({
preparers,
templaters,
publishers,
containerRunner,
logger,
config,
database,
+10 -10
View File
@@ -12,17 +12,17 @@ import { EntityName } from '@backstage/catalog-model';
import express from 'express';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { GitLabIntegrationConfig } from '@backstage/integration';
import { Logger } from 'winston';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { Writable } from 'stream';
// @public (undocumented)
export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger) => Promise<string>;
export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger_2) => Promise<string>;
// @public (undocumented)
export class CommonGitPreparer implements PreparerBase {
constructor(config: Config, logger: Logger);
constructor(config: Config, logger: Logger_2);
// (undocumented)
prepare(entity: Entity, options?: {
etag?: string;
@@ -31,7 +31,7 @@ export class CommonGitPreparer implements PreparerBase {
// @public (undocumented)
export class DirectoryPreparer implements PreparerBase {
constructor(config: Config, logger: Logger, reader: UrlReader);
constructor(config: Config, logger: Logger_2, reader: UrlReader);
// (undocumented)
prepare(entity: Entity): Promise<PreparerResponse>;
}
@@ -51,7 +51,7 @@ export type GeneratorBuilder = {
export class Generators implements GeneratorBuilder {
// (undocumented)
static fromConfig(config: Config, { logger, containerRunner, }: {
logger: Logger;
logger: Logger_2;
containerRunner: ContainerRunner;
}): Promise<GeneratorBuilder>;
// (undocumented)
@@ -69,7 +69,7 @@ export const getDefaultBranch: (repositoryUrl: string, config: Config) => Promis
// @public (undocumented)
export const getDocFilesFromRepository: (reader: UrlReader, entity: Entity, opts?: {
etag?: string | undefined;
logger?: Logger | undefined;
logger?: Logger_2 | undefined;
} | undefined) => Promise<PreparerResponse>;
// @public (undocumented)
@@ -88,7 +88,7 @@ export const getGitRepositoryTempFolder: (repositoryUrl: string, config: Config)
export function getGitRepoType(url: string): string;
// @public (undocumented)
export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger) => Promise<number>;
export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger_2) => Promise<number>;
// @public (undocumented)
export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation;
@@ -108,7 +108,7 @@ export const parseReferenceAnnotation: (annotationName: string, entity: Entity)
// @public (undocumented)
export type PreparerBase = {
prepare(entity: Entity, options?: {
logger?: Logger;
logger?: Logger_2;
etag?: string;
}): Promise<PreparerResponse>;
};
@@ -153,7 +153,7 @@ export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azu
// @public (undocumented)
export class TechdocsGenerator implements GeneratorBase {
constructor({ logger, containerRunner, config, }: {
logger: Logger;
logger: Logger_2;
containerRunner: ContainerRunner;
config: Config;
});
@@ -170,7 +170,7 @@ export type TechDocsMetadata = {
// @public (undocumented)
export class UrlPreparer implements PreparerBase {
constructor(reader: UrlReader, logger: Logger);
constructor(reader: UrlReader, logger: Logger_2);
// (undocumented)
prepare(entity: Entity, options?: {
etag?: string;
+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,11 +11,20 @@ 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';
// @public (undocumented)
export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise<GroupEntity | undefined>;
// @public (undocumented)
export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise<UserEntity | undefined>;
// @public
export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise<GroupEntity | undefined>;
// @public
export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn";
@@ -29,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[]>;
@@ -39,15 +48,19 @@ 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;
// (undocumented)
readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise<boolean>;
}
}
// @public
export type LdapProviderConfig = {
@@ -57,15 +70,25 @@ export type LdapProviderConfig = {
groups: GroupConfig;
};
// @public
export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void;
// @public
export function readLdapConfig(config: Config): LdapProviderConfig[];
// @public
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
logger: Logger_2;
}): Promise<{
users: UserEntity[];
groups: GroupEntity[];
}>;
// @public
export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise<UserEntity | undefined>;
// (No @packageDocumentation comment for this package)
@@ -15,6 +15,7 @@
*/
export { LdapClient } from './client';
export { mapStringAttr } from './util';
export { readLdapConfig } from './config';
export type { LdapProviderConfig } from './config';
export {
@@ -22,4 +23,9 @@ export {
LDAP_RDN_ANNOTATION,
LDAP_UUID_ANNOTATION,
} from './constants';
export { readLdapOrg } from './read';
export {
defaultGroupTransformer,
defaultUserTransformer,
readLdapOrg,
} from './read';
export type { GroupTransformer, UserTransformer } from './types';
@@ -26,74 +26,97 @@ import {
LDAP_UUID_ANNOTATION,
} from './constants';
import { LdapVendor } from './vendors';
import { Logger } from 'winston';
import { GroupTransformer, UserTransformer } from './types';
import { mapStringAttr } from './util';
export async function defaultUserTransformer(
vendor: LdapVendor,
config: UserConfig,
entry: SearchEntry,
): Promise<UserEntity | undefined> {
const { set, map } = config;
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: '',
annotations: {},
},
spec: {
profile: {},
memberOf: [],
},
};
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
return entity;
}
/**
* Reads users out of an LDAP provider.
*
* @param client The LDAP client
* @param config The user data configuration
* @param opts
*/
export async function readLdapUsers(
client: LdapClient,
config: UserConfig,
opts?: { transformer?: UserTransformer },
): Promise<{
users: UserEntity[]; // With all relations empty
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
}> {
const { dn, options, set, map } = config;
const { dn, options, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
const entities: UserEntity[] = [];
const userMemberOf: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: '',
annotations: {},
},
spec: {
profile: {},
memberOf: [],
},
};
const transformer = opts?.transformer ?? defaultUserTransformer;
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
const entries = await client.search(dn, options);
for (const user of entries) {
const entity = await transformer(vendor, config, user);
if (!entity) {
continue;
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
ensureItems(userMemberOf, myDn, vs);
});
@@ -103,82 +126,103 @@ export async function readLdapUsers(
return { users: entities, userMemberOf };
}
export async function defaultGroupTransformer(
vendor: LdapVendor,
config: GroupConfig,
entry: SearchEntry,
): Promise<GroupEntity | undefined> {
const { set, map } = config;
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: '',
annotations: {},
},
spec: {
type: 'unknown',
profile: {},
children: [],
},
};
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.type, v => {
entity.spec.type = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
return entity;
}
/**
* Reads groups out of an LDAP provider.
*
* @param client The LDAP client
* @param config The group data configuration
* @param opts
*/
export async function readLdapGroups(
client: LdapClient,
config: GroupConfig,
opts?: {
transformer?: GroupTransformer;
},
): Promise<{
groups: GroupEntity[]; // With all relations empty
groupMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
}> {
const { dn, options, set, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
const groups: GroupEntity[] = [];
const groupMemberOf: Map<string, Set<string>> = new Map();
const groupMember: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: '',
annotations: {},
},
spec: {
type: 'unknown',
profile: {},
children: [],
},
};
const { dn, map, options } = config;
const vendor = await client.getVendor();
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
const transformer = opts?.transformer ?? defaultGroupTransformer;
const entries = await client.search(dn, options);
for (const group of entries) {
const entity = await transformer(vendor, config, group);
if (!entity) {
continue;
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.type, v => {
entity.spec.type = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => {
ensureItems(groupMemberOf, myDn, vs);
});
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
mapReferencesAttr(group, vendor, map.members, (myDn, vs) => {
ensureItems(groupMember, myDn, vs);
});
@@ -199,22 +243,30 @@ export async function readLdapGroups(
* with all relations etc filled in.
*
* @param client The LDAP client
* @param logger A logger instance
* @param userConfig The user data configuration
* @param groupConfig The group data configuration
* @param options
*/
export async function readLdapOrg(
client: LdapClient,
userConfig: UserConfig,
groupConfig: GroupConfig,
options: {
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
logger: Logger;
},
): Promise<{
users: UserEntity[];
groups: GroupEntity[];
}> {
const { users, userMemberOf } = await readLdapUsers(client, userConfig);
const { users, userMemberOf } = await readLdapUsers(client, userConfig, {
transformer: options?.userTransformer,
});
const { groups, groupMemberOf, groupMember } = await readLdapGroups(
client,
groupConfig,
{ transformer: options?.groupTransformer },
);
resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember);
@@ -228,21 +280,6 @@ export async function readLdapOrg(
// Helpers
//
// Maps a single-valued attribute to a consumer
function mapStringAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (value: string) => void,
) {
if (attributeName) {
const values = vendor.decodeStringAttribute(entry, attributeName);
if (values && values.length === 1) {
setter(values[0]);
}
}
}
// Maps a multi-valued attribute of references to other objects, to a consumer
function mapReferencesAttr(
entry: SearchEntry,
@@ -0,0 +1,47 @@
/*
* 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 { GroupEntity, UserEntity } from '@backstage/catalog-model';
import { SearchEntry } from 'ldapjs';
import { LdapVendor } from './vendors';
import { GroupConfig, UserConfig } from './config';
/**
* Customize the ingested User entity
*
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
* @param config The User specific config used by the default transformer.
* @param user The found LDAP entry in its source format. This is the entry that you want to transform
* @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog
*/
export type UserTransformer = (
vendor: LdapVendor,
config: UserConfig,
user: SearchEntry,
) => Promise<UserEntity | undefined>;
/**
* Customize the ingested Group entity
*
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
* @param config The Group specific config used by the default transformer.
* @param group The found LDAP entry in its source format. This is the entry that you want to transform
* @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog
*/
export type GroupTransformer = (
vendor: LdapVendor,
config: GroupConfig,
group: SearchEntry,
) => Promise<GroupEntity | undefined>;
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Error as LDAPError } from 'ldapjs';
import { Error as LDAPError, SearchEntry } from 'ldapjs';
import { LdapVendor } from './vendors';
/**
* Builds a string form of an LDAP Error structure.
@@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs';
export function errorString(error: LDAPError) {
return `${error.code} ${error.name}: ${error.message}`;
}
/**
* Maps a single-valued attribute to a consumer
*
* @param entry The LDAP source entry
* @param vendor The LDAP vendor
* @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored.
* @param setter The function to be called with the decoded attribute from the source entry
*/
export function mapStringAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (value: string) => void,
) {
if (attributeName) {
const values = vendor.decodeStringAttribute(entry, attributeName);
if (values && values.length === 1) {
setter(values[0]);
}
}
}
@@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import {
GroupTransformer,
LdapClient,
LdapProviderConfig,
readLdapConfig,
readLdapOrg,
UserTransformer,
} from '../ldap';
import {
CatalogProcessor,
@@ -35,8 +37,17 @@ import {
export class LdapOrgReaderProcessor implements CatalogProcessor {
private readonly providers: LdapProviderConfig[];
private readonly logger: Logger;
private readonly groupTransformer?: GroupTransformer;
private readonly userTransformer?: UserTransformer;
static fromConfig(config: Config, options: { logger: Logger }) {
static fromConfig(
config: Config,
options: {
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
},
) {
const c = config.getOptionalConfig('catalog.processors.ldapOrg');
return new LdapOrgReaderProcessor({
...options,
@@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
});
}
constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) {
constructor(options: {
providers: LdapProviderConfig[];
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
}) {
this.providers = options.providers;
this.logger = options.logger;
this.groupTransformer = options.groupTransformer;
this.userTransformer = options.userTransformer;
}
async readLocation(
@@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
client,
provider.users,
provider.groups,
{
groupTransformer: this.groupTransformer,
userTransformer: this.userTransformer,
logger: this.logger,
},
);
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
@@ -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[];
+1 -1
View File
@@ -71,7 +71,7 @@
"@types/uuid": "^8.0.0",
"@types/yup": "^0.29.8",
"msw": "^0.29.0",
"sqlite3": "^5.0.0",
"sqlite3": "^5.0.1",
"supertest": "^6.1.3",
"wait-for-expect": "^3.0.2"
},
@@ -21,7 +21,7 @@ import {
GroupEntity,
ResourceEntity,
SystemEntity,
TemplateEntity,
TemplateEntityV1beta2,
UserEntity,
} from '@backstage/catalog-model';
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
@@ -522,22 +522,13 @@ describe('BuiltinKindsEntityProcessor', () => {
});
});
it('generates relations for template entities', async () => {
const entity: TemplateEntity = {
apiVersion: 'backstage.io/v1alpha1',
const entity: TemplateEntityV1beta2 = {
apiVersion: 'backstage.io/v1beta2',
kind: 'Template',
metadata: { name: 'n' },
spec: {
schema: {
properties: {
description: {
title: 'd',
type: 'string',
description: 'des',
},
},
},
templater: 'cookiecutter',
path: '.',
parameters: {},
steps: [],
type: 'service',
owner: 'o',
},
@@ -46,8 +46,7 @@ import {
resourceEntityV1alpha1Validator,
SystemEntity,
systemEntityV1alpha1Validator,
TemplateEntity,
templateEntityV1alpha1Validator,
TemplateEntityV1beta2,
templateEntityV1beta2Validator,
UserEntity,
userEntityV1alpha1Validator,
@@ -62,7 +61,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
resourceEntityV1alpha1Validator,
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
templateEntityV1alpha1Validator,
templateEntityV1beta2Validator,
userEntityV1alpha1Validator,
systemEntityV1alpha1Validator,
@@ -136,7 +134,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
* Emit relations for the Template kind
*/
if (entity.kind === 'Template') {
const template = entity as TemplateEntity;
const template = entity as TemplateEntityV1beta2;
doEmit(
template.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
+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;
}
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { makeStyles } from '@material-ui/core';
import { Grid } from '@material-ui/core';
import {
EntityKindPicker,
EntityLifecyclePicker,
@@ -39,18 +39,6 @@ import {
TableProps,
} from '@backstage/core-components';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
buttonSpacing: {
marginLeft: theme.spacing(2),
},
}));
export type CatalogPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<EntityRow>[];
@@ -61,30 +49,36 @@ export const CatalogPage = ({
initiallySelectedFilter = 'owned',
columns,
actions,
}: CatalogPageProps) => {
const styles = useStyles();
return (
<CatalogLayout>
<Content>
<ContentHeader title="Components">
<CreateComponentButton />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</div>
}: CatalogPageProps) => (
<CatalogLayout>
<Content>
<ContentHeader title="Components">
<CreateComponentButton />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<Grid container spacing={2}>
<EntityListProvider>
<Grid item sm={12} lg={2} alignContent="flex-start">
<Grid container>
<Grid item xs={12} sm={4} lg={12}>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
</Grid>
<Grid item xs={12} sm={4} lg={12}>
<UserListPicker initialFilter={initiallySelectedFilter} />
</Grid>
<Grid item xs={12} sm={4} lg={12}>
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</Grid>
</Grid>
</Grid>
<Grid item xs={12} sm={12} lg={10}>
<CatalogTable columns={columns} actions={actions} />
</EntityListProvider>
</div>
</Content>
</CatalogLayout>
);
};
</Grid>
</EntityListProvider>
</Grid>
</Content>
</CatalogLayout>
);
+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"
]
}
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './processor';
export * from './types';
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/);
});
});
});
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -15,76 +15,73 @@
*/
import { ContainerRunner } from '@backstage/backend-common';
import { JsonValue } from '@backstage/config';
import commandExists from 'command-exists';
import fs from 'fs-extra';
import path from 'path';
import { runCommand } from './helpers';
import { TemplaterBase, TemplaterRunOptions } from './types';
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 CookieCutter implements TemplaterBase {
export class RailsNewRunner {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
private async fetchTemplateCookieCutter(
directory: string,
): Promise<Record<string, JsonValue>> {
try {
return await fs.readJSON(path.join(directory, 'cookiecutter.json'));
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
return {};
}
}
public async run({
workspacePath,
values,
logStream,
}: TemplaterRunOptions): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
}: {
workspacePath: string;
values: JsonObject;
logStream: Writable;
}): Promise<void> {
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir);
const { imageName, ...valuesForCookieCutterJson } = values;
const cookieInfo = {
...cookieCutterJson,
...valuesForCookieCutterJson,
};
await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo);
const { name, imageName, railsArguments } = values;
// Directories to bind on container
const mountDirs = {
[templateDir]: '/input',
[workspacePath]: '/input',
[intermediateDir]: '/output',
};
// the command-exists package returns `true` or throws an error
const cookieCutterInstalled = await commandExists('cookiecutter').catch(
() => false,
);
if (cookieCutterInstalled) {
const baseCommand = 'rails';
const baseArguments = ['new'];
const commandExistsToRun = await commandExists(baseCommand);
if (commandExistsToRun) {
const arrayExtraArguments = railsArgumentResolver(
workspacePath,
railsArguments as RailsRunOptions,
);
await runCommand({
command: 'cookiecutter',
args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'],
command: baseCommand,
args: [
...baseArguments,
`${intermediateDir}/${name}`,
...arrayExtraArguments,
],
logStream,
});
} else {
const arrayExtraArguments = railsArgumentResolver(
'/input',
railsArguments as RailsRunOptions,
);
await this.containerRunner.runContainer({
imageName: imageName || 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
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
@@ -94,12 +91,12 @@ export class CookieCutter implements TemplaterBase {
});
}
// if cookiecutter was successful, intermediateDir will contain
// 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 cookiecutter');
throw new Error(`No data generated by ${baseCommand}`);
}
await fs.move(path.join(intermediateDir, generated), resultDir);
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,7 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './prepare';
export * from './publish';
export * from './templater';
export * from './helpers';
export * from './fetch';
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,8 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './cookiecutter';
export * from './types';
export * from './helpers';
export * from './templaters';
export * from './cra';
export * from './actions';
+20 -370
View File
@@ -4,26 +4,18 @@
```ts
import { AzureIntegrationConfig } from '@backstage/integration';
import { BitbucketIntegrationConfig } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { ContainerRunner } from '@backstage/backend-common';
import { createPullRequest } from 'octokit-plugin-create-pull-request';
import express from 'express';
import { GithubCredentialsProvider } from '@backstage/integration';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/core';
import { GitLabIntegrationConfig } from '@backstage/integration';
import gitUrlParse from 'git-url-parse';
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';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/catalog-model';
import { UrlReader } from '@backstage/backend-common';
import { Writable } from 'stream';
@@ -31,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;
@@ -40,74 +32,12 @@ export type ActionContext<Input extends InputBase> = {
createTemporaryDirectory(): Promise<string>;
};
// @public (undocumented)
export class AzurePreparer implements PreparerBase {
constructor(config: {
token?: string;
});
// (undocumented)
static fromConfig(config: AzureIntegrationConfig): AzurePreparer;
// (undocumented)
prepare({ url, workspacePath, logger }: PreparerOptions): Promise<void>;
}
// @public (undocumented)
export class AzurePublisher implements PublisherBase {
constructor(config: {
token: string;
});
// (undocumented)
static fromConfig(config: AzureIntegrationConfig): Promise<AzurePublisher | undefined>;
// (undocumented)
publish({ values, workspacePath, logger, }: PublisherOptions): Promise<PublisherResult>;
}
// @public (undocumented)
export class BitbucketPreparer implements PreparerBase {
constructor(config: {
username?: string;
token?: string;
appPassword?: string;
});
// (undocumented)
static fromConfig(config: BitbucketIntegrationConfig): BitbucketPreparer;
// (undocumented)
prepare({ url, workspacePath, logger }: PreparerOptions): Promise<void>;
}
// @public (undocumented)
export class BitbucketPublisher implements PublisherBase {
constructor(config: {
host: string;
token?: string;
appPassword?: string;
username?: string;
apiBaseUrl?: string;
repoVisibility: RepoVisibilityOptions_2;
});
// (undocumented)
static fromConfig(config: BitbucketIntegrationConfig, { repoVisibility }: {
repoVisibility: RepoVisibilityOptions_2;
}): Promise<BitbucketPublisher>;
// (undocumented)
publish({ values, workspacePath, logger, }: PublisherOptions): Promise<PublisherResult>;
}
// @public
export class CatalogEntityClient {
constructor(catalogClient: CatalogApi);
findTemplate(templateName: string, options?: {
token?: string;
}): Promise<TemplateEntityV1alpha1 | TemplateEntityV1beta2>;
}
// @public (undocumented)
export class CookieCutter implements TemplaterBase {
constructor({ containerRunner }: {
containerRunner: ContainerRunner;
});
// (undocumented)
run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise<void>;
}): Promise<TemplateEntityV1beta2>;
}
// @public (undocumented)
@@ -115,7 +45,8 @@ export const createBuiltinActions: (options: {
reader: UrlReader;
integrations: ScmIntegrations;
catalogClient: CatalogApi;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
config: Config;
}) => TemplateAction<any>[];
// @public (undocumented)
@@ -134,7 +65,7 @@ export function createDebugLogAction(): TemplateAction<any>;
export function createFetchCookiecutterAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
}): TemplateAction<any>;
// @public (undocumented)
@@ -155,17 +86,16 @@ export const createFilesystemDeleteAction: () => TemplateAction<any>;
// @public (undocumented)
export const createFilesystemRenameAction: () => TemplateAction<any>;
// @public (undocumented)
export function createLegacyActions(options: Options): TemplateAction<any>[];
// @public (undocumented)
export function createPublishAzureAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public
@@ -174,6 +104,7 @@ export function createPublishFileAction(): TemplateAction<any>;
// @public (undocumented)
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
@@ -182,17 +113,9 @@ export const createPublishGithubPullRequestAction: ({ integrations, clientFactor
// @public (undocumented)
export function createPublishGitlabAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
export class CreateReactAppTemplater implements TemplaterBase {
constructor({ containerRunner }: {
containerRunner: ContainerRunner;
});
// (undocumented)
run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise<void>;
}
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -202,204 +125,13 @@ export const createTemplateAction: <Input extends Partial<{
}>>(templateAction: TemplateAction<Input>) => TemplateAction<any>;
// @public (undocumented)
export class FilePreparer implements PreparerBase {
// (undocumented)
prepare({ url, workspacePath }: PreparerOptions): Promise<void>;
}
// @public
export const getTemplaterKey: (entity: TemplateEntityV1alpha1) => string;
// @public (undocumented)
export class GithubPreparer implements PreparerBase {
constructor(config: {
credentialsProvider: GithubCredentialsProvider;
});
// (undocumented)
static fromConfig(config: GitHubIntegrationConfig): GithubPreparer;
// (undocumented)
prepare({ url, workspacePath, logger }: PreparerOptions): Promise<void>;
}
// @public @deprecated (undocumented)
export class GithubPublisher implements PublisherBase {
constructor(config: {
credentialsProvider: GithubCredentialsProvider;
repoVisibility: RepoVisibilityOptions;
apiBaseUrl: string | undefined;
});
// (undocumented)
static fromConfig(config: GitHubIntegrationConfig, { repoVisibility }: {
repoVisibility: RepoVisibilityOptions;
}): Promise<GithubPublisher | undefined>;
// (undocumented)
publish({ values, workspacePath, logger, }: PublisherOptions): Promise<PublisherResult>;
}
// @public (undocumented)
export class GitlabPreparer implements PreparerBase {
constructor(config: {
token?: string;
});
// (undocumented)
static fromConfig(config: GitLabIntegrationConfig): GitlabPreparer;
// (undocumented)
prepare({ url, workspacePath, logger }: PreparerOptions): Promise<void>;
}
// @public (undocumented)
export class GitlabPublisher implements PublisherBase {
constructor(config: {
token: string;
client: Gitlab;
repoVisibility: RepoVisibilityOptions_3;
});
// (undocumented)
static fromConfig(config: GitLabIntegrationConfig, { repoVisibility }: {
repoVisibility: RepoVisibilityOptions_3;
}): Promise<GitlabPublisher | undefined>;
// (undocumented)
publish({ values, workspacePath, logger, }: PublisherOptions): Promise<PublisherResult>;
}
// @public (undocumented)
export type Job = {
id: string;
context: StageContext;
status: ProcessorStatus;
stages: StageResult[];
error?: Error;
};
// @public (undocumented)
export type JobAndDirectoryTuple = {
job: Job;
directory: string;
};
// @public (undocumented)
export class JobProcessor implements Processor {
constructor(workingDirectory: string);
// (undocumented)
create({ entity, values, stages, }: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job;
// (undocumented)
static fromConfig({ config, logger, }: {
config: Config;
logger: Logger;
}): Promise<JobProcessor>;
// (undocumented)
get(id: string): Job | undefined;
// (undocumented)
run(job: Job): Promise<void>;
}
// @public (undocumented)
export function joinGitUrlPath(repoUrl: string, path?: string): string;
// @public (undocumented)
export type ParsedLocationAnnotation = {
protocol: 'file' | 'url';
location: string;
};
// @public (undocumented)
export const parseLocationAnnotation: (entity: TemplateEntityV1alpha1) => ParsedLocationAnnotation;
// @public (undocumented)
export interface PreparerBase {
prepare(opts: PreparerOptions): Promise<void>;
}
// @public (undocumented)
export type PreparerBuilder = {
register(host: string, preparer: PreparerBase): void;
get(url: string): PreparerBase;
};
// @public (undocumented)
export type PreparerOptions = {
url: string;
workspacePath: string;
logger: Logger;
};
// @public (undocumented)
export class Preparers implements PreparerBuilder {
// (undocumented)
static fromConfig(config: Config, _: {
logger: Logger;
}): Promise<PreparerBuilder>;
// (undocumented)
get(url: string): PreparerBase;
// (undocumented)
register(host: string, preparer: PreparerBase): void;
}
// @public (undocumented)
export type Processor = {
create({ entity, values, stages, }: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job;
get(id: string): Job | undefined;
run(job: Job): Promise<void>;
};
// @public (undocumented)
export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
// @public
export type PublisherBase = {
publish(opts: PublisherOptions): Promise<PublisherResult>;
};
// @public (undocumented)
export type PublisherBuilder = {
register(host: string, publisher: PublisherBase): void;
get(storePath: string): PublisherBase;
};
// @public (undocumented)
export type PublisherOptions = {
values: TemplaterValues;
workspacePath: string;
logger: Logger;
};
// @public (undocumented)
export type PublisherResult = {
remoteUrl: string;
catalogInfoUrl?: string;
};
// @public (undocumented)
export class Publishers implements PublisherBuilder {
// (undocumented)
static fromConfig(config: Config, _options: {
logger: Logger;
}): Promise<PublisherBuilder>;
// (undocumented)
get(url: string): PublisherBase;
// (undocumented)
register(host: string, preparer: PublisherBase | undefined): void;
}
// @public (undocumented)
export type RepoVisibilityOptions = 'private' | 'internal' | 'public';
// @public
export type RequiredTemplateValues = {
owner: string;
storePath: string;
destination?: {
git?: gitUrlParse.GitUrl;
};
};
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 {
@@ -410,63 +142,20 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
containerRunner: ContainerRunner;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
logger: Logger;
// (undocumented)
preparers: PreparerBuilder;
// (undocumented)
publishers: PublisherBuilder;
logger: Logger_2;
// (undocumented)
reader: UrlReader;
// (undocumented)
taskWorkers?: number;
// (undocumented)
templaters: TemplaterBuilder;
}
// @public (undocumented)
export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise<void>;
// @public (undocumented)
export type RunCommandOptions = {
command: string;
args: string[];
logStream?: Writable;
};
// @public (undocumented)
export type StageContext<T = {}> = {
values: TemplaterValues;
entity: TemplateEntityV1alpha1;
logger: Logger;
logStream: Writable;
workspacePath: string;
} & T;
// @public (undocumented)
export interface StageInput<T = {}> {
// (undocumented)
handler(ctx: StageContext<T>): Promise<void | object>;
// (undocumented)
name: string;
}
// @public (undocumented)
export interface StageResult extends StageInput {
// (undocumented)
endedAt?: number;
// (undocumented)
log: string[];
// (undocumented)
startedAt?: number;
// (undocumented)
status: ProcessorStatus;
}
// @public
export type SupportedTemplatingKey = 'cookiecutter' | string;
// @public (undocumented)
export type TemplateAction<Input extends InputBase> = {
id: string;
@@ -488,45 +177,6 @@ export class TemplateActionRegistry {
register<Parameters extends InputBase>(action: TemplateAction<Parameters>): void;
}
// @public (undocumented)
export type TemplaterBase = {
run(opts: TemplaterRunOptions): Promise<void>;
};
// @public
export type TemplaterBuilder = {
register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void;
get(templater: string): TemplaterBase;
};
// @public (undocumented)
export type TemplaterConfig = {
templater?: TemplaterBase;
};
// @public
export type TemplaterRunOptions = {
workspacePath: string;
values: TemplaterValues;
logStream?: Writable;
};
// @public
export type TemplaterRunResult = {
resultDir: string;
};
// @public (undocumented)
export class Templaters implements TemplaterBuilder {
// (undocumented)
get(templaterId: string): TemplaterBase;
// (undocumented)
register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase): void;
}
// @public (undocumented)
export type TemplaterValues = RequiredTemplateValues & Record<string, any>;
// (No @packageDocumentation comment for this package)
+7
View File
@@ -17,6 +17,13 @@
export interface Config {
/** Configuration options for the scaffolder plugin */
scaffolder?: {
/**
* The commit author info used when new components are created.
*/
defaultAuthor?: {
name?: string;
email?: string;
};
github?: {
[key: string]: string;
/**
+2 -2
View File
@@ -39,8 +39,7 @@
"@gitbeaker/node": "^30.2.0",
"@octokit/rest": "^18.5.3",
"@types/express": "^4.17.6",
"@types/git-url-parse": "^9.0.0",
"azure-devops-node-api": "^10.1.1",
"azure-devops-node-api": "^10.2.2",
"command-exists": "^1.2.9",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -70,6 +69,7 @@
"@backstage/test-utils": "^0.1.14",
"@types/command-exists": "^1.2.0",
"@types/fs-extra": "^9.0.1",
"@types/git-url-parse": "^9.0.0",
"@types/mock-fs": "^4.13.0",
"@types/nunjucks": "^3.1.4",
"@types/supertest": "^2.0.8",
@@ -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
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import {
TemplateEntityV1alpha1,
TemplateEntityV1beta2,
} from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { ConflictError, NotFoundError } from '@backstage/errors';
@@ -35,7 +32,7 @@ export class CatalogEntityClient {
async findTemplate(
templateName: string,
options?: { token?: string },
): Promise<TemplateEntityV1alpha1 | TemplateEntityV1beta2> {
): Promise<TemplateEntityV1beta2> {
const { items: templates } = (await this.catalogClient.getEntities(
{
filter: {
@@ -44,7 +41,7 @@ export class CatalogEntityClient {
},
},
options,
)) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] };
)) as { items: TemplateEntityV1beta2[] };
if (templates.length !== 1) {
if (templates.length > 1) {
@@ -14,14 +14,15 @@
* limitations under the License.
*/
import { UrlReader } from '@backstage/backend-common';
import { ContainerRunner, UrlReader } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { ScmIntegrations } from '@backstage/integration';
import { TemplaterBuilder } from '../../stages';
import { Config } from '@backstage/config';
import {
createCatalogRegisterAction,
createCatalogWriteAction,
createCatalogRegisterAction,
} from './catalog';
import { createDebugLogAction } from './debug';
import {
createFetchCookiecutterAction,
@@ -44,9 +45,16 @@ export const createBuiltinActions = (options: {
reader: UrlReader;
integrations: ScmIntegrations;
catalogClient: CatalogApi;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
config: Config;
}) => {
const { reader, integrations, templaters, catalogClient } = options;
const {
reader,
integrations,
containerRunner,
catalogClient,
config,
} = options;
return [
createFetchPlainAction({
@@ -56,7 +64,7 @@ export const createBuiltinActions = (options: {
createFetchCookiecutterAction({
reader,
integrations,
templaters,
containerRunner,
}),
createFetchTemplateAction({
integrations,
@@ -64,18 +72,22 @@ export const createBuiltinActions = (options: {
}),
createPublishGithubAction({
integrations,
config,
}),
createPublishGithubPullRequestAction({
integrations,
}),
createPublishGitlabAction({
integrations,
config,
}),
createPublishBitbucketAction({
integrations,
config,
}),
createPublishAzureAction({
integrations,
config,
}),
createDebugLogAction(),
createCatalogRegisterAction({ catalogClient, integrations }),
@@ -13,18 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('./helpers');
const runCommand = jest.fn();
const commandExists = jest.fn();
const fetchContents = jest.fn();
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
jest.mock('./helpers', () => ({ fetchContents }));
jest.mock('command-exists', () => commandExists);
jest.mock('../helpers', () => ({ runCommand }));
import {
getVoidLogger,
UrlReader,
ContainerRunner,
} from '@backstage/backend-common';
import { ConfigReader, JsonObject } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import mock from 'mock-fs';
import mockFs from 'mock-fs';
import os from 'os';
import { resolve as resolvePath } from 'path';
import { PassThrough } from 'stream';
import { Templaters } from '../../../stages/templater';
import { createFetchCookiecutterAction } from './cookiecutter';
import { fetchContents } from './helpers';
import { join } from 'path';
import { ActionContext } from '../../types';
describe('fetch:cookiecutter', () => {
const integrations = ScmIntegrations.fromConfig(
@@ -38,23 +47,19 @@ describe('fetch:cookiecutter', () => {
}),
);
const templaters = new Templaters();
const cookiecutterTemplater = { run: jest.fn() };
const mockTmpDir = os.tmpdir();
const mockContext = {
input: {
url: 'https://google.com/cookie/cutter',
targetPath: 'something',
values: {
help: 'me',
},
},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
let mockContext: ActionContext<{
url: string;
targetPath?: string;
values: JsonObject;
copyWithoutRender?: string[];
extensions?: string[];
imageName?: string;
}>;
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
const mockReader: UrlReader = {
@@ -65,122 +70,143 @@ describe('fetch:cookiecutter', () => {
const action = createFetchCookiecutterAction({
integrations,
templaters,
containerRunner,
reader: mockReader,
});
templaters.register('cookiecutter', cookiecutterTemplater);
beforeEach(() => {
mock({ [`${mockContext.workspacePath}/result`]: {} });
jest.restoreAllMocks();
jest.resetAllMocks();
mockContext = {
input: {
url: 'https://google.com/cookie/cutter',
targetPath: 'something',
values: {
help: 'me',
},
},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
// mock the temp directory
mockFs({ [mockTmpDir]: {} });
mockFs({ [`${join(mockTmpDir, 'template')}`]: {} });
commandExists.mockResolvedValue(null);
// Mock when run container is called it creates some new files in the mock filesystem
containerRunner.runContainer.mockImplementation(async () => {
mockFs({
[`${join(mockTmpDir, 'intermediate')}`]: {
'testfile.json': '{}',
},
});
});
// Mock when runCommand is called it creats some new files in the mock filesystem
runCommand.mockImplementation(async () => {
mockFs({
[`${join(mockTmpDir, 'intermediate')}`]: {
'testfile.json': '{}',
},
});
});
});
afterEach(() => {
mock.restore();
mockFs.restore();
});
it('should call fetchContents with the correct values', async () => {
await action.handler(mockContext);
it('should throw an error when copyWithoutRender is not an array', async () => {
(mockContext.input as any).copyWithoutRender = 'not an array';
expect(fetchContents).toHaveBeenCalledWith({
reader: mockReader,
integrations,
baseUrl: mockContext.baseUrl,
fetchUrl: mockContext.input.url,
outputPath: resolvePath(
mockContext.workspacePath,
`template/{{cookiecutter and 'contents'}}`,
),
});
});
it('should execute the cookiecutter templater with the correct values', async () => {
await action.handler(mockContext);
expect(cookiecutterTemplater.run).toHaveBeenCalledWith({
workspacePath: mockTmpDir,
logStream: mockContext.logStream,
values: mockContext.input.values,
});
});
it('should execute the cookiecutter templater with optional inputs if they are present and valid', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
copyWithoutRender: ['goreleaser.yml'],
extensions: [
'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension',
],
imageName: 'foo/cookiecutter-image-with-extensions',
},
});
expect(cookiecutterTemplater.run).toHaveBeenCalledWith({
workspacePath: mockTmpDir,
logStream: mockContext.logStream,
values: {
...mockContext.input.values,
_copy_without_render: ['goreleaser.yml'],
_extensions: [
'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension',
],
imageName: 'foo/cookiecutter-image-with-extensions',
},
});
});
it('should throw if copyWithoutRender is not an Array', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
copyWithoutRender: 'xyz',
},
}),
).rejects.toThrow(/copyWithoutRender must be an Array/);
});
it('should throw if extensions is not an Array', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
extensions: 'xyz',
},
}),
).rejects.toThrow(/extensions must be an Array/);
});
it('should throw if there is no cookiecutter templater initialized', async () => {
const templatersWithoutCookiecutter = new Templaters();
const newAction = createFetchCookiecutterAction({
integrations,
templaters: templatersWithoutCookiecutter,
reader: mockReader,
});
await expect(newAction.handler(mockContext)).rejects.toThrow(
/No templater registered/,
await expect(action.handler(mockContext)).rejects.toThrowError(
/Fetch action input copyWithoutRender must be an Array/,
);
});
it('should throw if the target directory is outside of the workspace path', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
targetPath: '/foo',
},
it('should throw an error when extensions is not an array', async () => {
(mockContext.input as any).extensions = 'not an array';
await expect(action.handler(mockContext)).rejects.toThrowError(
/Fetch action input extensions must be an Array/,
);
});
it('should call fetchContents with the correct variables', async () => {
fetchContents.mockImplementation(() => Promise.resolve());
await action.handler(mockContext);
expect(fetchContents).toHaveBeenCalledWith(
expect.objectContaining({
reader: mockReader,
integrations,
baseUrl: mockContext.baseUrl,
fetchUrl: mockContext.input.url,
outputPath: join(
mockTmpDir,
'template',
"{{cookiecutter and 'contents'}}",
),
}),
);
});
it('should call out to cookiecutter using runCommand when cookiecutter is installed', async () => {
commandExists.mockResolvedValue(true);
await action.handler(mockContext);
expect(runCommand).toHaveBeenCalledWith(
expect.objectContaining({
command: 'cookiecutter',
args: [
'--no-input',
'-o',
join(mockTmpDir, 'intermediate'),
join(mockTmpDir, 'template'),
'--verbose',
],
logStream: mockContext.logStream,
}),
);
});
it('should call out to the containerRunner when there is no cookiecutter installed', async () => {
commandExists.mockResolvedValue(false);
await action.handler(mockContext);
expect(containerRunner.runContainer).toHaveBeenCalledWith(
expect.objectContaining({
imageName: 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
mountDirs: {
[join(mockTmpDir, 'intermediate')]: '/output',
[join(mockTmpDir, 'template')]: '/input',
},
workingDir: '/input',
envVars: { HOME: '/tmp' },
logStream: mockContext.logStream,
}),
);
});
it('should use a custom imageName when there is an image supplied to the context', async () => {
const imageName = 'test-image';
mockContext.input.imageName = imageName;
await action.handler(mockContext);
expect(containerRunner.runContainer).toHaveBeenCalledWith(
expect.objectContaining({
imageName,
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
});
@@ -14,22 +14,117 @@
* limitations under the License.
*/
import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
import { JsonObject } from '@backstage/config';
import {
ContainerRunner,
UrlReader,
resolveSafeChildPath,
} from '@backstage/backend-common';
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import commandExists from 'command-exists';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater';
import path, { resolve as resolvePath } from 'path';
import { Writable } from 'stream';
import { runCommand } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { fetchContents } from './helpers';
export class CookiecutterRunner {
private readonly containerRunner: ContainerRunner;
constructor({ containerRunner }: { containerRunner: ContainerRunner }) {
this.containerRunner = containerRunner;
}
private async fetchTemplateCookieCutter(
directory: string,
): Promise<Record<string, JsonValue>> {
try {
return await fs.readJSON(path.join(directory, 'cookiecutter.json'));
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
return {};
}
}
public async run({
workspacePath,
values,
logStream,
}: {
workspacePath: string;
values: JsonObject;
logStream: Writable;
}): Promise<void> {
const templateDir = path.join(workspacePath, 'template');
const intermediateDir = path.join(workspacePath, 'intermediate');
await fs.ensureDir(intermediateDir);
const resultDir = path.join(workspacePath, 'result');
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir);
const { imageName, ...valuesForCookieCutterJson } = values;
const cookieInfo = {
...cookieCutterJson,
...valuesForCookieCutterJson,
};
await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo);
// Directories to bind on container
const mountDirs = {
[templateDir]: '/input',
[intermediateDir]: '/output',
};
// the command-exists package returns `true` or throws an error
const cookieCutterInstalled = await commandExists('cookiecutter').catch(
() => false,
);
if (cookieCutterInstalled) {
await runCommand({
command: 'cookiecutter',
args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'],
logStream,
});
} else {
await this.containerRunner.runContainer({
imageName: (imageName as string) ?? 'spotify/backstage-cookiecutter',
command: 'cookiecutter',
args: ['--no-input', '-o', '/output', '/input', '--verbose'],
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 cookiecutter was successful, intermediateDir will contain
// exactly one directory.
const [generated] = await fs.readdir(intermediateDir);
if (generated === undefined) {
throw new Error('No data generated by cookiecutter');
}
await fs.move(path.join(intermediateDir, generated), resultDir);
}
}
export function createFetchCookiecutterAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
containerRunner: ContainerRunner;
}) {
const { reader, templaters, integrations } = options;
const { reader, containerRunner, integrations } = options;
return createTemplateAction<{
url: string;
@@ -121,9 +216,9 @@ export function createFetchCookiecutterAction(options: {
outputPath: templateContentsDir,
});
const cookiecutter = templaters.get('cookiecutter');
const cookiecutter = new CookiecutterRunner({ containerRunner });
const values = {
...(ctx.input.values as TemplaterValues),
...ctx.input.values,
_copy_without_render: ctx.input.copyWithoutRender,
_extensions: ctx.input.extensions,
imageName: ctx.input.imageName,
@@ -17,3 +17,4 @@
export { createFetchPlainAction } from './plain';
export { createFetchCookiecutterAction } from './cookiecutter';
export { createFetchTemplateAction } from './template';
export { fetchContents } from './helpers';
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -14,23 +14,62 @@
* limitations under the License.
*/
import { spawn } from 'child_process';
import { PassThrough, Writable } from 'stream';
import globby from 'globby';
import { Logger } from 'winston';
import { Git } from '@backstage/backend-common';
import { Octokit } from '@octokit/rest';
export type RunCommandOptions = {
command: string;
args: string[];
logStream?: Writable;
};
export const runCommand = async ({
command,
args,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise<void>((resolve, reject) => {
const process = spawn(command, args);
process.stdout.on('data', stream => {
logStream.write(stream);
});
process.stderr.on('data', stream => {
logStream.write(stream);
});
process.on('error', error => {
return reject(error);
});
process.on('close', code => {
if (code !== 0) {
return reject(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
export async function initRepoAndPush({
dir,
remoteUrl,
auth,
logger,
defaultBranch = 'master',
gitAuthorInfo,
}: {
dir: string;
remoteUrl: string;
auth: { username: string; password: string };
logger: Logger;
defaultBranch?: string;
gitAuthorInfo?: { name?: string; email?: string };
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
@@ -53,11 +92,17 @@ export async function initRepoAndPush({
await git.add({ dir, filepath });
}
// use provided info if possible, otherwise use fallbacks
const authorInfo = {
name: gitAuthorInfo?.name ?? 'Scaffolder',
email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io',
};
await git.commit({
dir,
message: 'Initial commit',
author: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
committer: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
author: authorInfo,
committer: authorInfo,
});
await git.addRemote({
@@ -20,3 +20,4 @@ export * from './debug';
export * from './fetch';
export * from './filesystem';
export * from './publish';
export { runCommand } from './helpers';
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,32 +13,33 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
jest.mock('azure-devops-node-api', () => ({
WebApi: jest.fn(),
getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../helpers');
import { createPublishAzureAction } from './azure';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { WebApi } from 'azure-devops-node-api';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
describe('publish:azure', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
}),
);
const action = createPublishAzureAction({ integrations });
const config = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishAzureAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org',
@@ -165,6 +166,7 @@ describe('publish:azure', () => {
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -187,6 +189,47 @@ describe('publish:azure', () => {
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishAzureAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGitClient.createRepository.mockImplementation(() => ({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
}));
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -16,16 +16,18 @@
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
export function createPublishAzureAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -123,6 +125,11 @@ export function createPublishAzureAction(options: {
// so it's just the base path I think
const repoContentsUrl = remoteUrl;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -132,6 +139,7 @@ export function createPublishAzureAction(options: {
password: integrationConfig.config.token,
},
logger: ctx.logger,
gitAuthorInfo,
});
ctx.output('remoteUrl', remoteUrl);
@@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
jest.mock('../helpers');
import { createPublishBitbucketAction } from './bitbucket';
import { rest } from 'msw';
@@ -23,30 +24,30 @@ import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
describe('publish:bitbucket', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
}),
);
const action = createPublishBitbucketAction({ integrations });
const config = new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishBitbucketAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?repo=repo&owner=owner',
@@ -289,6 +290,7 @@ describe('publish:bitbucket', () => {
defaultBranch: 'master',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -331,6 +333,77 @@ describe('publish:bitbucket', () => {
defaultBranch: 'main',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishBitbucketAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/cloneurl',
},
],
},
}),
),
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -20,9 +20,10 @@ import {
ScmIntegrationRegistry,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createBitbucketCloudRepository = async (opts: {
owner: string;
@@ -184,8 +185,9 @@ const performEnableLFS = async (opts: {
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -284,6 +286,11 @@ export function createPublishBitbucketAction(options: {
apiBaseUrl,
});
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -297,6 +304,7 @@ export function createPublishBitbucketAction(options: {
},
defaultBranch,
logger: ctx.logger,
gitAuthorInfo,
});
if (enableLFS && host !== 'bitbucket.org') {
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
jest.mock('../helpers');
jest.mock('@octokit/rest');
import { createPublishGithubAction } from './github';
@@ -21,21 +22,21 @@ import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { when } from 'jest-when';
describe('publish:github', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
}),
);
const action = createPublishGithubAction({ integrations });
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGithubAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
@@ -178,6 +179,7 @@ describe('publish:github', () => {
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -207,6 +209,54 @@ describe('publish:github', () => {
defaultBranch: 'main',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishGithubAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGithubClient.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -22,17 +22,19 @@ import { Octokit } from '@octokit/rest';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../../../stages/publish/helpers';
} from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
type Collaborator = { access: Permission; username: string };
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
const credentialsProviders = new Map(
integrations.github.list().map(integration => {
@@ -248,6 +250,11 @@ export function createPublishGithubAction(options: {
const remoteUrl = newRepo.clone_url;
const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -257,6 +264,7 @@ export function createPublishGithubAction(options: {
password: token,
},
logger: ctx.logger,
gitAuthorInfo,
});
try {
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* 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.
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('../../../stages/publish/helpers');
jest.mock('../helpers');
jest.mock('@gitbeaker/node');
import { createPublishGitlabAction } from './gitlab';
@@ -21,27 +21,27 @@ import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
describe('publish:gitlab', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
}),
);
const action = createPublishGitlabAction({ integrations });
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGitlabAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
@@ -143,6 +143,7 @@ describe('publish:gitlab', () => {
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -166,6 +167,55 @@ describe('publish:gitlab', () => {
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishGitlabAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
});
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -17,14 +17,16 @@
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { initRepoAndPush } from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
export function createPublishGitlabAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -121,6 +123,11 @@ export function createPublishGitlabAction(options: {
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
const repoContentsUrl = `${remoteUrl}/-/blob/master`;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl: http_url_to_repo as string,
@@ -130,6 +137,7 @@ export function createPublishGitlabAction(options: {
password: integrationConfig.config.token,
},
logger: ctx.logger,
gitAuthorInfo,
});
ctx.output('remoteUrl', remoteUrl);
@@ -13,7 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createLegacyActions } from './stages/legacy';
export * from './stages';
export * from './jobs';
export * from './actions';
@@ -1,49 +0,0 @@
/*
* Copyright 2020 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 { makeLogStream } from './logger';
describe('Logger', () => {
const mockMeta = { test: 'blob' };
it('should return empty log lines by default', async () => {
const { log } = makeLogStream(mockMeta);
expect(log).toEqual([]);
});
it('should add lines to the log when using the logger that is returned', async () => {
const { logger, log } = makeLogStream(mockMeta);
logger.info('TEST LINE');
logger.warn('WARN LINE');
const [first, second] = log;
expect(log.length).toBe(2);
expect(first).toContain('info');
expect(first).toContain('TEST LINE');
expect(second).toContain('warn');
expect(second).toContain('WARN LINE');
});
it('should add lines from writing to the stream that is returned', async () => {
const { stream, log } = makeLogStream(mockMeta);
const textLine = 'SOMETHING';
stream.write(textLine);
expect(log).toContain(textLine);
});
});
@@ -1,48 +0,0 @@
/*
* Copyright 2020 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 { PassThrough } from 'stream';
import * as winston from 'winston';
import { JsonValue } from '@backstage/config';
export const makeLogStream = (meta: Record<string, JsonValue>) => {
const log: string[] = [];
// Create an empty stream to collect all the log lines into
// one variable for the API.
const stream = new PassThrough();
stream.on('data', chunk => {
const textValue = chunk.toString().trim();
if (textValue?.length > 1) log.push(textValue);
});
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: meta,
});
logger.add(new winston.transports.Stream({ stream }));
return {
log,
stream,
logger,
};
};
@@ -1,329 +0,0 @@
/*
* Copyright 2020 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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import parseGitUrl from 'git-url-parse';
import mockFs from 'mock-fs';
import os from 'os';
import { RequiredTemplateValues } from '../stages/templater';
import { makeLogStream } from './logger';
import { JobProcessor } from './processor';
import { StageInput } from './types';
describe('JobProcessor', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'example@email.com',
},
};
const mockValues: RequiredTemplateValues = {
owner: 'blobby',
storePath: 'https://github.com/backstage/mock-repo',
destination: {
git: parseGitUrl('https://github.com/backstage/mock-repo'),
},
};
const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
// NOTE(freben): Without this line, mock-fs makes winston/logform break.
// There are a number of reported issues with logform and its use of dynamic
// strings for imports. It confuses webpack. The basic fix is to trigger
// those imports before mock-fs runs. I wanted to add a mock dir
// 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing
// in mock-fs 4.
// Probable REAL fix: https://github.com/winstonjs/logform/pull/117
makeLogStream({});
beforeEach(() => {
mockFs({
[workingDirectory]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
describe('create', () => {
it('creates should create a new job with a unique id', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.id).toMatch(
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
);
});
it('should setup the correct context for the job', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.context.entity).toBe(mockEntity);
expect(job.context.values).toBe(mockValues);
});
it('should set the status as pending', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.status).toBe('PENDING');
});
it('should create the correct stages', async () => {
const stages: StageInput[] = [
{
name: 'Do something cool step 1',
handler: jest.fn(),
},
{
name: 'Do something cool step 2',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
expect(job.stages).toHaveLength(stages.length);
for (let i = 0; i < job.stages.length; i++) {
expect(job.stages[i].name).toBe(stages[i].name);
expect(job.stages[i].status).toBe('PENDING');
}
});
});
describe('get', () => {
it('return undefined for when the job does not exist', () => {
const processor = new JobProcessor(workingDirectory);
expect(processor.get('123')).not.toBeDefined();
});
it('should return the exact same instance of the job when one is created', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(processor.get(job.id)).toBe(job);
});
});
describe('process', () => {
it('throws an error when the status of the job is not in pending state', async () => {
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
job.status = 'STARTED';
await expect(processor.run(job)).rejects.toThrow(
/Job is not in a 'PENDING' state/,
);
});
it('will call each of the handlers in the stages', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of stages) {
expect(stage.handler).toHaveBeenCalled();
}
});
it('should set all stages to complete and the job to complete when finishes without errors', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of job.stages) {
expect(stage.status).toBe('COMPLETED');
}
expect(job.status).toBe('COMPLETED');
});
it('should merge the return value from previous steps into the context of the next step', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest
.fn()
.mockResolvedValue({ first: 'ben', second: 'lambert' }),
},
{
name: 'g/p',
handler: jest
.fn()
.mockResolvedValue({ second: 'linus', third: 'lambert' }),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(stages[1].handler).toHaveBeenCalledWith(
expect.objectContaining({ first: 'ben', second: 'lambert' }),
);
expect(stages[2].handler).toHaveBeenCalledWith(
expect.objectContaining({
first: 'ben',
second: 'linus',
third: 'lambert',
}),
);
});
it('should fail the job and the step if one of them fails', async () => {
const fail = new Error('something went wrong here');
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn().mockRejectedValue(fail),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor(workingDirectory);
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(job.status).toBe('FAILED');
expect(job.stages[0].status).toBe('COMPLETED');
expect(job.stages[1].status).toBe('FAILED');
expect(job.stages[2].status).toBe('PENDING');
expect(job.error?.message).toBe('something went wrong here');
expect(job.stages[1].log.join()).toContain('something went wrong here');
});
});
});
@@ -1,180 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { Processor, Job, StageContext, StageInput } from './types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import * as uuid from 'uuid';
import path from 'path';
import { TemplaterValues } from '../stages/templater';
import { makeLogStream } from './logger';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
export type JobAndDirectoryTuple = {
job: Job;
directory: string;
};
export class JobProcessor implements Processor {
private readonly workingDirectory: string;
private readonly jobs: Map<string, Job>;
static async fromConfig({
config,
logger,
}: {
config: Config;
logger: Logger;
}) {
let workingDirectory: string;
if (config.has('backend.workingDirectory')) {
workingDirectory = config.getString('backend.workingDirectory');
try {
// Check if working directory exists and is writable
await fs.promises.access(
workingDirectory,
fs.constants.F_OK | fs.constants.W_OK,
);
logger.info(`using working directory: ${workingDirectory}`);
} catch (err) {
logger.error(
`working directory ${workingDirectory} ${
err.code === 'ENOENT' ? 'does not exist' : 'is not writable'
}`,
);
throw err;
}
} else {
workingDirectory = os.tmpdir();
}
return new JobProcessor(workingDirectory);
}
constructor(workingDirectory: string) {
this.workingDirectory = workingDirectory;
this.jobs = new Map<string, Job>();
}
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job {
const id = uuid.v4();
const { logger, stream } = makeLogStream({ id });
const context: StageContext = {
entity,
values,
logger,
logStream: stream,
workspacePath: path.join(this.workingDirectory, id),
};
const job: Job = {
id,
context,
stages: stages.map(stage => ({
handler: stage.handler,
log: [],
name: stage.name,
status: 'PENDING',
})),
status: 'PENDING',
};
this.jobs.set(job.id, job);
return job;
}
get(id: string): Job | undefined {
return this.jobs.get(id);
}
async run(job: Job): Promise<void> {
if (job.status !== 'PENDING') {
throw new Error("Job is not in a 'PENDING' state");
}
await fs.mkdir(job.context.workspacePath);
job.status = 'STARTED';
try {
for (const stage of job.stages) {
// Create a logger for each stage so we can create separate
// Streams for each step.
const { logger, log, stream } = makeLogStream({
id: job.id,
stage: stage.name,
});
// Attach the logger to the stage, and setup some timestamps.
stage.log = log;
stage.startedAt = Date.now();
try {
// Run the handler with the context created for the Job and some
// Additional logging helpers.
stage.status = 'STARTED';
const handlerResponse = await stage.handler({
...job.context,
logger,
logStream: stream,
});
// If the handler returns something, then let's merge this onto the
// context for the next stage to use as it might be relevant.
if (handlerResponse) {
job.context = {
...job.context,
...handlerResponse,
};
}
// Complete the current stage
stage.status = 'COMPLETED';
} catch (error) {
// Log to the current stage the error that occurred and fail the stage.
stage.status = 'FAILED';
logger.error(`Stage failed with error: ${error.message}`);
logger.debug(error.stack);
// Throw the error so the job can be failed too.
throw error;
} finally {
// Always set the stage end timestamp.
stage.endedAt = Date.now();
}
}
// If all went to plan, complete the job.
job.status = 'COMPLETED';
} catch (error) {
// If something went wrong, fail the job, and set the error property on the job.
job.error = { name: error.name, message: error.message };
job.status = 'FAILED';
} finally {
await fs.remove(job.context.workspacePath);
}
}
}
@@ -1,68 +0,0 @@
/*
* Copyright 2020 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 type { Writable } from 'stream';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { TemplaterValues } from '../stages/templater';
import { Logger } from 'winston';
// Context will be a mutable object which is passed between stages
// To share data, but also thinking that we can pass in functions here too
// To maybe create sub steps or fail the entire thing, or skip stages down the line.
export type StageContext<T = {}> = {
values: TemplaterValues;
entity: TemplateEntityV1alpha1;
logger: Logger;
logStream: Writable;
workspacePath: string;
} & T;
export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export interface StageResult extends StageInput {
log: string[];
status: ProcessorStatus;
startedAt?: number;
endedAt?: number;
}
export interface StageInput<T = {}> {
name: string;
handler(ctx: StageContext<T>): Promise<void | object>;
}
export type Job = {
id: string;
context: StageContext;
status: ProcessorStatus;
stages: StageResult[];
error?: Error;
};
export type Processor = {
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: TemplaterValues;
stages: StageInput[];
}): Job;
get(id: string): Job | undefined;
run(job: Job): Promise<void>;
};
@@ -1,332 +0,0 @@
/*
* Copyright 2020 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 {
LOCATION_ANNOTATION,
TemplateEntityV1alpha1,
} from '@backstage/catalog-model';
import { joinGitUrlPath, parseLocationAnnotation } from './helpers';
describe('Helpers', () => {
describe('parseLocationAnnotation', () => {
it('throws an exception when no annotation location', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-d@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'InputError',
message: `No location annotation provided in entity: ${mockEntity.metadata.name}`,
}),
);
});
it('should throw an error when the protocol part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]:
':https://github.com/o/r/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-b@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'TypeError',
message:
"Unable to parse location reference ':https://github.com/o/r/blob/master/template.yaml', expected '<type>:<target>', e.g. 'url:https://host/path'",
}),
);
});
it('should throw an error when the location part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-a@example.com',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'TypeError',
message: `Unable to parse location reference 'github:', expected '<type>:<target>', e.g. 'url:https://host/path'`,
}),
);
});
it('should parse the location and protocol correctly for simple locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'file:./path',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-b@example.com',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'file',
location: './path',
});
});
it('should parse the location and protocol correctly for complex with unescaped locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:https://lol.com/:something/shello',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
owner: 'team-c@example.com',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'github',
location: 'https://lol.com/:something/shello',
});
});
});
describe('joinGitUrlPath', () => {
it.each([
[
'https://github.com/o/r/blob/master/template.yaml',
'template',
'https://github.com/o/r/blob/master/template',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml',
undefined,
'https://dev.azure.com/o/p/_git/template-repo?path=%2F',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml',
'a',
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa',
],
[
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml',
'b',
'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb',
],
[
'https://github.com/o/r/blob/master/template.yaml',
undefined,
'https://github.com/o/r/blob/master',
],
[
'https://github.com/o/r/blob/master/template.yaml',
'template',
'https://github.com/o/r/blob/master/template',
],
[
'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml',
'template',
'https://github.com/o/r/blob/master/templates/graphql-starter/template',
],
[
'https://gitlab.com/o/r/-/blob/master/template.yaml',
undefined,
'https://gitlab.com/o/r/-/blob/master',
],
[
'https://gitlab.com/o/r/-/blob/master/template.yaml',
'template',
'https://gitlab.com/o/r/-/blob/master/template',
],
[
'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml',
'../../c',
'https://gitlab.com/o/r/-/blob/master/a/c',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
undefined,
'https://bitbucket.org/p/r/src/master/a/b',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
'c',
'https://bitbucket.org/p/r/src/master/a/b/c',
],
[
'https://bitbucket.org/p/r/src/master/a/b/template.yaml',
'../c',
'https://bitbucket.org/p/r/src/master/a/c',
],
])('should join git url %s with path %s', (url, path, result) => {
expect(joinGitUrlPath(url, path)).toBe(result);
});
});
});
@@ -1,62 +0,0 @@
/*
* Copyright 2020 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 { InputError } from '@backstage/errors';
import {
LOCATION_ANNOTATION,
parseLocationReference,
TemplateEntityV1alpha1,
} from '@backstage/catalog-model';
import { posix as posixPath } from 'path';
export type ParsedLocationAnnotation = {
protocol: 'file' | 'url';
location: string;
};
export const parseLocationAnnotation = (
entity: TemplateEntityV1alpha1,
): ParsedLocationAnnotation => {
const annotation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!annotation) {
throw new InputError(
`No location annotation provided in entity: ${entity.metadata.name}`,
);
}
const { type, target } = parseLocationReference(annotation);
return {
protocol: type as 'file' | 'url',
location: target,
};
};
export function joinGitUrlPath(repoUrl: string, path?: string): string {
const parsed = new URL(repoUrl);
if (parsed.hostname.endsWith('azure.com')) {
const templatePath = posixPath.normalize(
posixPath.join(
posixPath.dirname(parsed.searchParams.get('path') || '/'),
path || '.',
),
);
parsed.searchParams.set('path', templatePath);
return parsed.toString();
}
return new URL(path || '.', repoUrl).toString().replace(/\/$/, '');
}
@@ -1,105 +0,0 @@
/*
* 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 } from '../actions';
import { FilePreparer, PreparerBuilder } from './prepare';
import { PublisherBuilder } from './publish';
import { TemplaterBuilder, TemplaterValues } from './templater';
type Options = {
preparers: PreparerBuilder;
templaters: TemplaterBuilder;
publishers: PublisherBuilder;
};
export function createLegacyActions(options: Options) {
const { preparers, templaters, publishers } = options;
return [
createTemplateAction({
id: 'legacy:prepare',
async handler(ctx) {
ctx.logger.info('Preparing the skeleton');
const { protocol, url } = ctx.input;
const preparer =
protocol === 'file'
? new FilePreparer()
: preparers.get(url as string);
await preparer.prepare({
url: url as string,
logger: ctx.logger,
workspacePath: ctx.workspacePath,
});
},
}),
createTemplateAction({
id: 'legacy:template',
async handler(ctx) {
ctx.logger.info('Running the templater');
const templater = templaters.get(ctx.input.templater as string);
await templater.run({
workspacePath: ctx.workspacePath,
logStream: ctx.logStream,
values: ctx.input.values as TemplaterValues,
});
},
}),
createTemplateAction({
id: 'legacy:publish',
async handler(ctx) {
const { values } = ctx.input;
if (
typeof values !== 'object' ||
values === null ||
Array.isArray(values)
) {
throw new Error(
`Invalid values passed to publish, got ${typeof values}`,
);
}
const storePath = values.storePath as unknown;
if (typeof storePath !== 'string') {
throw new Error(
`Invalid store path passed to publish, got ${typeof storePath}`,
);
}
const owner = values.owner as unknown;
if (typeof owner !== 'string') {
throw new Error(
`Invalid owner passed to publish, got ${typeof owner}`,
);
}
const publisher = publishers.get(storePath);
ctx.logger.info('Will now store the template');
const { remoteUrl, catalogInfoUrl } = await publisher.publish({
values: {
...values,
owner,
storePath,
},
workspacePath: ctx.workspacePath,
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
if (catalogInfoUrl) {
ctx.output('catalogInfoUrl', catalogInfoUrl);
}
},
}),
];
}
@@ -1,116 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { AzurePreparer } from './azure';
import { getVoidLogger, Git } from '@backstage/backend-common';
jest.mock('fs-extra');
describe('AzurePreparer', () => {
const mockGitClient = {
clone: jest.fn(),
};
const logger = getVoidLogger();
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
const preparer = AzurePreparer.fromConfig({
host: 'dev.azure.com',
token: 'fake-azure-token',
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const prepareOptions = {
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
workspacePath,
logger,
};
it('calls the clone command with token from integrations config', async () => {
await preparer.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
password: 'fake-azure-token',
username: 'notempty',
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
});
});
it('calls the clone command with the correct arguments for a repository with a specified branch', async () => {
await preparer.prepare({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster',
logger,
workspacePath,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
ref: 'master',
});
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
await preparer.prepare({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
workspacePath,
logger,
});
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: checkoutPath,
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
});
it('moves the template from path if it is specified', async () => {
await preparer.prepare({
url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent(
'./subdir',
)}`,
logger,
workspacePath,
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, 'subdir'),
templatePath,
);
});
});
@@ -1,63 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import path from 'path';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import parseGitUrl from 'git-url-parse';
import { AzureIntegrationConfig } from '@backstage/integration';
export class AzurePreparer implements PreparerBase {
static fromConfig(config: AzureIntegrationConfig) {
return new AzurePreparer({ token: config.token });
}
constructor(private readonly config: { token?: string }) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
// Username can be anything but the empty string according to:
// https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat
const git = this.config.token
? Git.fromAuth({
password: this.config.token,
username: 'notempty',
logger,
})
: Git.fromAuth({ logger });
await git.clone({
url: parsedGitUrl.toString('https'),
ref: parsedGitUrl.ref,
dir: checkoutPath,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
}
@@ -1,113 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { BitbucketPreparer } from './bitbucket';
import { getVoidLogger, Git } from '@backstage/backend-common';
import path from 'path';
import os from 'os';
jest.mock('fs-extra');
describe('BitbucketPreparer', () => {
const logger = getVoidLogger();
const mockGitClient = {
clone: jest.fn(),
};
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
});
const preparer = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
const checkoutPath = path.resolve(workspacePath, 'checkout');
const templatePath = path.resolve(workspacePath, 'template');
const prepareOptions = {
url: 'https://bitbucket.org/backstage-project/backstage-repo',
logger,
workspacePath,
};
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://bitbucket.org/backstage-project/backstage-repo',
dir: checkoutPath,
ref: expect.any(String),
});
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
});
it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => {
const preparerCheck = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
await preparerCheck.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'fake-user',
password: 'fake-password',
});
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
await preparer.prepare(prepareOptions);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://bitbucket.org/backstage-project/backstage-repo',
dir: checkoutPath,
ref: expect.any(String),
});
});
it('moves a template subdirectory to checkout if specified', async () => {
await preparer.prepare({
url: 'https://bitbucket.org/foo/bar/src/master/1/2/3',
logger,
workspacePath,
});
expect(fs.move).toHaveBeenCalledWith(
path.resolve(checkoutPath, '1', '2', '3'),
templatePath,
);
});
it('calls the clone command with with token for auth method', async () => {
const preparerCheck = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
token: 'fake-token',
});
await preparerCheck.prepare(prepareOptions);
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'x-token-auth',
password: 'fake-token',
});
});
});
@@ -1,82 +0,0 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import path from 'path';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import { BitbucketIntegrationConfig } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
export class BitbucketPreparer implements PreparerBase {
static fromConfig(config: BitbucketIntegrationConfig) {
return new BitbucketPreparer({
username: config.username,
token: config.token,
appPassword: config.appPassword,
});
}
constructor(
private readonly config: {
username?: string;
token?: string;
appPassword?: string;
},
) {}
async prepare({ url, workspacePath, logger }: PreparerOptions) {
const parsedGitUrl = parseGitUrl(url);
const checkoutPath = path.join(workspacePath, 'checkout');
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath ?? '',
);
const git = Git.fromAuth({ logger, ...this.getAuth() });
await git.clone({
url: parsedGitUrl.toString('https'),
dir: checkoutPath,
ref: parsedGitUrl.ref,
});
await fs.move(fullPathToTemplate, targetPath);
try {
await fs.rmdir(path.join(targetPath, '.git'));
} catch {
// Ignore intentionally
}
}
private getAuth(): { username: string; password: string } | undefined {
const { username, token, appPassword } = this.config;
if (username && appPassword) {
return { username: username, password: appPassword };
}
if (token) {
return {
username: 'x-token-auth',
password: token! || appPassword!,
};
}
return undefined;
}
}

Some files were not shown because too many files have changed in this diff Show More