diff --git a/.changeset/fresh-elephants-tease.md b/.changeset/fresh-elephants-tease.md new file mode 100644 index 0000000000..5141f96be7 --- /dev/null +++ b/.changeset/fresh-elephants-tease.md @@ -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 { + 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, + +``` diff --git a/.changeset/great-bears-work.md b/.changeset/great-bears-work.md new file mode 100644 index 0000000000..572cf1538d --- /dev/null +++ b/.changeset/great-bears-work.md @@ -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 }`. diff --git a/.changeset/happy-pugs-notice.md b/.changeset/happy-pugs-notice.md new file mode 100644 index 0000000000..4120018da9 --- /dev/null +++ b/.changeset/happy-pugs-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Change catalog page layout to use Grid components to improve responsiveness diff --git a/.changeset/healthy-colts-watch.md b/.changeset/healthy-colts-watch.md new file mode 100644 index 0000000000..2b24621f20 --- /dev/null +++ b/.changeset/healthy-colts-watch.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-test-utils': patch +'@backstage/create-app': patch +'@backstage/plugin-catalog-backend': patch +--- + +bump sqlite3 to 5.0.1 diff --git a/.changeset/large-mice-sin.md b/.changeset/large-mice-sin.md new file mode 100644 index 0000000000..77aba8d3c5 --- /dev/null +++ b/.changeset/large-mice-sin.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': minor +--- + +Add extension points to the `LdapOrgReaderProcessor` to make it possible to do more advanced modifications +of the ingested users and groups. diff --git a/.changeset/late-pants-itch.md b/.changeset/late-pants-itch.md new file mode 100644 index 0000000000..7bd3087316 --- /dev/null +++ b/.changeset/late-pants-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Show scroll bar of the sidebar wrapper only on hover diff --git a/.changeset/moody-garlics-whisper.md b/.changeset/moody-garlics-whisper.md new file mode 100644 index 0000000000..b9683e4637 --- /dev/null +++ b/.changeset/moody-garlics-whisper.md @@ -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 +``` diff --git a/.changeset/red-ladybugs-promise.md b/.changeset/red-ladybugs-promise.md new file mode 100644 index 0000000000..dbfbf2bbd4 --- /dev/null +++ b/.changeset/red-ladybugs-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +add support for uiSchema on dependent form fields diff --git a/.changeset/shaggy-ties-carry.md b/.changeset/shaggy-ties-carry.md new file mode 100644 index 0000000000..83b7782cd0 --- /dev/null +++ b/.changeset/shaggy-ties-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Handle empty code blocks in markdown files so they don't fail rendering diff --git a/.changeset/sweet-terms-approve.md b/.changeset/sweet-terms-approve.md new file mode 100644 index 0000000000..e8daa06673 --- /dev/null +++ b/.changeset/sweet-terms-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +bump azure-devops-node to 10.2.2 diff --git a/.changeset/tasty-chairs-flash.md b/.changeset/tasty-chairs-flash.md new file mode 100644 index 0000000000..8be48d92b3 --- /dev/null +++ b/.changeset/tasty-chairs-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Pass through the `idToken` in `Authorization` Header for `listActions` request diff --git a/.changeset/twelve-deers-explode.md b/.changeset/twelve-deers-explode.md new file mode 100644 index 0000000000..0d34392eab --- /dev/null +++ b/.changeset/twelve-deers-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Export the `fetchContents` from scaffolder-backend diff --git a/app-config.yaml b/app-config.yaml index e3ab12587e..b50de963ed 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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' diff --git a/cypress/src/integration/catalog.ts b/cypress/src/integration/catalog.ts index 1db2eb8a13..20dcdd9b8a 100644 --- a/cypress/src/integration/catalog.ts +++ b/cypress/src/integration/catalog.ts @@ -23,7 +23,7 @@ describe('Catalog', () => { cy.visit('/catalog'); - cy.contains('Owned (8)').should('be.visible'); + cy.contains('Owned (10)').should('be.visible'); }); }); }); diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.ts index 3e3a10aca2..4703c18881 100644 --- a/cypress/src/integration/integrations.ts +++ b/cypress/src/integration/integrations.ts @@ -27,8 +27,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'github-repo'); cy.get('table').should('contain', 'github-repo-nested'); }); @@ -52,8 +54,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'gitlab-repo'); cy.get('table').should('contain', 'gitlab-repo-nested'); }); @@ -67,8 +71,10 @@ describe('Integrations', () => { type: 'url', }); + cy.wait(5000); + cy.visit('/catalog'); - cy.contains('All').click(); + cy.get('[data-testid="user-picker-all"]').click(); cy.get('table').should('contain', 'bitbucket-repo'); cy.get('table').should('contain', 'bitbucket-repo-nested'); }); diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 836601971c..d1438aba8b 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -122,8 +122,8 @@ The DN under which users are stored, e.g. #### users.options The search options to use when sending the query to the server, when reading all -users. All of the options are shown below, with their default values, but they -are all optional. +users. All the options are shown below, with their default values, but they are +all optional. ```yaml options: @@ -158,8 +158,8 @@ set: Mappings from well known entity fields, to LDAP attribute names. This is where you are able to define how to interpret the attributes of each LDAP result item, -and to move them into the corresponding entity fields. All of the options are -shown below, with their default values, but they are all optional. +and to move them into the corresponding entity fields. All the options are shown +below, with their default values, but they are all optional. If you leave out an optional mapping, it will still be copied using that default value. For example, even if you do not put in the field `displayName` in your @@ -204,8 +204,8 @@ The DN under which groups are stored, e.g. #### groups.options The search options to use when sending the query to the server, when reading all -groups. All of the options are shown below, with their default values, but they -are all optional. +groups. All the options are shown below, with their default values, but they are +all optional. ```yaml options: @@ -282,3 +282,35 @@ map: # the spec.children field of the entity. members: member ``` + +## Customize the Processor + +In case you want to customize the ingested entities, the +`LdapOrgReaderProcessor` allows to pass transformers for users and groups. + +1. Create a transformer: + +```ts +export async function myGroupTransformer( + vendor: LdapVendor, + config: GroupConfig, + group: SearchEntry, +): Promise { + // Transformations may change namespace, change entity naming pattern, fill + // profile with more or other details... + + // Create the group entity on your own, or wrap the default transformer + return await defaultGroupTransformer(vendor, config, group); +} +``` + +2. Configure the processor with the transformer: + +```ts +builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(config, { + logger, + groupTransformer: myGroupTransformer, + }), +); +``` diff --git a/microsite/data/plugins/scaffolder-backend-module-rails.yaml b/microsite/data/plugins/scaffolder-backend-module-rails.yaml new file mode 100644 index 0000000000..5f5a29cf3c --- /dev/null +++ b/microsite/data/plugins/scaffolder-backend-module-rails.yaml @@ -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' diff --git a/microsite/static/img/rails-icon.png b/microsite/static/img/rails-icon.png new file mode 100644 index 0000000000..f7b8b69bd9 Binary files /dev/null and b/microsite/static/img/rails-icon.png differ diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index a21aaef4a6..22eac06ed2 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -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; diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 3079041c7f..65852c3a4f 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -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" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index a2c5906696..c3e693865f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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": { diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 09b3732301..619134a990 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -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 { 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, diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 68644bef87..5d055566be 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -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) diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts deleted file mode 100644 index f03519a3c6..0000000000 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ /dev/null @@ -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/); - }); -}); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts deleted file mode 100644 index bdc6f35df2..0000000000 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ /dev/null @@ -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, -); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index ccdf0051db..be9f7a0d6a 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -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'; diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json deleted file mode 100644 index 5a0c0efa73..0000000000 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ /dev/null @@ -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 - } - } - } - } - } - ] -} diff --git a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 39cce709c0..d477494964 100644 --- a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -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) => { /> diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx index 2167da6fd2..296df22ecd 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx @@ -65,8 +65,8 @@ type Props = { }; const renderers = { - code: ({ language, value }: { language: string; value: string }) => { - return ; + code: ({ language, value }: { language: string; value?: string }) => { + return ; }, }; diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index e53cdfaa57..28abbce53c 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -21,8 +21,10 @@ import { makeStyles, styled, TextField, + Theme, Typography, } from '@material-ui/core'; +import { CreateCSSProperties } from '@material-ui/core/styles/withStyles'; import SearchIcon from '@material-ui/icons/Search'; import clsx from 'clsx'; import React, { @@ -291,14 +293,8 @@ export const SidebarDivider = styled('hr')({ margin: '12px 0px', }); -export const SidebarScrollWrapper = styled('div')(({ theme }) => ({ - flex: '0 1 auto', - overflowX: 'hidden', - // 5px space to the right of the scrollbar - width: 'calc(100% - 5px)', - // Display at least one item in the container - // Question: Can this be a config/theme variable - if so, which? :/ - minHeight: '48px', +const styledScrollbar = (theme: Theme): CreateCSSProperties => ({ + overflowY: 'auto', '&::-webkit-scrollbar': { backgroundColor: theme.palette.background.default, width: '5px', @@ -308,4 +304,20 @@ export const SidebarScrollWrapper = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.text.hint, borderRadius: '5px', }, -})); +}); + +export const SidebarScrollWrapper = styled('div')(({ theme }) => { + const scrollbarStyles = styledScrollbar(theme); + return { + flex: '0 1 auto', + overflowX: 'hidden', + // 5px space to the right of the scrollbar + width: 'calc(100% - 5px)', + // Display at least one item in the container + // Question: Can this be a config/theme variable - if so, which? :/ + minHeight: '48px', + overflowY: 'hidden', + '@media (hover: none)': scrollbarStyles, + '&:hover': scrollbarStyles, + }; +}); diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 8f5a116de4..44af516099 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -40,7 +40,7 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.1", {{/if}} "winston": "^3.2.1" }, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 333ffa11df..a0201cec01 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -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, diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 3fff030eee..d25f55bf3a 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -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; +export const checkoutGitRepository: (repoUrl: string, config: Config, logger: Logger_2) => Promise; // @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; } @@ -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; // (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; // @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; +export const getLastCommitTimestamp: (repositoryLocation: string, logger: Logger_2) => Promise; // @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; }; @@ -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; diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index 4f59102265..93aba5596f 100644 --- a/plugins/app-backend/api-report.md +++ b/plugins/app-backend/api-report.md @@ -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; @@ -18,7 +18,7 @@ export interface RouterOptions { config: Config; disableConfigInjection?: boolean; // (undocumented) - logger: Logger; + logger: Logger_2; staticFallbackHandler?: express.Handler; } diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 6ae878accf..9f9a8d18ef 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -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; } diff --git a/plugins/catalog-backend-module-ldap/api-report.md b/plugins/catalog-backend-module-ldap/api-report.md index d5e9d731ea..446dc2df52 100644 --- a/plugins/catalog-backend-module-ldap/api-report.md +++ b/plugins/catalog-backend-module-ldap/api-report.md @@ -11,11 +11,20 @@ import { Config } from '@backstage/config'; import { GroupEntity } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; import { LocationSpec } from '@backstage/catalog-model'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { SearchEntry } from 'ldapjs'; import { SearchOptions } from 'ldapjs'; import { UserEntity } from '@backstage/catalog-model'; +// @public (undocumented) +export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise; + +// @public (undocumented) +export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise; + +// @public +export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise; + // @public export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn"; @@ -29,7 +38,7 @@ export const LDAP_UUID_ANNOTATION = "backstage.io/ldap-uuid"; export class LdapClient { constructor(client: Client); // (undocumented) - static create(logger: Logger, target: string, bind?: BindConfig): Promise; + static create(logger: Logger_2, target: string, bind?: BindConfig): Promise; getRootDSE(): Promise; getVendor(): Promise; search(dn: string, options: SearchOptions): Promise; @@ -39,15 +48,19 @@ export class LdapClient { export class LdapOrgReaderProcessor implements CatalogProcessor { constructor(options: { providers: LdapProviderConfig[]; - logger: Logger; + logger: Logger_2; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; }); // (undocumented) static fromConfig(config: Config, options: { - logger: Logger; + logger: Logger_2; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; }): LdapOrgReaderProcessor; // (undocumented) readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise; -} + } // @public export type LdapProviderConfig = { @@ -57,15 +70,25 @@ export type LdapProviderConfig = { groups: GroupConfig; }; +// @public +export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void; + // @public export function readLdapConfig(config: Config): LdapProviderConfig[]; // @public -export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{ +export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: { + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + logger: Logger_2; +}): Promise<{ users: UserEntity[]; groups: GroupEntity[]; }>; +// @public +export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise; + // (No @packageDocumentation comment for this package) diff --git a/plugins/catalog-backend-module-ldap/src/ldap/index.ts b/plugins/catalog-backend-module-ldap/src/ldap/index.ts index 194a75ac60..a9f127a3a7 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/index.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/index.ts @@ -15,6 +15,7 @@ */ export { LdapClient } from './client'; +export { mapStringAttr } from './util'; export { readLdapConfig } from './config'; export type { LdapProviderConfig } from './config'; export { @@ -22,4 +23,9 @@ export { LDAP_RDN_ANNOTATION, LDAP_UUID_ANNOTATION, } from './constants'; -export { readLdapOrg } from './read'; +export { + defaultGroupTransformer, + defaultUserTransformer, + readLdapOrg, +} from './read'; +export type { GroupTransformer, UserTransformer } from './types'; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.ts index a662b36d87..f3ee09d32d 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -26,74 +26,97 @@ import { LDAP_UUID_ANNOTATION, } from './constants'; import { LdapVendor } from './vendors'; +import { Logger } from 'winston'; +import { GroupTransformer, UserTransformer } from './types'; +import { mapStringAttr } from './util'; + +export async function defaultUserTransformer( + vendor: LdapVendor, + config: UserConfig, + entry: SearchEntry, +): Promise { + const { set, map } = config; + + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: '', + annotations: {}, + }, + spec: { + profile: {}, + memberOf: [], + }, + }; + + if (set) { + for (const [path, value] of Object.entries(set)) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(entry, vendor, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(entry, vendor, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(entry, vendor, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(entry, vendor, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(entry, vendor, map.picture, v => { + entity.spec.profile!.picture = v; + }); + + return entity; +} /** * Reads users out of an LDAP provider. * * @param client The LDAP client * @param config The user data configuration + * @param opts */ export async function readLdapUsers( client: LdapClient, config: UserConfig, + opts?: { transformer?: UserTransformer }, ): Promise<{ users: UserEntity[]; // With all relations empty userMemberOf: Map>; // DN -> DN or UUID of groups }> { - const { dn, options, set, map } = config; + const { dn, options, map } = config; const vendor = await client.getVendor(); - const entries = await client.search(dn, options); - const entities: UserEntity[] = []; const userMemberOf: Map> = new Map(); - for (const entry of entries) { - const entity: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: '', - annotations: {}, - }, - spec: { - profile: {}, - memberOf: [], - }, - }; + const transformer = opts?.transformer ?? defaultUserTransformer; - if (set) { - for (const [path, value] of Object.entries(set)) { - lodashSet(entity, path, value); - } + const entries = await client.search(dn, options); + + for (const user of entries) { + const entity = await transformer(vendor, config, user); + + if (!entity) { + continue; } - mapStringAttr(entry, vendor, map.name, v => { - entity.metadata.name = v; - }); - mapStringAttr(entry, vendor, map.description, v => { - entity.metadata.description = v; - }); - mapStringAttr(entry, vendor, map.rdn, v => { - entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { - entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { - entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, map.displayName, v => { - entity.spec.profile!.displayName = v; - }); - mapStringAttr(entry, vendor, map.email, v => { - entity.spec.profile!.email = v; - }); - mapStringAttr(entry, vendor, map.picture, v => { - entity.spec.profile!.picture = v; - }); - - mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { + mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { ensureItems(userMemberOf, myDn, vs); }); @@ -103,82 +126,103 @@ export async function readLdapUsers( return { users: entities, userMemberOf }; } +export async function defaultGroupTransformer( + vendor: LdapVendor, + config: GroupConfig, + entry: SearchEntry, +): Promise { + const { set, map } = config; + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: '', + annotations: {}, + }, + spec: { + type: 'unknown', + profile: {}, + children: [], + }, + }; + + if (set) { + for (const [path, value] of Object.entries(set)) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(entry, vendor, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(entry, vendor, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(entry, vendor, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(entry, vendor, map.type, v => { + entity.spec.type = v; + }); + mapStringAttr(entry, vendor, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(entry, vendor, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(entry, vendor, map.picture, v => { + entity.spec.profile!.picture = v; + }); + + return entity; +} + /** * Reads groups out of an LDAP provider. * * @param client The LDAP client * @param config The group data configuration + * @param opts */ export async function readLdapGroups( client: LdapClient, config: GroupConfig, + opts?: { + transformer?: GroupTransformer; + }, ): Promise<{ groups: GroupEntity[]; // With all relations empty groupMemberOf: Map>; // DN -> DN or UUID of groups groupMember: Map>; // DN -> DN or UUID of groups & users }> { - const { dn, options, set, map } = config; - const vendor = await client.getVendor(); - - const entries = await client.search(dn, options); - const groups: GroupEntity[] = []; const groupMemberOf: Map> = new Map(); const groupMember: Map> = new Map(); - for (const entry of entries) { - const entity: GroupEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: '', - annotations: {}, - }, - spec: { - type: 'unknown', - profile: {}, - children: [], - }, - }; + const { dn, map, options } = config; + const vendor = await client.getVendor(); - if (set) { - for (const [path, value] of Object.entries(set)) { - lodashSet(entity, path, value); - } + const transformer = opts?.transformer ?? defaultGroupTransformer; + + const entries = await client.search(dn, options); + + for (const group of entries) { + const entity = await transformer(vendor, config, group); + + if (!entity) { + continue; } - mapStringAttr(entry, vendor, map.name, v => { - entity.metadata.name = v; - }); - mapStringAttr(entry, vendor, map.description, v => { - entity.metadata.description = v; - }); - mapStringAttr(entry, vendor, map.rdn, v => { - entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => { - entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, vendor.dnAttributeName, v => { - entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; - }); - mapStringAttr(entry, vendor, map.type, v => { - entity.spec.type = v; - }); - mapStringAttr(entry, vendor, map.displayName, v => { - entity.spec.profile!.displayName = v; - }); - mapStringAttr(entry, vendor, map.email, v => { - entity.spec.profile!.email = v; - }); - mapStringAttr(entry, vendor, map.picture, v => { - entity.spec.profile!.picture = v; - }); - - mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { + mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); }); - mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { + mapReferencesAttr(group, vendor, map.members, (myDn, vs) => { ensureItems(groupMember, myDn, vs); }); @@ -199,22 +243,30 @@ export async function readLdapGroups( * with all relations etc filled in. * * @param client The LDAP client - * @param logger A logger instance * @param userConfig The user data configuration * @param groupConfig The group data configuration + * @param options */ export async function readLdapOrg( client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, + options: { + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + logger: Logger; + }, ): Promise<{ users: UserEntity[]; groups: GroupEntity[]; }> { - const { users, userMemberOf } = await readLdapUsers(client, userConfig); + const { users, userMemberOf } = await readLdapUsers(client, userConfig, { + transformer: options?.userTransformer, + }); const { groups, groupMemberOf, groupMember } = await readLdapGroups( client, groupConfig, + { transformer: options?.groupTransformer }, ); resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember); @@ -228,21 +280,6 @@ export async function readLdapOrg( // Helpers // -// Maps a single-valued attribute to a consumer -function mapStringAttr( - entry: SearchEntry, - vendor: LdapVendor, - attributeName: string | undefined, - setter: (value: string) => void, -) { - if (attributeName) { - const values = vendor.decodeStringAttribute(entry, attributeName); - if (values && values.length === 1) { - setter(values[0]); - } - } -} - // Maps a multi-valued attribute of references to other objects, to a consumer function mapReferencesAttr( entry: SearchEntry, diff --git a/plugins/catalog-backend-module-ldap/src/ldap/types.ts b/plugins/catalog-backend-module-ldap/src/ldap/types.ts new file mode 100644 index 0000000000..e32e4c5b42 --- /dev/null +++ b/plugins/catalog-backend-module-ldap/src/ldap/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { SearchEntry } from 'ldapjs'; +import { LdapVendor } from './vendors'; +import { GroupConfig, UserConfig } from './config'; + +/** + * Customize the ingested User entity + * + * @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes + * @param config The User specific config used by the default transformer. + * @param user The found LDAP entry in its source format. This is the entry that you want to transform + * @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog + */ +export type UserTransformer = ( + vendor: LdapVendor, + config: UserConfig, + user: SearchEntry, +) => Promise; + +/** + * Customize the ingested Group entity + * + * @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes + * @param config The Group specific config used by the default transformer. + * @param group The found LDAP entry in its source format. This is the entry that you want to transform + * @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog + */ +export type GroupTransformer = ( + vendor: LdapVendor, + config: GroupConfig, + group: SearchEntry, +) => Promise; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/util.ts b/plugins/catalog-backend-module-ldap/src/ldap/util.ts index 74dd84cdc9..21ee40f52c 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/util.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/util.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Error as LDAPError } from 'ldapjs'; +import { Error as LDAPError, SearchEntry } from 'ldapjs'; +import { LdapVendor } from './vendors'; /** * Builds a string form of an LDAP Error structure. @@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs'; export function errorString(error: LDAPError) { return `${error.code} ${error.name}: ${error.message}`; } + +/** + * Maps a single-valued attribute to a consumer + * + * @param entry The LDAP source entry + * @param vendor The LDAP vendor + * @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored. + * @param setter The function to be called with the decoded attribute from the source entry + */ +export function mapStringAttr( + entry: SearchEntry, + vendor: LdapVendor, + attributeName: string | undefined, + setter: (value: string) => void, +) { + if (attributeName) { + const values = vendor.decodeStringAttribute(entry, attributeName); + if (values && values.length === 1) { + setter(values[0]); + } + } +} diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts index 79c0ed6964..f87afc38b9 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts @@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { + GroupTransformer, LdapClient, LdapProviderConfig, readLdapConfig, readLdapOrg, + UserTransformer, } from '../ldap'; import { CatalogProcessor, @@ -35,8 +37,17 @@ import { export class LdapOrgReaderProcessor implements CatalogProcessor { private readonly providers: LdapProviderConfig[]; private readonly logger: Logger; + private readonly groupTransformer?: GroupTransformer; + private readonly userTransformer?: UserTransformer; - static fromConfig(config: Config, options: { logger: Logger }) { + static fromConfig( + config: Config, + options: { + logger: Logger; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + }, + ) { const c = config.getOptionalConfig('catalog.processors.ldapOrg'); return new LdapOrgReaderProcessor({ ...options, @@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { }); } - constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) { + constructor(options: { + providers: LdapProviderConfig[]; + logger: Logger; + groupTransformer?: GroupTransformer; + userTransformer?: UserTransformer; + }) { this.providers = options.providers; this.logger = options.logger; + this.groupTransformer = options.groupTransformer; + this.userTransformer = options.userTransformer; } async readLocation( @@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { client, provider.users, provider.groups, + { + groupTransformer: this.groupTransformer, + userTransformer: this.userTransformer, + logger: this.logger, + }, ); const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index f1e7e22c35..c755514b48 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -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[]; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 2a8588c964..13cd430d01 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -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" }, diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index bdc6d463c2..33beffed0d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -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', }, diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index b8805ab9ba..bcabc00f32 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -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 }, diff --git a/plugins/catalog-graphql/api-report.md b/plugins/catalog-graphql/api-report.md index cbe325f4c5..40e8a19b66 100644 --- a/plugins/catalog-graphql/api-report.md +++ b/plugins/catalog-graphql/api-report.md @@ -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; @@ -16,7 +16,7 @@ export interface ModuleOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; } diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ba19d6036c..089e61f0fb 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { makeStyles } from '@material-ui/core'; +import { Grid } from '@material-ui/core'; import { EntityKindPicker, EntityLifecyclePicker, @@ -39,18 +39,6 @@ import { TableProps, } from '@backstage/core-components'; -const useStyles = makeStyles(theme => ({ - contentWrapper: { - display: 'grid', - gridTemplateAreas: "'filters' 'table'", - gridTemplateColumns: '250px 1fr', - gridColumnGap: theme.spacing(2), - }, - buttonSpacing: { - marginLeft: theme.spacing(2), - }, -})); - export type CatalogPageProps = { initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; @@ -61,30 +49,36 @@ export const CatalogPage = ({ initiallySelectedFilter = 'owned', columns, actions, -}: CatalogPageProps) => { - const styles = useStyles(); - - return ( - - - - - All your software catalog entities - -
- -
-
+}: CatalogPageProps) => ( + + + + + All your software catalog entities + + + + + + + + + + + + + + + + + + - -
-
-
- ); -}; + + + + + +); diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md index f8f1575d0b..186b76fb39 100644 --- a/plugins/code-coverage-backend/api-report.md +++ b/plugins/code-coverage-backend/api-report.md @@ -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; } diff --git a/plugins/graphql/api-report.md b/plugins/graphql/api-report.md index 8109c94122..ef172afbd7 100644 --- a/plugins/graphql/api-report.md +++ b/plugins/graphql/api-report.md @@ -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; @@ -16,7 +16,7 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) - logger: Logger; + logger: Logger_2; } diff --git a/plugins/kafka-backend/api-report.md b/plugins/kafka-backend/api-report.md index 47754ad572..6840bd7527 100644 --- a/plugins/kafka-backend/api-report.md +++ b/plugins/kafka-backend/api-report.md @@ -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; diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index fe43e8888a..a9389b7e62 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -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) diff --git a/plugins/proxy-backend/api-report.md b/plugins/proxy-backend/api-report.md index 1b1eba3b00..63469a2017 100644 --- a/plugins/proxy-backend/api-report.md +++ b/plugins/proxy-backend/api-report.md @@ -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) diff --git a/plugins/rollbar-backend/api-report.md b/plugins/rollbar-backend/api-report.md index fa8c67d4f0..9fa525cfc3 100644 --- a/plugins/rollbar-backend/api-report.md +++ b/plugins/rollbar-backend/api-report.md @@ -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; @@ -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; } diff --git a/plugins/scaffolder-backend-module-rails/.eslintrc.js b/plugins/scaffolder-backend-module-rails/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/scaffolder-backend-module-rails/README.md b/plugins/scaffolder-backend-module-rails/README.md new file mode 100644 index 0000000000..4ac52be941 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/README.md @@ -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 }}' +``` diff --git a/plugins/scaffolder-backend-module-rails/Rails.dockerfile b/plugins/scaffolder-backend-module-rails/Rails.dockerfile new file mode 100644 index 0000000000..0774fdc975 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/Rails.dockerfile @@ -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 diff --git a/plugins/scaffolder-backend-module-rails/api-report.md b/plugins/scaffolder-backend-module-rails/api-report.md new file mode 100644 index 0000000000..95d9c1bd9f --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/api-report.md @@ -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; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json new file mode 100644 index 0000000000..b513100f16 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -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" + ] +} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts similarity index 85% rename from plugins/scaffolder-backend/src/scaffolder/jobs/index.ts rename to plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts index 8ebffa2026..7d590c9a1a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts new file mode 100644 index 0000000000..6b98dd86c6 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts @@ -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/, + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts new file mode 100644 index 0000000000..e2dc8fd95a --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -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); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts new file mode 100644 index 0000000000..99a113f4f7 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts @@ -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) => { + // 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); + }, + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts new file mode 100644 index 0000000000..b1ddadc6d9 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts @@ -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; +}; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts new file mode 100644 index 0000000000..dc3f824236 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.test.ts @@ -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 = { + 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/); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts similarity index 52% rename from plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts rename to plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts index ef51fc20a9..3e18490587 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsNewRunner.ts @@ -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> { - 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 { - const templateDir = path.join(workspacePath, 'template'); + }: { + workspacePath: string; + values: JsonObject; + logStream: Writable; + }): Promise { 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); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/index.ts similarity index 79% rename from plugins/scaffolder-backend/src/scaffolder/stages/index.ts rename to plugins/scaffolder-backend-module-rails/src/actions/index.ts index c6f824a139..1b47db9e03 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts b/plugins/scaffolder-backend-module-rails/src/index.ts similarity index 76% rename from plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts rename to plugins/scaffolder-backend-module-rails/src/index.ts index 4d80f86946..4f06b14a86 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 1f3415cc23..cca05b3333 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -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 = { baseUrl?: string; - logger: Logger; + logger: Logger_2; logStream: Writable; token?: string | undefined; workspacePath: string; @@ -40,74 +32,12 @@ export type ActionContext = { createTemporaryDirectory(): Promise; }; -// @public (undocumented) -export class AzurePreparer implements PreparerBase { - constructor(config: { - token?: string; - }); - // (undocumented) - static fromConfig(config: AzureIntegrationConfig): AzurePreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @public (undocumented) -export class AzurePublisher implements PublisherBase { - constructor(config: { - token: string; - }); - // (undocumented) - static fromConfig(config: AzureIntegrationConfig): Promise; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @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; -} - -// @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; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - // @public export class CatalogEntityClient { constructor(catalogClient: CatalogApi); findTemplate(templateName: string, options?: { token?: string; - }): Promise; -} - -// @public (undocumented) -export class CookieCutter implements TemplaterBase { - constructor({ containerRunner }: { - containerRunner: ContainerRunner; - }); - // (undocumented) - run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; + }): Promise; } // @public (undocumented) @@ -115,7 +45,8 @@ export const createBuiltinActions: (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; + config: Config; }) => TemplateAction[]; // @public (undocumented) @@ -134,7 +65,7 @@ export function createDebugLogAction(): TemplateAction; export function createFetchCookiecutterAction(options: { reader: UrlReader; integrations: ScmIntegrations; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; }): TemplateAction; // @public (undocumented) @@ -155,17 +86,16 @@ export const createFilesystemDeleteAction: () => TemplateAction; // @public (undocumented) export const createFilesystemRenameAction: () => TemplateAction; -// @public (undocumented) -export function createLegacyActions(options: Options): TemplateAction[]; - // @public (undocumented) export function createPublishAzureAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public (undocumented) export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public @@ -174,6 +104,7 @@ export function createPublishFileAction(): TemplateAction; // @public (undocumented) export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; // @public (undocumented) @@ -182,17 +113,9 @@ export const createPublishGithubPullRequestAction: ({ integrations, clientFactor // @public (undocumented) export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; + config: Config; }): TemplateAction; -// @public (undocumented) -export class CreateReactAppTemplater implements TemplaterBase { - constructor({ containerRunner }: { - containerRunner: ContainerRunner; - }); - // (undocumented) - run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; -} - // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -202,204 +125,13 @@ export const createTemplateAction: >(templateAction: TemplateAction) => TemplateAction; // @public (undocumented) -export class FilePreparer implements PreparerBase { - // (undocumented) - prepare({ url, workspacePath }: PreparerOptions): Promise; -} - -// @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; -} - -// @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; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @public (undocumented) -export class GitlabPreparer implements PreparerBase { - constructor(config: { - token?: string; - }); - // (undocumented) - static fromConfig(config: GitLabIntegrationConfig): GitlabPreparer; - // (undocumented) - prepare({ url, workspacePath, logger }: PreparerOptions): Promise; -} - -// @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; - // (undocumented) - publish({ values, workspacePath, logger, }: PublisherOptions): Promise; -} - -// @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; - // (undocumented) - get(id: string): Job | undefined; - // (undocumented) - run(job: Job): Promise; - } - -// @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; -} - -// @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; - // (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; -}; - -// @public (undocumented) -export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; - -// @public -export type PublisherBase = { - publish(opts: PublisherOptions): Promise; -}; - -// @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; - // (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; // @public (undocumented) export interface RouterOptions { @@ -410,63 +142,20 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) + containerRunner: ContainerRunner; + // (undocumented) database: PluginDatabaseManager; // (undocumented) - logger: Logger; - // (undocumented) - preparers: PreparerBuilder; - // (undocumented) - publishers: PublisherBuilder; + logger: Logger_2; // (undocumented) reader: UrlReader; // (undocumented) taskWorkers?: number; - // (undocumented) - templaters: TemplaterBuilder; } // @public (undocumented) export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise; -// @public (undocumented) -export type RunCommandOptions = { - command: string; - args: string[]; - logStream?: Writable; -}; - -// @public (undocumented) -export type StageContext = { - values: TemplaterValues; - entity: TemplateEntityV1alpha1; - logger: Logger; - logStream: Writable; - workspacePath: string; -} & T; - -// @public (undocumented) -export interface StageInput { - // (undocumented) - handler(ctx: StageContext): Promise; - // (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 = { id: string; @@ -488,45 +177,6 @@ export class TemplateActionRegistry { register(action: TemplateAction): void; } -// @public (undocumented) -export type TemplaterBase = { - run(opts: TemplaterRunOptions): Promise; -}; - -// @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; - // (No @packageDocumentation comment for this package) diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 971c3036d6..795fe5b112 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -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; /** diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 534fcba169..081e048737 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -39,8 +39,7 @@ "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "@types/express": "^4.17.6", - "@types/git-url-parse": "^9.0.0", - "azure-devops-node-api": "^10.1.1", + "azure-devops-node-api": "^10.2.2", "command-exists": "^1.2.9", "compression": "^1.7.4", "cors": "^2.8.5", @@ -70,6 +69,7 @@ "@backstage/test-utils": "^0.1.14", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", + "@types/git-url-parse": "^9.0.0", "@types/mock-fs": "^4.13.0", "@types/nunjucks": "^3.1.4", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml new file mode 100644 index 0000000000..174c58741b --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/rails-demo/template.yaml @@ -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 }}' diff --git a/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb b/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb new file mode 100644 index 0000000000..90c0d53ddc --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/rails-demo/template/rails-template-file.rb @@ -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 diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 9f3a754485..430afa5b5d 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -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 { + ): Promise { 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) { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 71042129db..6541625035 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -14,14 +14,15 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { ContainerRunner, UrlReader } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ScmIntegrations } from '@backstage/integration'; -import { TemplaterBuilder } from '../../stages'; +import { Config } from '@backstage/config'; import { - createCatalogRegisterAction, createCatalogWriteAction, + createCatalogRegisterAction, } from './catalog'; + import { createDebugLogAction } from './debug'; import { createFetchCookiecutterAction, @@ -44,9 +45,16 @@ export const createBuiltinActions = (options: { reader: UrlReader; integrations: ScmIntegrations; catalogClient: CatalogApi; - templaters: TemplaterBuilder; + containerRunner: ContainerRunner; + config: Config; }) => { - const { reader, integrations, templaters, catalogClient } = options; + const { + reader, + integrations, + containerRunner, + catalogClient, + config, + } = options; return [ createFetchPlainAction({ @@ -56,7 +64,7 @@ export const createBuiltinActions = (options: { createFetchCookiecutterAction({ reader, integrations, - templaters, + containerRunner, }), createFetchTemplateAction({ integrations, @@ -64,18 +72,22 @@ export const createBuiltinActions = (options: { }), createPublishGithubAction({ integrations, + config, }), createPublishGithubPullRequestAction({ integrations, }), createPublishGitlabAction({ integrations, + config, }), createPublishBitbucketAction({ integrations, + config, }), createPublishAzureAction({ integrations, + config, }), createDebugLogAction(), createCatalogRegisterAction({ catalogClient, integrations }), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts index 3ce3052956..86f953a6a5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts @@ -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 = { + 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/, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts index d179270303..048b2bba64 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -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> { + 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 { + 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, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts index e06de4a2b5..fea32df39c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts @@ -17,3 +17,4 @@ export { createFetchPlainAction } from './plain'; export { createFetchCookiecutterAction } from './cookiecutter'; export { createFetchTemplateAction } from './template'; +export { fetchContents } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts similarity index 73% rename from plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index dff0feb5f2..e2459a8ca7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -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((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 { 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({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts index a5d5092327..849b056ac9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts @@ -20,3 +20,4 @@ export * from './debug'; export * from './fetch'; export * from './filesystem'; export * from './publish'; +export { runCommand } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index d3cb4ccafc..30995dee38 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -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' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index eb1b0522c6..c58dd69424 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -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); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts index 16b84737a7..9fe245935b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts @@ -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' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 295f4d1574..ffd2e4d6a7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -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') { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 910fa27b46..f0725d6e8e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -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' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 7dd790cb62..05301bda85 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -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 { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts index 67064efde7..5150205a08 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts @@ -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' }, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 57c7fdf752..b99d40b1f7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -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); diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 038873b42c..284e372b4c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts deleted file mode 100644 index e5d298163b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts deleted file mode 100644 index 9ffdb33f37..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts +++ /dev/null @@ -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) => { - 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, - }; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts deleted file mode 100644 index 2bb2171899..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts deleted file mode 100644 index ef603477c1..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ /dev/null @@ -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; - - 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(); - } - - 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 { - 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); - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts deleted file mode 100644 index 84509a6c0f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ /dev/null @@ -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 = { - 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 { - name: string; - handler(ctx: StageContext): Promise; -} - -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; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts deleted file mode 100644 index 8f21485f64..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ /dev/null @@ -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 ':', 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 ':', 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); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts deleted file mode 100644 index 8085277030..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ /dev/null @@ -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(/\/$/, ''); -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts deleted file mode 100644 index efbb241470..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts +++ /dev/null @@ -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); - } - }, - }), - ]; -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts deleted file mode 100644 index 74a21e0833..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ /dev/null @@ -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, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts deleted file mode 100644 index a405288f9f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ /dev/null @@ -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 - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts deleted file mode 100644 index 2c4e24fed0..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ /dev/null @@ -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', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts deleted file mode 100644 index 36f6b07750..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ /dev/null @@ -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; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts deleted file mode 100644 index 73d944db67..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ /dev/null @@ -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()}`, - ); - } - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts deleted file mode 100644 index 92ee93511c..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ /dev/null @@ -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, - }); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts deleted file mode 100644 index 0438e28f01..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ /dev/null @@ -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', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts deleted file mode 100644 index dec7fc06c2..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ /dev/null @@ -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 - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts deleted file mode 100644 index fa76c74bd7..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ /dev/null @@ -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: '', - token: 'fake-token', - apiBaseUrl: '', - baseUrl: '', - }); - - 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, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts deleted file mode 100644 index 582214606b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ /dev/null @@ -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 - } - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts deleted file mode 100644 index 060b99b3a8..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ /dev/null @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts deleted file mode 100644 index 20dfad924b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts +++ /dev/null @@ -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`, - ); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts deleted file mode 100644 index 98ca984c17..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ /dev/null @@ -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(); - - 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 { - 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; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts deleted file mode 100644 index 1b6b4380f7..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ /dev/null @@ -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; -} - -export type PreparerBuilder = { - register(host: string, preparer: PreparerBase): void; - get(url: string): PreparerBase; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts deleted file mode 100644 index 3eecab33b5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ /dev/null @@ -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, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts deleted file mode 100644 index 921c139a2e..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ /dev/null @@ -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 { - 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 || ''; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts deleted file mode 100644 index a3f02e2c89..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ /dev/null @@ -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', - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts deleted file mode 100644 index a85518eeae..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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()}`); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts deleted file mode 100644 index fd4256dc86..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ /dev/null @@ -1,315 +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('@octokit/rest'); -jest.mock('./helpers'); - -import os from 'os'; -import { resolve } from 'path'; - -import { getVoidLogger } from '@backstage/backend-common'; -import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; -import { GithubPublisher } from './github'; -import { initRepoAndPush } from './helpers'; - -const { mockGithubClient } = require('@octokit/rest') as { - mockGithubClient: { - repos: jest.Mocked; - users: jest.Mocked; - teams: jest.Mocked; - }; -}; - -describe('GitHub Publisher', () => { - const logger = getVoidLogger(); - beforeEach(() => { - jest.clearAllMocks(); - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = resolve(workspacePath, 'result'); - - describe('with public repo visibility', () => { - describe('publish: createRemoteInGithub', () => { - it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createInOrg']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'blam/team', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ - org: 'blam', - name: 'test', - private: false, - visibility: 'public', - }); - expect( - mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg, - ).toHaveBeenCalledWith({ - org: 'blam', - team_slug: 'team', - owner: 'blam', - repo: 'test', - permission: 'admin', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - - it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'blam', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - name: 'test', - private: false, - }); - expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); - - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - it('should invite other user in the authed user', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'public' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - access: 'bob', - description: 'description', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - description: 'description', - name: 'test', - private: false, - }); - expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({ - owner: 'blam', - repo: 'test', - username: 'bob', - permission: 'admin', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - describe('with internal repo visibility', () => { - it('creates a private repository in the organization with visibility set to internal', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'internal' }, - ); - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createInOrg']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - isOrg: true, - storePath: 'https://github.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ - org: 'blam', - name: 'test', - private: true, - visibility: 'internal', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); - - describe('private visibility in a user account', () => { - it('creates a private repository', async () => { - const publisher = await GithubPublisher.fromConfig( - { - token: 'fake-token', - host: 'github.com', - }, - { repoVisibility: 'private' }, - ); - - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/backstage/backstage.git', - }, - } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as RestEndpointMethodTypes['users']['getByUsername']['response']); - - const result = await publisher!.publish({ - values: { - storePath: 'https://github.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'https://github.com/backstage/backstage.git', - catalogInfoUrl: - 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - }); - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - name: 'test', - private: true, - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'x-access-token', password: 'fake-token' }, - logger, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts deleted file mode 100644 index 9b4b1d48f5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ /dev/null @@ -1,178 +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 { - enableBranchProtectionOnDefaultRepoBranch, - initRepoAndPush, -} from './helpers'; -import { - GitHubIntegrationConfig, - GithubCredentialsProvider, -} from '@backstage/integration'; -import parseGitUrl from 'git-url-parse'; -import { Octokit } from '@octokit/rest'; -import path from 'path'; - -export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -/** @deprecated use createPublishGithubAction instead */ -export class GithubPublisher implements PublisherBase { - static async fromConfig( - config: GitHubIntegrationConfig, - { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, - ) { - if (!config.token && !config.apps) { - return undefined; - } - - const credentialsProvider = GithubCredentialsProvider.create(config); - - return new GithubPublisher({ - credentialsProvider, - repoVisibility, - apiBaseUrl: config.apiBaseUrl, - }); - } - - constructor( - private readonly config: { - credentialsProvider: GithubCredentialsProvider; - repoVisibility: RepoVisibilityOptions; - apiBaseUrl: string | undefined; - }, - ) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner, name } = parseGitUrl(values.storePath); - - const { token } = await this.config.credentialsProvider.getCredentials({ - url: values.storePath, - }); - - if (!token) { - throw new Error( - `No token could be acquired for URL: ${values.storePath}`, - ); - } - - const client = new Octokit({ - auth: token, - baseUrl: this.config.apiBaseUrl, - previews: ['nebula-preview'], - }); - - const description = values.description as string; - const access = values.access as string; - const remoteUrl = await this.createRemote({ - client, - description, - access, - name, - owner, - }); - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl, - auth: { - username: 'x-access-token', - password: token, - }, - logger, - }); - - const catalogInfoUrl = remoteUrl.replace( - /\.git$/, - '/blob/master/catalog-info.yaml', - ); - - try { - await enableBranchProtectionOnDefaultRepoBranch({ - owner, - client, - repoName: name, - logger, - }); - } catch (e) { - throw new Error(`Failed to add branch protection to '${name}', ${e}`); - } - - return { remoteUrl, catalogInfoUrl }; - } - - private async createRemote(opts: { - client: Octokit; - access: string; - name: string; - owner: string; - description: string; - }) { - const { client, access, description, owner, name } = opts; - - const user = await client.users.getByUsername({ - username: owner, - }); - - const repoCreationPromise = - user.data.type === 'Organization' - ? client.repos.createInOrg({ - name, - org: owner, - private: this.config.repoVisibility !== 'public', - visibility: this.config.repoVisibility, - description, - }) - : client.repos.createForAuthenticatedUser({ - name, - private: this.config.repoVisibility === 'private', - description, - }); - - const { data: newRepo } = await repoCreationPromise; - - try { - if (access?.startsWith(`${owner}/`)) { - const [, team] = access.split('/'); - await client.teams.addOrUpdateRepoPermissionsInOrg({ - org: owner, - team_slug: team, - owner, - repo: name, - permission: 'admin', - }); - // no need to add access if it's the person who owns the personal account - } else if (access && access !== owner) { - await client.repos.addCollaborator({ - owner, - repo: name, - username: access, - permission: 'admin', - }); - } - } catch (e) { - throw new Error( - `Failed to add access to '${access}'. Status ${e.status} ${e.message}`, - ); - } - - return newRepo.clone_url; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts deleted file mode 100644 index 5c6a01afc8..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ /dev/null @@ -1,151 +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('@gitbeaker/node', () => ({ - Gitlab: jest.fn(), -})); - -jest.mock('./helpers'); - -import os from 'os'; -import path from 'path'; -import { GitlabPublisher } from './gitlab'; -import { Gitlab } from '@gitbeaker/node'; -import { initRepoAndPush } from './helpers'; -import { getVoidLogger } from '@backstage/backend-common'; - -describe('GitLab Publisher', () => { - const logger = getVoidLogger(); - const mockGitlabClient = { - Namespaces: { - show: jest.fn(), - }, - Projects: { - create: jest.fn(), - }, - Users: { - current: jest.fn(), - }, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - ((Gitlab as unknown) as jest.Mock).mockImplementation( - () => mockGitlabClient, - ); - }); - - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = path.resolve(workspacePath, 'result'); - - describe('publish: createRemoteInGitLab', () => { - it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { - const publisher = await GitlabPublisher.fromConfig( - { - host: 'gitlab.com', - apiBaseUrl: 'https://gitlab.com/api/v4', - token: 'fake-token', - baseUrl: 'https://gitlab.hosted.com', - }, - { repoVisibility: 'public' }, - ); - - mockGitlabClient.Namespaces.show.mockResolvedValue({ - id: 42, - } as { id: number }); - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - const result = await publisher!.publish({ - values: { - isOrg: true, - storePath: 'https://gitlab.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(Gitlab).toHaveBeenCalledWith({ - token: 'fake-token', - host: 'https://gitlab.hosted.com', - }); - expect(result).toEqual({ - remoteUrl: 'mockclone', - catalogInfoUrl: 'mockclone', - }); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ - namespace_id: 42, - name: 'test', - visibility: 'public', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'mockclone', - auth: { username: 'oauth2', password: 'fake-token' }, - logger, - }); - }); - - it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { - const publisher = await GitlabPublisher.fromConfig( - { - host: 'gitlab.com', - apiBaseUrl: 'https://gitlab.com/api/v4', - token: 'fake-token', - baseUrl: 'https://gitlab.com', - }, - { repoVisibility: 'public' }, - ); - - mockGitlabClient.Namespaces.show.mockResolvedValue({}); - mockGitlabClient.Users.current.mockResolvedValue({ - id: 21, - } as { id: number }); - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - const result = await publisher!.publish({ - values: { - storePath: 'https://gitlab.com/blam/test', - owner: 'bob', - }, - workspacePath, - logger, - }); - - expect(result).toEqual({ - remoteUrl: 'mockclone', - catalogInfoUrl: 'mockclone', - }); - expect(mockGitlabClient.Users.current).toHaveBeenCalled(); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ - namespace_id: 21, - name: 'test', - visibility: 'public', - }); - expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: resultPath, - remoteUrl: 'mockclone', - auth: { username: 'oauth2', password: 'fake-token' }, - logger, - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts deleted file mode 100644 index d2f58900a8..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ /dev/null @@ -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 { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Gitlab } from '@gitbeaker/node'; -import { Gitlab as GitlabClient } from '@gitbeaker/core'; -import { initRepoAndPush } from './helpers'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; -import { GitLabIntegrationConfig } from '@backstage/integration'; - -export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -export class GitlabPublisher implements PublisherBase { - static async fromConfig( - config: GitLabIntegrationConfig, - { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, - ) { - if (!config.token) { - return undefined; - } - - const client = new Gitlab({ host: config.baseUrl, token: config.token }); - return new GitlabPublisher({ - token: config.token, - client, - repoVisibility, - }); - } - - constructor( - private readonly config: { - token: string; - client: GitlabClient; - repoVisibility: RepoVisibilityOptions; - }, - ) {} - - async publish({ - values, - workspacePath, - logger, - }: PublisherOptions): Promise { - const { owner, name } = parseGitUrl(values.storePath); - - const remoteUrl = await this.createRemote({ - owner, - name, - }); - - await initRepoAndPush({ - dir: path.join(workspacePath, 'result'), - remoteUrl, - auth: { - username: 'oauth2', - password: this.config.token, - }, - logger, - }); - - const catalogInfoUrl = remoteUrl.replace( - /\.git$/, - '/-/blob/master/catalog-info.yaml', - ); - return { remoteUrl, catalogInfoUrl }; - } - - private async createRemote(opts: { name: string; owner: string }) { - const { owner, name } = opts; - - // TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high! - // Shouldn't have to cast things now - let targetNamespace = ((await this.config.client.Namespaces.show( - owner, - )) as { - id: number; - }).id; - - if (!targetNamespace) { - targetNamespace = ((await this.config.client.Users.current()) as { - id: number; - }).id; - } - - const project = (await this.config.client.Projects.create({ - namespace_id: targetNamespace, - name: name, - visibility: this.config.repoVisibility, - })) as { http_url_to_repo: string }; - - return project?.http_url_to_repo; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts deleted file mode 100644 index 7a384f1657..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ /dev/null @@ -1,27 +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 { AzurePublisher } from './azure'; -export { BitbucketPublisher } from './bitbucket'; -export { GithubPublisher } from './github'; -export type { RepoVisibilityOptions } from './github'; -export { GitlabPublisher } from './gitlab'; -export { Publishers } from './publishers'; -export type { - PublisherBase, - PublisherBuilder, - PublisherOptions, - PublisherResult, -} from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts deleted file mode 100644 index c7ca832752..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts +++ /dev/null @@ -1,127 +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 { Publishers } from './publishers'; -import { GithubPublisher } from './github'; -import { getVoidLogger } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; -import { AzurePublisher } from './azure'; -import { GitlabPublisher } from './gitlab'; -import { BitbucketPublisher } from './bitbucket'; - -jest.mock('@octokit/rest'); -jest.mock('azure-devops-node-api'); - -describe('Publishers', () => { - const logger = getVoidLogger(); - - it('should throw an error when the publisher for the source location is not registered', () => { - const publishers = new Publishers(); - - expect(() => publishers.get('https://github.com/org/repo')).toThrow( - expect.objectContaining({ - message: - 'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config', - }), - ); - }); - - it('should return the correct preparer when the source matches for github', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - github: [{ host: 'github.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - - expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf( - GithubPublisher, - ); - }); - - it('should return the correct preparer when the source matches for azure', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - azure: [{ host: 'dev.azure.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - - expect( - publishers.get('https://dev.azure.com/org/project/_git/repo'), - ).toBeInstanceOf(AzurePublisher); - }); - - it('should return the correct preparer when the source matches for bitbucket', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - bitbucket: [ - { host: 'bitbucket.com', username: 'foo', token: 'blob' }, - ], - }, - }), - { - logger, - }, - ); - expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf( - BitbucketPublisher, - ); - }); - - it('should return the correct preparer when the source matches for gitlab', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - gitlab: [{ host: 'gitlab.com', token: 'blob' }], - }, - }), - { - logger, - }, - ); - expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf( - GitlabPublisher, - ); - }); - - it('should respect registrations for custom URLs for providers using the integrations config', async () => { - const publishers = await Publishers.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { host: 'my.special.github.enterprise.thing', token: 'lolghe' }, - ], - }, - }), - { - logger, - }, - ); - - expect( - publishers.get('https://my.special.github.enterprise.thing/org/repo'), - ).toBeInstanceOf(GithubPublisher); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts deleted file mode 100644 index 9c34ab5783..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ /dev/null @@ -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 { Config } from '@backstage/config'; -import { PublisherBase, PublisherBuilder } from './types'; -import { - GithubPublisher, - RepoVisibilityOptions as GithubRepoVisibilityOptions, -} from './github'; -import { - GitlabPublisher, - RepoVisibilityOptions as GitlabRepoVisibilityOptions, -} from './gitlab'; -import { AzurePublisher } from './azure'; -import { - BitbucketPublisher, - RepoVisibilityOptions as BitbucketRepoVisibilityOptions, -} from './bitbucket'; -import { Logger } from 'winston'; -import { ScmIntegrations } from '@backstage/integration'; - -export class Publishers implements PublisherBuilder { - private publisherMap = new Map(); - - register(host: string, preparer: PublisherBase | undefined) { - this.publisherMap.set(host, preparer); - } - - get(url: string): PublisherBase { - const preparer = this.publisherMap.get(new URL(url).host); - if (!preparer) { - throw new Error( - `Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`, - ); - } - return preparer; - } - - static async fromConfig( - config: Config, - _options: { logger: Logger }, - ): Promise { - const publishers = new Publishers(); - - const scm = ScmIntegrations.fromConfig(config); - - for (const integration of scm.azure.list()) { - const publisher = await AzurePublisher.fromConfig(integration.config); - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.github.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.github.visibility', - ) ?? 'public') as GithubRepoVisibilityOptions; - - const publisher = await GithubPublisher.fromConfig(integration.config, { - repoVisibility, - }); - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.gitlab.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.gitlab.visibility', - ) ?? 'public') as GitlabRepoVisibilityOptions; - - const publisher = await GitlabPublisher.fromConfig(integration.config, { - repoVisibility, - }); - - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - for (const integration of scm.bitbucket.list()) { - const repoVisibility = (config.getOptionalString( - 'scaffolder.bitbucket.visibility', - ) ?? 'public') as BitbucketRepoVisibilityOptions; - - const publisher = await BitbucketPublisher.fromConfig( - integration.config, - { - repoVisibility, - }, - ); - - if (publisher) { - publishers.register(integration.config.host, publisher); - } - } - - return publishers; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts deleted file mode 100644 index 725816c918..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ /dev/null @@ -1,47 +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 { TemplaterValues } from '../templater'; -import { Logger } from 'winston'; - -/** - * Publisher is in charge of taking a folder created by - * the templater, and pushing it to a remote storage - */ -export type PublisherBase = { - /** - * - * @param opts object containing the template entity from the service - * catalog, plus the values from the form and the directory that has - * been templated - */ - publish(opts: PublisherOptions): Promise; -}; - -export type PublisherOptions = { - values: TemplaterValues; - workspacePath: string; - logger: Logger; -}; - -export type PublisherResult = { - remoteUrl: string; - catalogInfoUrl?: string; -}; - -export type PublisherBuilder = { - register(host: string, publisher: PublisherBase): void; - get(storePath: string): PublisherBase; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts deleted file mode 100644 index 1db5b52ffa..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ /dev/null @@ -1,279 +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. - */ - -const runCommand = jest.fn(); -const commandExists = jest.fn(); - -jest.mock('./helpers', () => ({ runCommand })); -jest.mock('command-exists', () => commandExists); -jest.mock('fs-extra'); - -import { ContainerRunner } from '@backstage/backend-common'; -import fs from 'fs-extra'; -import parseGitUrl from 'git-url-parse'; -import path from 'path'; -import { PassThrough } from 'stream'; -import { CookieCutter } from './cookiecutter'; - -describe('CookieCutter Templater', () => { - const containerRunner: jest.Mocked = { - runContainer: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - commandExists.mockRejectedValue(null); - }); - - it('should write a cookiecutter.json file with the values from the entity', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - description: 'description', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate')); - expect(fs.writeJson).toBeCalledWith( - path.join('tempdir', 'template', 'cookiecutter.json'), - expect.objectContaining(values), - ); - }); - - it('should merge any value that is in the cookiecutter.json path already', async () => { - const existingJson = { - _copy_without_render: ['./github/workflows/*'], - }; - - jest - .spyOn(fs, 'readJSON') - .mockImplementationOnce(() => Promise.resolve(existingJson)); - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'something', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(fs.writeJSON).toBeCalledWith( - path.join('tempdir', 'template', 'cookiecutter.json'), - { - ...existingJson, - ...values, - destination: { - git: expect.objectContaining({ organization: 'org', name: 'repo' }), - }, - }, - ); - }); - - it('should throw an error if the cookiecutter json is malformed and not missing', async () => { - jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => { - throw new Error('BAM'); - }); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const templater = new CookieCutter({ containerRunner }); - await expect( - templater.run({ - workspacePath: 'tempdir', - values, - }), - ).rejects.toThrow('BAM'); - }); - - it('should run the correct docker container with the correct bindings for the volumes', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - jest - .spyOn(fs, 'realpath') - .mockImplementation(x => Promise.resolve(x.toString())); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith({ - imageName: 'spotify/backstage-cookiecutter', - command: 'cookiecutter', - args: ['--no-input', '-o', '/output', '/input', '--verbose'], - envVars: { HOME: '/tmp' }, - mountDirs: { - [path.join('tempdir', 'template')]: '/input', - [path.join('tempdir', 'intermediate')]: '/output', - }, - workingDir: '/input', - logStream: undefined, - }); - }); - - it('should run the docker container mentioned in configs, overriding the default', async () => { - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - imageName: 'foo/cookiecutter-image-with-extensions', - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith( - expect.objectContaining({ - imageName: 'foo/cookiecutter-image-with-extensions', - }), - ); - }); - - it('should pass through the streamer to the run docker helper', async () => { - const stream = new PassThrough(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - logStream: stream, - }); - - expect(containerRunner.runContainer).toHaveBeenCalledWith({ - imageName: 'spotify/backstage-cookiecutter', - command: 'cookiecutter', - args: ['--no-input', '-o', '/output', '/input', '--verbose'], - envVars: { HOME: '/tmp' }, - mountDirs: { - [path.join('tempdir', 'template')]: '/input', - [path.join('tempdir', 'intermediate')]: '/output', - }, - workingDir: '/input', - logStream: stream, - }); - }); - - describe('when cookiecutter is available', () => { - it('use the binary', async () => { - const stream = new PassThrough(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - commandExists.mockResolvedValueOnce(true); - - const templater = new CookieCutter({ containerRunner }); - await templater.run({ - workspacePath: 'tempdir', - values, - logStream: stream, - }); - - expect(runCommand).toHaveBeenCalledWith({ - command: 'cookiecutter', - args: expect.arrayContaining([ - '--no-input', - '-o', - path.join('tempdir', 'intermediate'), - path.join('tempdir', 'template'), - '--verbose', - ]), - 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 CookieCutter({ containerRunner }); - await expect( - templater.run({ - workspacePath: 'tempdir', - values: { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }, - logStream: stream, - }), - ).rejects.toThrow(/No data generated by cookiecutter/); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts deleted file mode 100644 index 46bd9094ac..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ /dev/null @@ -1,159 +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 { ContainerRunner } from '@backstage/backend-common'; -import fs from 'fs-extra'; -import path from 'path'; -import * as yaml from 'yaml'; -import { TemplaterBase, TemplaterRunOptions } from '../types'; - -// TODO(blam): Replace with the universal import from github-actions after a release -// As it will break the E2E without it -const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; - -export class CreateReactAppTemplater implements TemplaterBase { - private readonly containerRunner: ContainerRunner; - - constructor({ containerRunner }: { containerRunner: ContainerRunner }) { - this.containerRunner = containerRunner; - } - - public async run({ - workspacePath, - values, - logStream, - }: TemplaterRunOptions): Promise { - const { - component_id: componentName, - use_typescript: withTypescript, - use_github_actions: withGithubActions, - description, - owner, - } = values; - const intermediateDir = path.join(workspacePath, 'template'); - await fs.ensureDir(intermediateDir); - - const mountDirs = { - [intermediateDir]: '/template', - [intermediateDir]: '/result', - }; - - await this.containerRunner.runContainer({ - imageName: 'node:lts-alpine', - command: ['npx'], - args: [ - 'create-react-app', - componentName as string, - withTypescript ? ' --template typescript' : '', - ], - mountDirs, - workingDir: '/result', - logStream: logStream, - // 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' }, - }); - - // 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'); - } - - const resultDir = path.join(workspacePath, 'result'); - await fs.move(path.join(intermediateDir, generated), resultDir); - - const extraAnnotations: Record = {}; - if (withGithubActions) { - await fs.mkdir(`${resultDir}/.github`); - await fs.mkdir(`${resultDir}/.github/workflows`); - const githubActionsYaml = ` -name: CRA Build - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [12.x] - - steps: - - name: checkout code - uses: actions/checkout@v1 - - name: get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 - with: - path: \${{ steps.yarn-cache.outputs.dir }} - key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }} - restore-keys: | - \${{ runner.os }}-yarn- - - name: use node.js \${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: \${{ matrix.node-version }} - - name: yarn install, build, and test - working-directory: . - run: | - yarn install - yarn build - yarn test - env: - CI: true - `; - await fs.writeFile( - `${resultDir}/.github/workflows/main.yml`, - githubActionsYaml, - ); - - extraAnnotations[ - GITHUB_ACTIONS_ANNOTATION - ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; - } - - const componentInfo = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: componentName, - description, - annotations: { - ...extraAnnotations, - }, - }, - spec: { - type: 'website', - lifecycle: 'experimental', - owner, - }, - }; - - await fs.writeFile( - `${resultDir}/catalog-info.yaml`, - yaml.stringify(componentInfo), - ); - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts deleted file mode 100644 index c2742428a5..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ /dev/null @@ -1,75 +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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { spawn } from 'child_process'; -import { PassThrough, Writable } from 'stream'; - -export type RunCommandOptions = { - command: string; - args: string[]; - logStream?: Writable; -}; - -/** - * Gets the templater key to use for templating from the entity - * @param entity Template entity - */ -export const getTemplaterKey = (entity: TemplateEntityV1alpha1): string => { - const { templater } = entity.spec; - - if (!templater) { - throw new InputError('Template does not have a required templating key'); - } - - return templater; -}; - -/** - * - * @param options the options object - * @param options.command the command to run - * @param options.args the arguments to pass the command - * @param options.logStream the log streamer to capture log messages - */ -export const runCommand = async ({ - command, - args, - logStream = new PassThrough(), -}: RunCommandOptions) => { - await new Promise((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(); - }); - }); -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts deleted file mode 100644 index ae3890c092..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ /dev/null @@ -1,43 +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 { ContainerRunner } from '@backstage/backend-common'; -import { CookieCutter } from './cookiecutter'; -import { Templaters } from './templaters'; - -describe('Templaters', () => { - const containerRunner: jest.Mocked = { - runContainer: jest.fn(), - }; - - it('should throw an error when the templater is not registered', () => { - const templaters = new Templaters(); - - expect(() => templaters.get('cookiecutter')).toThrow( - expect.objectContaining({ - message: 'No templater registered for template: "cookiecutter"', - }), - ); - }); - it('should return the correct templater when the templater matches', () => { - const templaters = new Templaters(); - const templater = new CookieCutter({ containerRunner }); - - templaters.register('cookiecutter', templater); - - expect(templaters.get('cookiecutter')).toBe(templater); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts deleted file mode 100644 index a977502a01..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts +++ /dev/null @@ -1,39 +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 { - TemplaterBase, - SupportedTemplatingKey, - TemplaterBuilder, -} from './types'; - -export class Templaters implements TemplaterBuilder { - private templaterMap = new Map(); - - register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) { - this.templaterMap.set(templaterKey, templater); - } - - get(templaterId: string): TemplaterBase { - const templater = this.templaterMap.get(templaterId); - - if (!templater) { - throw new Error(`No templater registered for template: "${templaterId}"`); - } - - return templater; - } -} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts deleted file mode 100644 index 4d6b96c776..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ /dev/null @@ -1,73 +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 gitUrlParse from 'git-url-parse'; -import type { Writable } from 'stream'; - -/** - * Currently the required template values. The owner - * and where to store the result from templating - */ -export type RequiredTemplateValues = { - owner: string; - storePath: string; - destination?: { - git?: gitUrlParse.GitUrl; - }; -}; - -export type TemplaterValues = RequiredTemplateValues & Record; - -/** - * The returned directory from the templater which is ready - * to pass to the next stage of the scaffolder which is publishing - */ -export type TemplaterRunResult = { - resultDir: string; -}; - -/** - * The values that the templater will receive. The directory of the - * skeleton, with the values from the frontend. A dedicated log stream and a docker - * client to run any templater on top of your directory. - */ -export type TemplaterRunOptions = { - workspacePath: string; - values: TemplaterValues; - logStream?: Writable; -}; - -export type TemplaterBase = { - // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; -}; - -export type TemplaterConfig = { - templater?: TemplaterBase; -}; - -/** - * List of supported templating options - */ -export type SupportedTemplatingKey = 'cookiecutter' | string; - -/** - * The templater builder holds the templaters ready for run time - */ -export type TemplaterBuilder = { - register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(templater: string): TemplaterBase; -}; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts deleted file mode 100644 index fef92d081f..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ /dev/null @@ -1,101 +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 { resolve as resolvePath, dirname } from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import parseGitUrl from 'git-url-parse'; -import { TaskSpec } from './types'; -import { - getTemplaterKey, - joinGitUrlPath, - parseLocationAnnotation, - TemplaterValues, -} from '../stages'; - -export function templateEntityToSpec( - template: TemplateEntityV1alpha1, - inputValues: TemplaterValues, -): TaskSpec { - const steps: TaskSpec['steps'] = []; - - const { protocol, location } = parseLocationAnnotation(template); - - let url: string; - if (protocol === 'file') { - const path = resolvePath(dirname(location), template.spec.path || '.'); - - url = `file://${path}`; - } else { - url = joinGitUrlPath(location, template.spec.path); - } - const templater = getTemplaterKey(template); - - const values = { - ...inputValues, - destination: { - git: parseGitUrl(inputValues.storePath), - }, - } as TemplaterValues; - - steps.push({ - id: 'prepare', - name: 'Prepare', - action: 'legacy:prepare', - input: { - protocol, - url, - }, - }); - - steps.push({ - id: 'template', - name: 'Template', - action: 'legacy:template', - input: { - templater, - values, - }, - }); - - steps.push({ - id: 'publish', - name: 'Publish', - action: 'legacy:publish', - input: { - values, - }, - }); - - steps.push({ - id: 'register', - name: 'Register', - action: 'catalog:register', - input: { - catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}', - }, - }); - - return { - baseUrl: undefined, // not used by legacy actions - values: {}, - steps, - output: { - remoteUrl: '{{ steps.publish.output.remoteUrl }}', - catalogInfoUrl: '{{ steps.register.output.catalogInfoUrl }}', - entityRef: '{{ steps.register.output.entityRef }}', - }, - }; -} diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index b43b62a413..c0fdcb2ef7 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -33,12 +33,13 @@ import { PluginDatabaseManager, DatabaseManager, UrlReaders, + DockerContainerRunner, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; -import { Preparers, Publishers, Templaters } from '../scaffolder'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { createRouter } from './router'; const createCatalogClient = (templates: any[] = []) => @@ -66,8 +67,8 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; - const template = { - apiVersion: 'backstage.io/v1alpha1', + const template: TemplateEntityV1beta2 = { + apiVersion: 'backstage.io/v1beta2', kind: 'Template', metadata: { description: 'Create a new CRA website project', @@ -80,42 +81,28 @@ describe('createRouter', () => { }, spec: { owner: 'web@example.com', - path: '.', - schema: { + type: 'website', + steps: [], + parameters: { + type: 'object', + required: ['required'], properties: { - component_id: { - description: 'Unique name of the component', - title: 'Name', + required: { type: 'string', - }, - description: { - description: 'Description of the component', - title: 'Description', - type: 'string', - }, - use_typescript: { - default: true, - description: 'Include TypeScript', - title: 'Use TypeScript', - type: 'boolean', + description: 'Required parameter', }, }, - required: ['component_id', 'use_typescript'], }, - templater: 'cra', - type: 'website', }, }; beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - preparers: new Preparers(), - templaters: new Templaters(), - publishers: new Publishers(), config: new ConfigReader({}), database: createDatabase(), catalogClient: createCatalogClient([template]), + containerRunner: new DockerContainerRunner({} as any), reader: mockUrlReader, }); app = express().use(router); @@ -139,7 +126,7 @@ describe('createRouter', () => { const response = await request(app) .post('/v2/tasks') .send({ - templateName: '', + templateName: 'create-react-app-template', values: { storePath: 'https://github.com/backstage/backstage', }, @@ -154,10 +141,7 @@ describe('createRouter', () => { .send({ templateName: 'create-react-app-template', values: { - storePath: 'https://github.com/backstage/backstage', - component_id: '123', - name: 'test', - use_typescript: false, + required: 'required-value', }, }); @@ -174,10 +158,7 @@ describe('createRouter', () => { .send({ templateName: 'create-react-app-template', values: { - storePath: 'https://github.com/backstage/backstage', - component_id: '123', - name: 'test', - use_typescript: false, + required: 'required-value', }, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index b0142d4b02..2b5bdb3bf8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -18,12 +18,6 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { - PreparerBuilder, - TemplaterBuilder, - TemplaterValues, - PublisherBuilder, -} from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; import { validate } from 'jsonschema'; import { @@ -31,27 +25,21 @@ import { StorageTaskBroker, TaskWorker, } from '../scaffolder/tasks'; -import { templateEntityToSpec } from '../scaffolder/tasks/TemplateConverter'; import { TemplateActionRegistry } from '../scaffolder/actions/TemplateActionRegistry'; -import { createLegacyActions } from '../scaffolder/stages/legacy'; import { getEntityBaseUrl, getWorkingDirectory } from './helpers'; -import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; +import { + ContainerRunner, + PluginDatabaseManager, + UrlReader, +} from '@backstage/backend-common'; import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; -import { - TemplateEntityV1alpha1, - TemplateEntityV1beta2, - Entity, -} from '@backstage/catalog-model'; +import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '../scaffolder/actions'; import { createBuiltinActions } from '../scaffolder/actions/builtin/createBuiltinActions'; export interface RouterOptions { - preparers: PreparerBuilder; - templaters: TemplaterBuilder; - publishers: PublisherBuilder; - logger: Logger; config: Config; reader: UrlReader; @@ -59,19 +47,11 @@ export interface RouterOptions { catalogClient: CatalogApi; actions?: TemplateAction[]; taskWorkers?: number; -} - -function isAlpha1Template( - entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, -): entity is TemplateEntityV1alpha1 { - return ( - entity.apiVersion === 'backstage.io/v1alpha1' || - entity.apiVersion === 'backstage.io/v1beta1' - ); + containerRunner: ContainerRunner; } function isBeta2Template( - entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, + entity: TemplateEntityV1beta2, ): entity is TemplateEntityV1beta2 { return entity.apiVersion === 'backstage.io/v1beta2'; } @@ -83,15 +63,13 @@ export async function createRouter( router.use(express.json()); const { - preparers, - templaters, - publishers, logger: parentLogger, config, reader, database, catalogClient, actions, + containerRunner, taskWorkers, } = options; @@ -118,19 +96,13 @@ export async function createRouter( const actionsToRegister = Array.isArray(actions) ? actions - : [ - ...createLegacyActions({ - preparers, - publishers, - templaters, - }), - ...createBuiltinActions({ - integrations, - catalogClient, - templaters, - reader, - }), - ]; + : createBuiltinActions({ + integrations, + catalogClient, + containerRunner, + reader, + config, + }); actionsToRegister.forEach(action => actionRegistry.register(action)); workers.forEach(worker => worker.start()); @@ -164,42 +136,6 @@ export async function createRouter( schema, })), }); - } else if (isAlpha1Template(template)) { - res.json({ - title: template.metadata.title ?? template.metadata.name, - steps: [ - { - title: 'Fill in template parameters', - schema: template.spec.schema, - }, - { - title: 'Choose owner and repo', - schema: { - type: 'object', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: - 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', - }, - access: { - type: 'string', - title: 'Access', - description: - 'Who should have access, in org/team or user format', - }, - }, - }, - }, - ], - }); } else { throw new InputError( `Unsupported apiVersion field in schema entity, ${ @@ -221,26 +157,15 @@ export async function createRouter( }) .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; - const values: TemplaterValues = req.body.values; + const values = req.body.values; const token = getBearerToken(req.headers.authorization); const template = await entityClient.findTemplate(templateName, { token, }); let taskSpec; - if (isAlpha1Template(template)) { - logger.warn( - `[DEPRECATION] - Template: ${template.metadata.name} has version ${template.apiVersion} which is going to be deprecated. Please refer to https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2 for help on migrating`, - ); - const result = validate(values, template.spec.schema); - if (!result.valid) { - res.status(400).json({ errors: result.errors }); - return; - } - - taskSpec = templateEntityToSpec(template, values); - } else if (isBeta2Template(template)) { + if (isBeta2Template(template)) { for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(values, parameters); diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 5da1869fc2..7982ad997e 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -4,6 +4,7 @@ ```ts +import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -24,9 +25,21 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; // @public (undocumented) export function createScaffolderFieldExtension(options: FieldExtensionOptions): Extension<() => null>; +// @public (undocumented) +export type CustomFieldValidator = ((data: T, field: FieldValidation) => void) | ((data: T, field: FieldValidation, context: { + apiHolder: ApiHolder; +}) => void); + // @public (undocumented) export const EntityPickerFieldExtension: () => null; +// @public (undocumented) +export type FieldExtensionOptions = { + name: string; + component: (props: FieldProps) => JSX.Element | null; + validation?: CustomFieldValidator; +}; + // @public (undocumented) export const OwnerPickerFieldExtension: () => null; diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 85df77f2f5..37d78d0fda 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -245,7 +245,10 @@ export class ScaffolderClient implements ScaffolderApi { */ async listActions(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const response = await fetch(`${baseUrl}/v2/actions`); + const token = await this.identityApi.getIdToken(); + const response = await fetch(`${baseUrl}/v2/actions`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!response.ok) { throw ResponseError.fromResponse(response); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts index 1031dfffe3..b51d01f719 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -286,4 +286,64 @@ describe('transformSchemaToProps', () => { uiSchema: expectedUiSchema, }); }); + + it('transforms schema with dependencies', () => { + const inputSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + credit_card: { + type: 'number', + }, + }, + required: ['name'], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: 'string', + 'ui:widget': 'textarea', + }, + }, + required: ['billing_address'], + }, + }, + }; + const expectedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + credit_card: { + type: 'number', + }, + }, + required: ['name'], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: 'string', + }, + }, + required: ['billing_address'], + }, + }, + }; + const expectedUiSchema = { + billing_address: { + 'ui:widget': 'textarea', + }, + credit_card: {}, + name: {}, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); }); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index 3342e3a046..ee2dbce406 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { return; } - const { properties, anyOf, oneOf, allOf } = schema; + const { properties, anyOf, oneOf, allOf, dependencies } = schema; for (const propName in schema) { if (!schema.hasOwnProperty(propName)) { @@ -81,6 +81,16 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { extractUiSchema(schemaNode, uiSchema); } } + + if (isObject(dependencies)) { + for (const depName of Object.keys(dependencies)) { + const schemaNode = dependencies[depName]; + if (!isObject(schemaNode)) { + continue; + } + extractUiSchema(schemaNode, uiSchema); + } + } } export function transformSchemaToProps( diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 1d16e50568..65386b60b3 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -14,7 +14,18 @@ * limitations under the License. */ -import { EntityMeta, TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { EntityMeta, TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + Header, + ItemCardGrid, + Lifecycle, + WarningPanel, + Page, + Progress, + SupportButton, +} from '@backstage/core-components'; import { useStarredEntities } from '@backstage/plugin-catalog-react'; import { Button, Link, makeStyles, Typography } from '@material-ui/core'; import StarIcon from '@material-ui/icons/Star'; @@ -30,18 +41,6 @@ import { TemplateCard } from '../TemplateCard'; import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; -import { - Content, - ContentHeader, - Header, - ItemCardGrid, - Lifecycle, - Page, - Progress, - SupportButton, - WarningPanel, -} from '@backstage/core-components'; - const useStyles = makeStyles(theme => ({ contentWrapper: { display: 'grid', @@ -90,7 +89,7 @@ export const ScaffolderPageContents = () => { ); const [search, setSearch] = useState(''); const [matchingEntities, setMatchingEntities] = useState( - [] as TemplateEntityV1alpha1[], + [] as TemplateEntityV1beta2[], ); const matchesQuery = (metadata: EntityMeta, query: string) => @@ -175,11 +174,7 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map((template, i) => ( - + ))} diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index ad91fc6cfd..9d2e827be9 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -16,7 +16,7 @@ import { Entity, RELATION_OWNED_BY, - TemplateEntityV1alpha1, + TemplateEntityV1beta2, } from '@backstage/catalog-model'; import { ScmIntegrationIcon, @@ -93,7 +93,7 @@ const useDeprecationStyles = makeStyles(theme => ({ })); export type TemplateCardProps = { - template: TemplateEntityV1alpha1; + template: TemplateEntityV1beta2; deprecated?: boolean; }; @@ -106,7 +106,7 @@ type TemplateProps = { }; const getTemplateCardProps = ( - template: TemplateEntityV1alpha1, + template: TemplateEntityV1beta2, ): TemplateProps & { key: string } => { return { key: template.metadata.uid!, diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 3caece4878..e72255f129 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -15,14 +15,13 @@ */ import { JsonObject, JsonValue } from '@backstage/config'; import { LinearProgress } from '@material-ui/core'; -import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core'; -import parseGitUrl from 'git-url-parse'; +import { FormValidation, IChangeEvent } from '@rjsf/core'; import React, { useCallback, useState } from 'react'; import { generatePath, Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; -import { FieldExtensionOptions } from '../../extensions'; +import { CustomFieldValidator, FieldExtensionOptions } from '../../extensions'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; @@ -33,7 +32,13 @@ import { Lifecycle, Page, } from '@backstage/core-components'; -import { errorApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { + ApiHolder, + errorApiRef, + useApi, + useApiHolder, + useRouteRef, +} from '@backstage/core-plugin-api'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -55,10 +60,10 @@ function isObject(obj: unknown): obj is JsonObject { export const createValidator = ( rootSchema: JsonObject, - validators: Record< - string, - undefined | ((value: JsonValue, validation: FieldValidation) => void) - >, + validators: Record>, + context: { + apiHolder: ApiHolder; + }, ) => { function validate( schema: JsonObject, @@ -87,7 +92,11 @@ export const createValidator = ( const fieldName = isObject(propSchema) && (propSchema['ui:field'] as string); if (fieldName && typeof validators[fieldName] === 'function') { - validators[fieldName]!(propData as JsonValue, propValidation); + validators[fieldName]!( + propData as JsonValue, + propValidation, + context, + ); } } } @@ -99,44 +108,12 @@ export const createValidator = ( }; }; -const storePathValidator = ( - formData: { storePath?: string }, - errors: FormValidation, -) => { - const { storePath } = formData; - if (!storePath) { - errors.storePath.addError('Store path is required and not present'); - return errors; - } - - try { - const parsedUrl = parseGitUrl(storePath); - - if (!parsedUrl.resource || !parsedUrl.owner || !parsedUrl.name) { - if (parsedUrl.resource === 'dev.azure.com') { - errors.storePath.addError( - "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", - ); - } else { - errors.storePath.addError( - 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', - ); - } - } - } catch (ex) { - errors.storePath.addError( - `Failed validation of the store path with message ${ex.message}`, - ); - } - - return errors; -}; - export const TemplatePage = ({ customFieldExtensions = [], }: { customFieldExtensions?: FieldExtensionOptions[]; }) => { + const apiHolder = useApiHolder(); const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); @@ -203,18 +180,13 @@ export const TemplatePage = ({ onReset={handleFormReset} onFinish={handleCreate} steps={schema.steps.map(step => { - // TODO: Can delete this function when the migration from v1 to v2 beta is completed - // And just have the default validator for all fields. - if ((step.schema as any)?.properties?.storePath) { - return { - ...step, - validate: (a, b) => storePathValidator(a, b), - }; - } - return { ...step, - validate: createValidator(step.schema, customFieldValidators), + validate: createValidator( + step.schema, + customFieldValidators, + { apiHolder }, + ), }; })} /> diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts index f0df198507..1ea66e7df9 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { repoPickerValidation } from './validation'; import { FieldValidation } from '@rjsf/core'; @@ -22,7 +23,7 @@ describe('RepoPicker Validation', () => { addError: jest.fn(), } as unknown) as FieldValidation); - it('validaties when no repo', () => { + it('validates when no repo', () => { const mockFieldValidation = fieldValidator(); repoPickerValidation('github.com?owner=a', mockFieldValidation); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts index 827afa3cab..760b9f7890 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { FieldValidation } from '@rjsf/core'; export const repoPickerValidation = ( diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index f53052cb67..131cd105da 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { FieldExtensionOptions } from './types'; +import { CustomFieldValidator, FieldExtensionOptions } from './types'; import { Extension, attachComponentData } from '@backstage/core-plugin-api'; export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1'; @@ -46,6 +46,6 @@ attachComponentData( true, ); -export type { FieldExtensionOptions }; +export type { CustomFieldValidator, FieldExtensionOptions }; export { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from './default'; diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts index 8f1c155f28..644897f7f4 100644 --- a/plugins/scaffolder/src/extensions/types.ts +++ b/plugins/scaffolder/src/extensions/types.ts @@ -13,10 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ApiHolder } from '@backstage/core-plugin-api'; import { FieldValidation, FieldProps } from '@rjsf/core'; +export type CustomFieldValidator = + | ((data: T, field: FieldValidation) => void) + | (( + data: T, + field: FieldValidation, + context: { apiHolder: ApiHolder }, + ) => void); + export type FieldExtensionOptions = { name: string; component: (props: FieldProps) => JSX.Element | null; - validation?: (data: T, field: FieldValidation) => void; + validation?: CustomFieldValidator; }; diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx index 7aa1a21643..a5be11380f 100644 --- a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAsyncFn } from 'react-use'; @@ -50,7 +50,7 @@ function useProvideEntityFilters(): FilterGroupsContext { const response = await catalogApi.getEntities({ filter: { kind: 'Template' }, }); - return response.items as TemplateEntityV1alpha1[]; + return response.items as TemplateEntityV1beta2[]; }); const filterGroups = useRef<{ @@ -64,7 +64,7 @@ function useProvideEntityFilters(): FilterGroupsContext { [filterGroupId: string]: FilterGroupStates; }>({}); const [filteredEntities, setFilteredEntities] = useState< - TemplateEntityV1alpha1[] + TemplateEntityV1beta2[] >([]); const [availableCategories, setAvailableCategories] = useState([]); const [isCatalogEmpty, setCatalogEmpty] = useState(false); @@ -161,7 +161,7 @@ function buildStates( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, selectedCategories: string[], - entities?: TemplateEntityV1alpha1[], + entities?: TemplateEntityV1beta2[], error?: Error, ): { [filterGroupId: string]: FilterGroupStates } { // On error - all entries are an error state @@ -208,7 +208,7 @@ function buildStates( } // Given all entites, find all possible categories and provide them in a sorted list. -function collectCategories(entities?: TemplateEntityV1alpha1[]): string[] { +function collectCategories(entities?: TemplateEntityV1beta2[]): string[] { const categories = new Set(); (entities || []).forEach(e => { if (e.spec?.type) { @@ -224,9 +224,9 @@ function buildMatchingEntities( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, selectedCategories: string[], - entities?: TemplateEntityV1alpha1[], + entities?: TemplateEntityV1beta2[], excludeFilterGroupId?: string, -): TemplateEntityV1alpha1[] { +): TemplateEntityV1beta2[] { // Build one filter fn per filter group const allFilters: EntityFilterFn[] = []; for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts index ee66e3d9da..385d3f6eb5 100644 --- a/plugins/scaffolder/src/filter/context.ts +++ b/plugins/scaffolder/src/filter/context.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { createContext } from 'react'; import { FilterGroup, FilterGroupStates } from './types'; @@ -32,7 +32,7 @@ export type FilterGroupsContext = { loading: boolean; error?: Error; filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; - filteredEntities: TemplateEntityV1alpha1[]; + filteredEntities: TemplateEntityV1beta2[]; availableCategories: string[]; isCatalogEmpty: boolean; }; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 6025abc067..a034f5f574 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -16,9 +16,11 @@ export { scaffolderApiRef, ScaffolderClient } from './api'; export type { ScaffolderApi } from './api'; -export { - createScaffolderFieldExtension, +export { createScaffolderFieldExtension } from './extensions'; +export type { ScaffolderFieldExtensions, + CustomFieldValidator, + FieldExtensionOptions, } from './extensions'; export { EntityPickerFieldExtension, diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md index 03585b6b99..ccb7475573 100644 --- a/plugins/search-backend-node/api-report.md +++ b/plugins/search-backend-node/api-report.md @@ -7,7 +7,7 @@ import { DocumentCollator } from '@backstage/search-common'; import { DocumentDecorator } from '@backstage/search-common'; import { IndexableDocument } from '@backstage/search-common'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { default as lunr_2 } from 'lunr'; import { SearchQuery } from '@backstage/search-common'; import { SearchResultSet } from '@backstage/search-common'; @@ -27,14 +27,14 @@ export class IndexBuilder { // @public (undocumented) export class LunrSearchEngine implements SearchEngine { constructor({ logger }: { - logger: Logger; + logger: Logger_2; }); // (undocumented) protected docStore: Record; // (undocumented) index(type: string, documents: IndexableDocument[]): void; // (undocumented) - protected logger: Logger; + protected logger: Logger_2; // (undocumented) protected lunrIndices: Record; // (undocumented) @@ -48,7 +48,7 @@ export class LunrSearchEngine implements SearchEngine { // @public export class Scheduler { constructor({ logger }: { - logger: Logger; + logger: Logger_2; }); addToSchedule(task: Function, interval: number): void; start(): void; diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index 9e617b73ed..7eba677439 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -5,7 +5,7 @@ ```ts import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; // @public (undocumented) diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index d433d428e0..947439533a 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -8,7 +8,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import { GeneratorBuilder } from '@backstage/techdocs-common'; import { Knex } from 'knex'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PreparerBuilder } from '@backstage/techdocs-common'; import { PublisherBase } from '@backstage/techdocs-common'; diff --git a/plugins/todo-backend/api-report.md b/plugins/todo-backend/api-report.md index 8bcfe30aee..d203a52b36 100644 --- a/plugins/todo-backend/api-report.md +++ b/plugins/todo-backend/api-report.md @@ -8,7 +8,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import express from 'express'; -import { Logger } from 'winston'; +import { Logger as Logger_2 } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; import { UrlReader } from '@backstage/backend-common'; diff --git a/yarn.lock b/yarn.lock index accbe9389c..55761c6d65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2015,9 +2015,9 @@ subscriptions-transport-ws "^0.9.18" "@graphql-codegen/cli@^1.21.3": - version "1.21.5" - resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.5.tgz#b9041553747cfb2dee7c3473a2e2461ec3e7ada5" - integrity sha512-w3SovNJ9qtMhFLAdPZeCdGvHXDgfdb53mueWDTyncOt04m+tohVnY4qExvyKLTN5zlGxrA/5ubp2x8Az0xQarA== + version "1.21.6" + resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.6.tgz#d06b5f6cb625541f3981d69f99966e520b958072" + integrity sha512-wtBk4lk/YxG6MrxnBOxE9nCfR9PNDjaqA8CF9hi6Q8jiSl4sV03tC2R+gE7+2EI8J6Xa1bxZe15LnBhVwb/mUA== dependencies: "@graphql-codegen/core" "1.17.10" "@graphql-codegen/plugin-helpers" "^1.18.7" @@ -2034,7 +2034,7 @@ ansi-escapes "^4.3.1" chalk "^4.1.0" change-case-all "1.0.14" - chokidar "^3.5.1" + chokidar "^3.5.2" common-tags "^1.8.0" cosmiconfig "^7.0.0" debounce "^1.2.0" @@ -2053,7 +2053,7 @@ mkdirp "^1.0.4" string-env-interpolation "^1.0.1" ts-log "^2.2.3" - tslib "~2.2.0" + tslib "~2.3.0" valid-url "^1.0.9" wrap-ansi "^7.0.0" yaml "^1.10.0" @@ -7458,7 +7458,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -anymatch@^3.0.3, anymatch@~3.1.1: +anymatch@^3.0.3: version "3.1.1" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== @@ -7466,6 +7466,14 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + apollo-cache-control@^0.14.0: version "0.14.0" resolved "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.14.0.tgz#95f20c3e03e7994e0d1bd48c59aeaeb575ed0ce7" @@ -8046,14 +8054,13 @@ axobject-query@^2.2.0: resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== -azure-devops-node-api@^10.1.1: - version "10.2.1" - resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.2.1.tgz#835080164f8c30cec6506c47198b044c053f1f36" - integrity sha512-XuSiUaYpk0tQpd9fD8qfRa5y1IdavupKNVmwxy0w/RhmxG2Wl8uAYnNJchUoWd3Rn9On0mYTCCZSn+UlYdYFSg== +azure-devops-node-api@^10.2.2: + version "10.2.2" + resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.2.2.tgz#9f557e622dd07bbaa9bd5e7e84e17c761e2151b2" + integrity sha512-4TVv2X7oNStT0vLaEfExmy3J4/CzfuXolEcQl/BRUmvGySqKStTG2O55/hUQ0kM7UJlZBLgniM0SBq4d/WkKow== dependencies: tunnel "0.0.6" - typed-rest-client "^1.8.0" - underscore "1.8.3" + typed-rest-client "^1.8.4" babel-code-frame@^6.22.0: version "6.26.0" @@ -9398,20 +9405,20 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.1: - version "3.5.1" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" - integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== +chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.2: + version "3.5.2" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" + integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== dependencies: - anymatch "~3.1.1" + anymatch "~3.1.2" braces "~3.0.2" - glob-parent "~5.1.0" + glob-parent "~5.1.2" is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.5.0" + readdirp "~3.6.0" optionalDependencies: - fsevents "~2.3.1" + fsevents "~2.3.2" chownr@^1.1.1: version "1.1.4" @@ -13562,7 +13569,7 @@ fsevents@^2.1.2: resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== -fsevents@~2.3.1: +fsevents@~2.3.1, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -13905,7 +13912,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.0: +glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -19122,10 +19129,10 @@ node-abi@^2.7.0: dependencies: semver "^5.4.1" -node-addon-api@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" - integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-addon-api@^3.0.0: + version "3.2.1" + resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== node-cache@^5.1.2: version "5.1.2" @@ -20337,9 +20344,9 @@ passport-oauth2@1.2.0: uid2 "0.0.x" passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" - integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== + version "1.6.0" + resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.6.0.tgz#5f599735e0ea40ea3027643785f81a3a9b4feb50" + integrity sha512-emXPLqLcVEcLFR/QvQXZcwLmfK8e9CqvMgmOFJxcNT3okSFMtUbRRKpY20x5euD+01uHsjjCa07DYboEeLXYiw== dependencies: base64url "3.x.x" oauth "0.9.x" @@ -22539,10 +22546,10 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -readdirp@~3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" - integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== dependencies: picomatch "^2.2.1" @@ -24090,12 +24097,12 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -sqlite3@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.0.tgz#1bfef2151c6bc48a3ab1a6c126088bb8dd233566" - integrity sha512-rjvqHFUaSGnzxDy2AHCwhHy6Zp6MNJzCPGYju4kD8yi6bze4d1/zMTg6C7JI49b7/EM7jKMTvyfN/4ylBKdwfw== +sqlite3@^5.0.1: + version "5.0.2" + resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz#00924adcc001c17686e0a6643b6cbbc2d3965083" + integrity sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA== dependencies: - node-addon-api "2.0.0" + node-addon-api "^3.0.0" node-pre-gyp "^0.11.0" optionalDependencies: node-gyp "3.x" @@ -25721,14 +25728,14 @@ type@^2.0.0: resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== -typed-rest-client@^1.8.0: - version "1.8.0" - resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.0.tgz#3b6c22a7cc31b665ec1e4bedb3482ebe12e2fbe6" - integrity sha512-Nu1MrdH6ECrRW5gHoRAdubgCs4oH6q5/J76jsEC8bVDfvVoVPkigukPalhMHPwb7ZvpsZqPptd5zpt/QdtrdBw== +typed-rest-client@^1.8.4: + version "1.8.4" + resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.4.tgz#ba3fb788e5b9322547406392533f12d660a5ced6" + integrity sha512-MyfKKYzk3I6/QQp6e1T50py4qg+c+9BzOEl2rBmQIpStwNUoqQ73An+Tkfy9YuV7O+o2mpVVJpe+fH//POZkbg== dependencies: qs "^6.9.1" tunnel "0.0.6" - underscore "1.8.3" + underscore "^1.12.1" typedarray-to-buffer@^3.1.5: version "3.1.5" @@ -25827,10 +25834,10 @@ undefsafe@^2.0.3: dependencies: debug "^2.2.0" -underscore@1.8.3: - version "1.8.3" - resolved "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" - integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI= +underscore@^1.12.1: + version "1.13.1" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" + integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== underscore@^1.9.1: version "1.11.0"