Merge remote-tracking branch 'origin/master' into mob/use-entity-list-provider-in-scaffolder-page
Signed-off-by: Himanshu Mishra <himanshu@orkohunter.net>
This commit is contained in:
@@ -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,
|
||||
|
||||
```
|
||||
@@ -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 }`.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
'@backstage/create-app': patch
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
bump sqlite3 to 5.0.1
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
add support for uiSchema on dependent form fields
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Handle empty code blocks in markdown files so they don't fail rendering
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
bump azure-devops-node to 10.2.2
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Export the `fetchContents` from scaffolder-backend
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? ''} />;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"pg": "^8.3.0",
|
||||
{{/if}}
|
||||
{{#if dbTypeSqlite}}
|
||||
"sqlite3": "^5.0.0",
|
||||
"sqlite3": "^5.0.1",
|
||||
{{/if}}
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
|
||||
+1
-18
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { JSONWebKey } from 'jose';
|
||||
import { Logger } from 'winston';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { Profile } from 'passport';
|
||||
@@ -23,7 +23,7 @@ export type AuthProviderFactoryOptions = {
|
||||
providerId: string;
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
tokenIssuer: TokenIssuer;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
catalogApi: CatalogApi;
|
||||
@@ -206,7 +206,7 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
// (undocumented)
|
||||
providerFactories?: ProviderFactories;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Config } from '@backstage/config';
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { SearchEntry } from 'ldapjs';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
@@ -38,7 +38,7 @@ export const LDAP_UUID_ANNOTATION = "backstage.io/ldap-uuid";
|
||||
export class LdapClient {
|
||||
constructor(client: Client);
|
||||
// (undocumented)
|
||||
static create(logger: Logger, target: string, bind?: BindConfig): Promise<LdapClient>;
|
||||
static create(logger: Logger_2, target: string, bind?: BindConfig): Promise<LdapClient>;
|
||||
getRootDSE(): Promise<SearchEntry | undefined>;
|
||||
getVendor(): Promise<LdapVendor>;
|
||||
search(dn: string, options: SearchOptions): Promise<SearchEntry[]>;
|
||||
@@ -48,13 +48,13 @@ export class LdapClient {
|
||||
export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config, options: {
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}): LdapOrgReaderProcessor;
|
||||
@@ -80,7 +80,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[];
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
}): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
|
||||
import { Config } from '@backstage/config';
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
|
||||
import * as msal from '@azure/msal-node';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
@@ -70,12 +70,12 @@ export class MicrosoftGraphClient {
|
||||
export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
providers: MicrosoftGraphProviderConfig[];
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
groupTransformer?: GroupTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config, options: {
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
groupTransformer?: GroupTransformer;
|
||||
}): MicrosoftGraphOrgReaderProcessor;
|
||||
// (undocumented)
|
||||
@@ -107,7 +107,7 @@ export function readMicrosoftGraphOrg(client: MicrosoftGraphClient, tenantId: st
|
||||
userFilter?: string;
|
||||
groupFilter?: string;
|
||||
groupTransformer?: GroupTransformer;
|
||||
logger: Logger;
|
||||
logger: Logger_2;
|
||||
}): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+5
-14
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
-3
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+55
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+112
@@ -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;
|
||||
};
|
||||
+263
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
-45
@@ -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);
|
||||
+2
-5
@@ -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';
|
||||
+2
-6
@@ -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';
|
||||
@@ -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)
|
||||
@@ -149,17 +80,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
|
||||
@@ -168,6 +98,7 @@ export function createPublishFileAction(): TemplateAction<any>;
|
||||
// @public (undocumented)
|
||||
export function createPublishGithubAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
config: Config;
|
||||
}): TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -176,17 +107,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>;
|
||||
|
||||
@@ -196,204 +119,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 {
|
||||
@@ -404,63 +136,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;
|
||||
@@ -482,45 +171,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
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
@@ -68,6 +67,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/supertest": "^2.0.8",
|
||||
"jest-when": "^3.1.0",
|
||||
|
||||
@@ -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 }}'
|
||||
+14
@@ -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, createFetchPlainAction } from './fetch';
|
||||
import {
|
||||
@@ -40,9 +41,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({
|
||||
@@ -52,22 +60,26 @@ export const createBuiltinActions = (options: {
|
||||
createFetchCookiecutterAction({
|
||||
reader,
|
||||
integrations,
|
||||
templaters,
|
||||
containerRunner,
|
||||
}),
|
||||
createPublishGithubAction({
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createPublishGithubPullRequestAction({
|
||||
integrations,
|
||||
}),
|
||||
createPublishGitlabAction({
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createPublishBitbucketAction({
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createPublishAzureAction({
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createDebugLogAction(),
|
||||
createCatalogRegisterAction({ catalogClient, integrations }),
|
||||
|
||||
+151
-125
@@ -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,
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export { createFetchPlainAction } from './plain';
|
||||
export { createFetchCookiecutterAction } from './cookiecutter';
|
||||
export { fetchContents } from './helpers';
|
||||
|
||||
+48
-3
@@ -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);
|
||||
|
||||
+96
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
import { FilePreparer } from './file';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('File preparer', () => {
|
||||
it('prepares templates from a file path', async () => {
|
||||
const logger = getVoidLogger();
|
||||
const preparer = new FilePreparer();
|
||||
const root = os.platform() === 'win32' ? 'C:\\' : '/';
|
||||
const workspacePath = path.join(root, 'tmp');
|
||||
const targetPath = path.resolve(workspacePath, 'template');
|
||||
|
||||
await preparer.prepare({
|
||||
url: `file://${root}path/to/template`,
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
path.join(root, 'path', 'to', 'template'),
|
||||
targetPath,
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith(targetPath);
|
||||
|
||||
await expect(
|
||||
preparer.prepare({
|
||||
url: 'http://not/file/path',
|
||||
logger,
|
||||
workspacePath,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Wrong location protocol, should be 'file', http://not/file/path",
|
||||
);
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
await expect(
|
||||
preparer.prepare({
|
||||
url: 'file:///unix/file/path',
|
||||
logger,
|
||||
workspacePath,
|
||||
}),
|
||||
).rejects.toThrow('File URL path must be absolute');
|
||||
} else {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
await expect(
|
||||
preparer.prepare({
|
||||
url: 'file://not/full/path',
|
||||
logger,
|
||||
workspacePath,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`File URL host must be "localhost" or empty on ${os.platform()}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,37 +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 { fileURLToPath } from 'url';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
|
||||
export class FilePreparer implements PreparerBase {
|
||||
async prepare({ url, workspacePath }: PreparerOptions) {
|
||||
if (!url.startsWith('file://')) {
|
||||
throw new InputError(`Wrong location protocol, should be 'file', ${url}`);
|
||||
}
|
||||
|
||||
const templatePath = fileURLToPath(url);
|
||||
|
||||
const targetDir = path.join(workspacePath, 'template');
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
await fs.copy(templatePath, targetDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,97 +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 { GithubPreparer } from './github';
|
||||
import { getVoidLogger, Git } from '@backstage/backend-common';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('GitHubPreparer', () => {
|
||||
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
const checkoutPath = path.resolve(workspacePath, 'checkout');
|
||||
const templatePath = path.resolve(workspacePath, 'template');
|
||||
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
const logger = getVoidLogger();
|
||||
|
||||
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const preparer = GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
token: 'fake-token',
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository', async () => {
|
||||
await preparer.prepare({
|
||||
url:
|
||||
'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template',
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/benjdlambert/backstage-graphql-template',
|
||||
dir: checkoutPath,
|
||||
ref: expect.any(String),
|
||||
});
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'),
|
||||
templatePath,
|
||||
);
|
||||
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
|
||||
});
|
||||
|
||||
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
|
||||
await preparer.prepare({
|
||||
url:
|
||||
'https://github.com/benjdlambert/backstage-graphql-template/blob/master',
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/benjdlambert/backstage-graphql-template',
|
||||
dir: checkoutPath,
|
||||
ref: 'master',
|
||||
});
|
||||
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
|
||||
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
|
||||
});
|
||||
|
||||
it('calls the clone command with token', async () => {
|
||||
await preparer.prepare({
|
||||
url:
|
||||
'https://github.com/benjdlambert/backstage-graphql-template/blob/master',
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'x-access-token',
|
||||
password: 'fake-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +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 {
|
||||
GitHubIntegrationConfig,
|
||||
GithubCredentialsProvider,
|
||||
} from '@backstage/integration';
|
||||
|
||||
export class GithubPreparer implements PreparerBase {
|
||||
static fromConfig(config: GitHubIntegrationConfig) {
|
||||
const credentialsProvider = GithubCredentialsProvider.create(config);
|
||||
return new GithubPreparer({ credentialsProvider });
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: { credentialsProvider: GithubCredentialsProvider },
|
||||
) {}
|
||||
|
||||
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 { token } = await this.config.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
const git = token
|
||||
? Git.fromAuth({
|
||||
username: 'x-access-token',
|
||||
password: token,
|
||||
logger,
|
||||
})
|
||||
: Git.fromAuth({ logger });
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 os from 'os';
|
||||
import path from 'path';
|
||||
import { GitlabPreparer } from './gitlab';
|
||||
import { getVoidLogger, Git } from '@backstage/backend-common';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('GitLabPreparer', () => {
|
||||
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
const checkoutPath = path.resolve(workspacePath, 'checkout');
|
||||
const templatePath = path.resolve(workspacePath, 'template');
|
||||
|
||||
const mockGitClient = {
|
||||
clone: jest.fn(),
|
||||
};
|
||||
const logger = getVoidLogger();
|
||||
|
||||
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const preparer = GitlabPreparer.fromConfig({
|
||||
host: '<ignored>',
|
||||
token: 'fake-token',
|
||||
apiBaseUrl: '<ignored>',
|
||||
baseUrl: '<ignored>',
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments for a repository`, async () => {
|
||||
await preparer.prepare({
|
||||
url:
|
||||
'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master',
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
expect(mockGitClient.clone).toHaveBeenCalledWith({
|
||||
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git',
|
||||
dir: checkoutPath,
|
||||
ref: expect.any(String),
|
||||
});
|
||||
|
||||
expect(Git.fromAuth).toHaveBeenCalledWith({
|
||||
logger,
|
||||
username: 'oauth2',
|
||||
password: 'fake-token',
|
||||
});
|
||||
|
||||
expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath);
|
||||
expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git'));
|
||||
});
|
||||
|
||||
it(`clones the template from a sub directory if specified`, async () => {
|
||||
await preparer.prepare({
|
||||
url:
|
||||
'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3',
|
||||
logger,
|
||||
workspacePath,
|
||||
});
|
||||
expect(fs.move).toHaveBeenCalledWith(
|
||||
path.resolve(checkoutPath, '1', '2', '3'),
|
||||
templatePath,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 { Git } from '@backstage/backend-common';
|
||||
import { GitLabIntegrationConfig } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import path from 'path';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
|
||||
export class GitlabPreparer implements PreparerBase {
|
||||
static fromConfig(config: GitLabIntegrationConfig) {
|
||||
return new GitlabPreparer({ 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 ?? '',
|
||||
);
|
||||
parsedGitUrl.git_suffix = true;
|
||||
|
||||
const git = this.config.token
|
||||
? Git.fromAuth({
|
||||
password: this.config.token,
|
||||
username: 'oauth2',
|
||||
logger,
|
||||
})
|
||||
: Git.fromAuth({ logger });
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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.
|
||||
*/
|
||||
|
||||
export { AzurePreparer } from './azure';
|
||||
export { BitbucketPreparer } from './bitbucket';
|
||||
export { FilePreparer } from './file';
|
||||
export { GithubPreparer } from './github';
|
||||
export { GitlabPreparer } from './gitlab';
|
||||
export { Preparers } from './preparers';
|
||||
export type { PreparerBase, PreparerBuilder, PreparerOptions } from './types';
|
||||
@@ -1,50 +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 { GithubPreparer } from './github';
|
||||
import { Preparers } from './preparers';
|
||||
|
||||
describe('Preparers', () => {
|
||||
it('should return the correct preparer based on the hostname', async () => {
|
||||
const preparer = await GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'lols',
|
||||
token: 'something else yo',
|
||||
});
|
||||
|
||||
const preparers = new Preparers();
|
||||
preparers.register('github.com', preparer);
|
||||
|
||||
expect(
|
||||
preparers.get('https://github.com/please/find/me/something/from/github'),
|
||||
).toBe(preparer);
|
||||
});
|
||||
|
||||
it('should throw an error if there is nothing that will match the url provided', async () => {
|
||||
const preparer = await GithubPreparer.fromConfig({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'lols',
|
||||
token: 'something else yo',
|
||||
});
|
||||
|
||||
const preparers = new Preparers();
|
||||
preparers.register('github.com', preparer);
|
||||
|
||||
expect(() => preparers.get('https://404.com')).toThrow(
|
||||
`Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,81 +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 { Config } from '@backstage/config';
|
||||
import { PreparerBase, PreparerBuilder } from './types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { GitlabPreparer } from './gitlab';
|
||||
import { AzurePreparer } from './azure';
|
||||
import { GithubPreparer } from './github';
|
||||
import { BitbucketPreparer } from './bitbucket';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
export class Preparers implements PreparerBuilder {
|
||||
private preparerMap = new Map<string, PreparerBase>();
|
||||
|
||||
register(host: string, preparer: PreparerBase) {
|
||||
this.preparerMap.set(host, preparer);
|
||||
}
|
||||
|
||||
get(url: string): PreparerBase {
|
||||
const preparer = this.preparerMap.get(new URL(url).host);
|
||||
if (!preparer) {
|
||||
throw new Error(
|
||||
`Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`,
|
||||
);
|
||||
}
|
||||
return preparer;
|
||||
}
|
||||
|
||||
static async fromConfig(
|
||||
config: Config,
|
||||
// eslint-disable-next-line
|
||||
_: { logger: Logger },
|
||||
): Promise<PreparerBuilder> {
|
||||
const preparers = new Preparers();
|
||||
const scm = ScmIntegrations.fromConfig(config);
|
||||
for (const integration of scm.azure.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
AzurePreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
for (const integration of scm.github.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
GithubPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
for (const integration of scm.gitlab.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
GitlabPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
for (const integration of scm.bitbucket.list()) {
|
||||
preparers.register(
|
||||
integration.config.host,
|
||||
BitbucketPreparer.fromConfig(integration.config),
|
||||
);
|
||||
}
|
||||
|
||||
return preparers;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +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 { Logger } from 'winston';
|
||||
|
||||
export type PreparerOptions = {
|
||||
/**
|
||||
* Full URL to the directory containg template data
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The workspace path that will eventually be the the root of the new repo
|
||||
*/
|
||||
workspacePath: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export interface PreparerBase {
|
||||
/**
|
||||
* Prepare a directory with contents from the remote location
|
||||
*/
|
||||
prepare(opts: PreparerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export type PreparerBuilder = {
|
||||
register(host: string, preparer: PreparerBase): void;
|
||||
get(url: string): PreparerBase;
|
||||
};
|
||||
@@ -1,89 +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.
|
||||
*/
|
||||
jest.mock('./helpers');
|
||||
|
||||
jest.mock('azure-devops-node-api', () => ({
|
||||
WebApi: jest.fn(),
|
||||
getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}),
|
||||
}));
|
||||
|
||||
import os from 'os';
|
||||
import { resolve } from 'path';
|
||||
import { AzurePublisher } from './azure';
|
||||
import { WebApi } from 'azure-devops-node-api';
|
||||
import * as helpers from './helpers';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
describe('Azure Publisher', () => {
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
const resultPath = resolve(workspacePath, 'result');
|
||||
|
||||
describe('publish: createRemoteInAzure', () => {
|
||||
it('should use azure-devops-node-api to create a repo in the given project', async () => {
|
||||
const mockGitClient = {
|
||||
createRepository: jest.fn(),
|
||||
};
|
||||
const mockGitApi = {
|
||||
getGitApi: jest.fn().mockReturnValue(mockGitClient),
|
||||
};
|
||||
|
||||
((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi);
|
||||
|
||||
const publisher = await AzurePublisher.fromConfig({
|
||||
host: 'dev.azure.com',
|
||||
token: 'fake-azure-token',
|
||||
});
|
||||
|
||||
mockGitClient.createRepository.mockResolvedValue({
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
} as { remoteUrl: string });
|
||||
|
||||
const result = await publisher!.publish({
|
||||
values: {
|
||||
storePath: 'https://dev.azure.com/organisation/project/_git/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
workspacePath,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(WebApi).toHaveBeenCalledWith(
|
||||
'https://dev.azure.com/organisation',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
catalogInfoUrl:
|
||||
'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml',
|
||||
});
|
||||
expect(mockGitClient.createRepository).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'repo',
|
||||
},
|
||||
'project',
|
||||
);
|
||||
expect(helpers.initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: resultPath,
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
auth: { username: 'notempty', password: 'fake-azure-token' },
|
||||
logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { IGitApi } from 'azure-devops-node-api/GitApi';
|
||||
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { AzureIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
import path from 'path';
|
||||
|
||||
export class AzurePublisher implements PublisherBase {
|
||||
static async fromConfig(config: AzureIntegrationConfig) {
|
||||
if (!config.token) {
|
||||
return undefined;
|
||||
}
|
||||
return new AzurePublisher({ token: config.token });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token: string }) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
workspacePath,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const { owner, name, organization, resource } = parseGitUrl(
|
||||
values.storePath,
|
||||
);
|
||||
const authHandler = getPersonalAccessTokenHandler(this.config.token);
|
||||
const webApi = new WebApi(
|
||||
`https://${resource}/${organization}`,
|
||||
authHandler,
|
||||
);
|
||||
const client = await webApi.getGitApi();
|
||||
|
||||
const remoteUrl = await this.createRemote({
|
||||
project: owner,
|
||||
name,
|
||||
client,
|
||||
});
|
||||
|
||||
const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`;
|
||||
|
||||
await initRepoAndPush({
|
||||
dir: path.join(workspacePath, 'result'),
|
||||
remoteUrl,
|
||||
auth: {
|
||||
username: 'notempty',
|
||||
password: this.config.token,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
|
||||
private async createRemote(opts: {
|
||||
name: string;
|
||||
project: string;
|
||||
client: IGitApi;
|
||||
}) {
|
||||
const { name, project, client } = opts;
|
||||
const createOptions: GitRepositoryCreateOptions = { name };
|
||||
const repo = await client.createRepository(createOptions, project);
|
||||
|
||||
return repo.remoteUrl || '';
|
||||
}
|
||||
}
|
||||
@@ -1,228 +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.
|
||||
*/
|
||||
|
||||
jest.mock('./helpers');
|
||||
|
||||
import os from 'os';
|
||||
import { resolve } from 'path';
|
||||
import { BitbucketPublisher } from './bitbucket';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
describe('Bitbucket Publisher', () => {
|
||||
const logger = getVoidLogger();
|
||||
const server = setupServer();
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
const resultPath = resolve(workspacePath, 'result');
|
||||
|
||||
describe('publish: createRemoteInBitbucketCloud', () => {
|
||||
it('should create repo in bitbucket cloud', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
'https://api.bitbucket.org/2.0/repositories/project/repo',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json({
|
||||
links: {
|
||||
html: {
|
||||
href: 'https://bitbucket.org/project/repo',
|
||||
},
|
||||
clone: [
|
||||
{
|
||||
name: 'https',
|
||||
href: 'https://bitbucket.org/project/repo',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const publisher = await BitbucketPublisher.fromConfig(
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
username: 'fake-user',
|
||||
appPassword: 'fake-token',
|
||||
},
|
||||
{ repoVisibility: 'private' },
|
||||
);
|
||||
|
||||
const result = await publisher.publish({
|
||||
values: {
|
||||
storePath: 'https://bitbucket.org/project/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
workspacePath,
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'https://bitbucket.org/project/repo',
|
||||
catalogInfoUrl:
|
||||
'https://bitbucket.org/project/repo/src/master/catalog-info.yaml',
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: resultPath,
|
||||
remoteUrl: 'https://bitbucket.org/project/repo',
|
||||
auth: { username: 'fake-user', password: 'fake-token' },
|
||||
logger: logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInBitbucketServer', () => {
|
||||
it('should throw an error if no username present', async () => {
|
||||
await expect(
|
||||
BitbucketPublisher.fromConfig(
|
||||
{
|
||||
host: 'bitbucket.mycompany.com',
|
||||
token: 'fake-token',
|
||||
},
|
||||
{ repoVisibility: 'private' },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Bitbucket server requires the username to be set in your config',
|
||||
);
|
||||
});
|
||||
it('should create repo in bitbucket server', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
'https://bitbucket.mycompany.com/rest/api/1.0/projects/project/repos',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json({
|
||||
links: {
|
||||
self: [
|
||||
{
|
||||
href:
|
||||
'https://bitbucket.mycompany.com/projects/project/repos/repo',
|
||||
},
|
||||
],
|
||||
clone: [
|
||||
{
|
||||
name: 'http',
|
||||
href: 'https://bitbucket.mycompany.com/scm/project/repo',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const publisher = await BitbucketPublisher.fromConfig(
|
||||
{
|
||||
host: 'bitbucket.mycompany.com',
|
||||
username: 'foo',
|
||||
token: 'fake-token',
|
||||
},
|
||||
{ repoVisibility: 'private' },
|
||||
);
|
||||
|
||||
const result = await publisher.publish({
|
||||
values: {
|
||||
storePath: 'https://bitbucket.mycompany.com/project/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
workspacePath,
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
|
||||
catalogInfoUrl:
|
||||
'https://bitbucket.mycompany.com/projects/project/repos/repo/catalog-info.yaml',
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: resultPath,
|
||||
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
|
||||
auth: { username: 'foo', password: 'fake-token' },
|
||||
logger: logger,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should use apiBaseUrl to create the repository if it is set', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0/projects/project/repos',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json({
|
||||
links: {
|
||||
self: [
|
||||
{
|
||||
href:
|
||||
'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo',
|
||||
},
|
||||
],
|
||||
clone: [
|
||||
{
|
||||
name: 'http',
|
||||
href:
|
||||
'https://bitbucket.mycompany.com/bitbucket/scm/project/repo',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const publisher = await BitbucketPublisher.fromConfig(
|
||||
{
|
||||
host: 'bitbucket.mycompany.com',
|
||||
username: 'foo',
|
||||
token: 'fake-token',
|
||||
apiBaseUrl: 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0',
|
||||
},
|
||||
{ repoVisibility: 'private' },
|
||||
);
|
||||
|
||||
const result = await publisher.publish({
|
||||
values: {
|
||||
storePath: 'https://bitbucket.mycompany.com/project/repo',
|
||||
owner: 'bob',
|
||||
},
|
||||
workspacePath,
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
remoteUrl: 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo',
|
||||
catalogInfoUrl:
|
||||
'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo/catalog-info.yaml',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +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 { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import fetch from 'cross-fetch';
|
||||
import { BitbucketIntegrationConfig } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import path from 'path';
|
||||
|
||||
export type RepoVisibilityOptions = 'private' | 'public';
|
||||
|
||||
// TODO(blam): We should probably start to use a bitbucket client here that we can change
|
||||
// the baseURL to point at on-prem or public bitbucket versions like we do for
|
||||
// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using
|
||||
// a supported bitbucket client if one exists.
|
||||
export class BitbucketPublisher implements PublisherBase {
|
||||
static async fromConfig(
|
||||
config: BitbucketIntegrationConfig,
|
||||
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
|
||||
) {
|
||||
if (config.host !== 'bitbucket.org' && !config.username)
|
||||
throw new Error(
|
||||
'Bitbucket server requires the username to be set in your config',
|
||||
);
|
||||
|
||||
return new BitbucketPublisher({
|
||||
host: config.host,
|
||||
token: config.token,
|
||||
appPassword: config.appPassword,
|
||||
username: config.username,
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
repoVisibility,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: {
|
||||
host: string;
|
||||
token?: string;
|
||||
appPassword?: string;
|
||||
username?: string;
|
||||
apiBaseUrl?: string;
|
||||
repoVisibility: RepoVisibilityOptions;
|
||||
},
|
||||
) {}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
workspacePath,
|
||||
logger,
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const { owner: project, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const description = values.description as string;
|
||||
const result = await this.createRemote({
|
||||
project,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
await initRepoAndPush({
|
||||
dir: path.join(workspacePath, 'result'),
|
||||
remoteUrl: result.remoteUrl,
|
||||
auth: {
|
||||
username: this.config.username ? this.config.username : 'x-token-auth',
|
||||
password: this.config.appPassword
|
||||
? this.config.appPassword
|
||||
: this.config.token ?? '',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createRemote(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
if (this.config.host === 'bitbucket.org') {
|
||||
return this.createBitbucketCloudRepository(opts);
|
||||
}
|
||||
return this.createBitbucketServerRepository(opts);
|
||||
}
|
||||
|
||||
private async createBitbucketCloudRepository(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
const { project, name, description } = opts;
|
||||
|
||||
let response: Response;
|
||||
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
scm: 'git',
|
||||
description: description,
|
||||
is_private: this.config.repoVisibility === 'private',
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
try {
|
||||
response = await fetch(
|
||||
`https://api.bitbucket.org/2.0/repositories/${project}/${name}`,
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to create repository, ${e}`);
|
||||
}
|
||||
if (response.status === 200) {
|
||||
const r = await response.json();
|
||||
let remoteUrl = '';
|
||||
for (const link of r.links.clone) {
|
||||
if (link.name === 'https') {
|
||||
remoteUrl = link.href;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO use the urlReader to get the default branch
|
||||
const catalogInfoUrl = `${r.links.html.href}/src/master/catalog-info.yaml`;
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
throw new Error(`Not a valid response code ${await response.text()}`);
|
||||
}
|
||||
|
||||
private getAuthorizationHeader(): string {
|
||||
if (this.config.username && this.config.appPassword) {
|
||||
const buffer = Buffer.from(
|
||||
`${this.config.username}:${this.config.appPassword}`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return `Basic ${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
if (this.config.token) {
|
||||
return `Bearer ${this.config.token}`;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createBitbucketServerRepository(opts: {
|
||||
project: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<PublisherResult> {
|
||||
const { project, name, description } = opts;
|
||||
|
||||
let response: Response;
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
description: description,
|
||||
public: this.config.repoVisibility === 'public',
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const baseUrl = this.config.apiBaseUrl
|
||||
? this.config.apiBaseUrl
|
||||
: `https://${this.config.host}/rest/api/1.0`;
|
||||
|
||||
response = await fetch(`${baseUrl}/projects/${project}/repos`, options);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to create repository, ${e}`);
|
||||
}
|
||||
if (response.status === 201) {
|
||||
const r = await response.json();
|
||||
let remoteUrl = '';
|
||||
for (const link of r.links.clone) {
|
||||
if (link.name === 'http') {
|
||||
remoteUrl = link.href;
|
||||
}
|
||||
}
|
||||
const catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`;
|
||||
return { remoteUrl, catalogInfoUrl };
|
||||
}
|
||||
throw new Error(`Not a valid response code ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user