diff --git a/.changeset/afraid-rockets-yell.md b/.changeset/afraid-rockets-yell.md new file mode 100644 index 0000000000..363c4dcd9f --- /dev/null +++ b/.changeset/afraid-rockets-yell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fix error handling in `LdapOrgReaderProcessor`, and support complex paging options diff --git a/.changeset/cost-insights-fuzzy-boats-float.md b/.changeset/cost-insights-fuzzy-boats-float.md new file mode 100644 index 0000000000..c987363480 --- /dev/null +++ b/.changeset/cost-insights-fuzzy-boats-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +support jsx in alert titles diff --git a/.changeset/fuzzy-ladybugs-drive.md b/.changeset/fuzzy-ladybugs-drive.md new file mode 100644 index 0000000000..3cdc521e19 --- /dev/null +++ b/.changeset/fuzzy-ladybugs-drive.md @@ -0,0 +1,11 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Add Owner field in template card and new data distribution +Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema +Add relations ownedBy and ownerOf into Template entity +Template documentation updated diff --git a/.changeset/improbable-word-combination.md b/.changeset/improbable-word-combination.md new file mode 100644 index 0000000000..1b1abe2cf1 --- /dev/null +++ b/.changeset/improbable-word-combination.md @@ -0,0 +1,17 @@ +--- +'@backstage/backend-common': patch +--- + +All cache-related connection errors are now handled and logged by the cache manager. App Integrators may provide an optional error handler when instantiating the cache manager if custom error handling is needed. + +```typescript +// Providing an error handler +const cacheManager = CacheManager.fromConfig(config, { + onError: e => { + if (isSomehowUnrecoverable(e)) { + gracefullyShutThingsDown(); + process.exit(1); + } + }, +}); +``` diff --git a/.changeset/nasty-zebras-worry.md b/.changeset/nasty-zebras-worry.md new file mode 100644 index 0000000000..18c5bc0416 --- /dev/null +++ b/.changeset/nasty-zebras-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Make UI errors friendlier when importing existing components diff --git a/.changeset/short-schools-pay.md b/.changeset/short-schools-pay.md new file mode 100644 index 0000000000..8466d31c3c --- /dev/null +++ b/.changeset/short-schools-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Added example client diff --git a/.changeset/thick-ads-smash.md b/.changeset/thick-ads-smash.md new file mode 100644 index 0000000000..ef4881f759 --- /dev/null +++ b/.changeset/thick-ads-smash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add branch protection for default branches of scaffolded GitHub repositories diff --git a/ADOPTERS.md b/ADOPTERS.md index 15e4c41655..61ee3e293b 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,28 +1,30 @@ -| Organization | Contact | Description of Use | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | -| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | -| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | -| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | -| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | -| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | -| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | -| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | -| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| Organization | Contact | Description of Use | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | +| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | +| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | +| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 699574b986..aab2af3145 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -706,6 +706,25 @@ You can find out more about the `parameters` key You can find out more about the `steps` key [here](../software-templates/writing-templates.md) +### `spec.owner` [optional] + +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a Template is the singular entity (commonly a team) +that bears ultimate responsibility for the Template, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this Template belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +Template, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + ## Kind: API Describes the following entity kind: diff --git a/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md index c59eb79be7..25ff2a2f3a 100644 --- a/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md +++ b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md @@ -14,12 +14,11 @@ steps which was pretty hard to extend and add new functionality to, difficult to re-use logic between templates. There used to be a fixed pipeline of `preparers`, `templaters`, and `publishers`, which were defined by the backend and needed to be run for each template. This is now changed, to give the -template total control over what - -should be executed as part of the templating run. This makes templates a little -more declarative as you can now register different `actions` or `functions` with -the `scaffolder-backend` which you then can decide how, and in what order, to -run using the template definition YAML file. +template total control over what should be executed as part of the templating +run. This makes templates a little more declarative as you can now register +different `actions` or `functions` with the `scaffolder-backend` which you then +can decide how, and in what order, to run using the template definition YAML +file. We've also made some improvements, and added some helpers to work with cookiecutter. The skeleton for a template can now be stored in a different place diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 23e22fe34b..068220cad3 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -73,7 +73,7 @@ export interface CacheClient { // @public export class CacheManager { forPlugin(pluginId: string): PluginCacheManager; - static fromConfig(config: Config): CacheManager; + static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager; } // @public (undocumented) diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts index 49b5c367dd..3b654e248b 100644 --- a/packages/backend-common/src/cache/CacheClient.ts +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -92,7 +92,8 @@ export class DefaultCacheClient implements CacheClient { const wellFormedKey = Buffer.from(candidateKey).toString('base64'); // Memcache in particular doesn't do well with keys > 250 bytes. - if (wellFormedKey.length < 250) { + // Padded because a plugin ID is also prepended to the key. + if (wellFormedKey.length < 200) { return wellFormedKey; } diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts index ece4687ee6..97c1714c53 100644 --- a/packages/backend-common/src/cache/CacheManager.test.ts +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -86,6 +86,16 @@ describe('CacheManager', () => { expect(client).toHaveBeenCalledTimes(1); }); + it('attaches error handler to client', () => { + const pluginId = 'error-test'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + it('provides different plugins different cache clients', async () => { const plugin1Id = 'test1'; const plugin2Id = 'test2'; @@ -163,4 +173,53 @@ describe('CacheManager', () => { expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); }); }); + + describe('connection errors', () => { + it('uses provided logger', () => { + // Set up and inject mock logger. + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + const manager = CacheManager.fromConfig(defaultConfig(), { + logger: mockLogger as any, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-logger-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockLogger.error).toHaveBeenCalledWith(expectedError); + }); + + it('calls provided handler', () => { + // Set up and inject mock logger. + const mockHandler = jest.fn(); + const manager = CacheManager.fromConfig(defaultConfig(), { + onError: mockHandler, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-handler-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockHandler).toHaveBeenCalledWith(expectedError); + }); + }); }); diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts index fbd7e3ba0a..e6699e628e 100644 --- a/packages/backend-common/src/cache/CacheManager.ts +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -18,9 +18,15 @@ import { Config } from '@backstage/config'; import Keyv from 'keyv'; // @ts-expect-error import KeyvMemcache from 'keyv-memcache'; +import { Logger } from 'winston'; +import { getRootLogger } from '../logging'; import { DefaultCacheClient, CacheClient } from './CacheClient'; import { NoStore } from './NoStore'; -import { PluginCacheManager } from './types'; +import { + CacheManagerOptions, + OptionalOnError, + PluginCacheManager, +} from './types'; /** * Implements a Cache Manager which will automatically create new cache clients @@ -38,8 +44,10 @@ export class CacheManager { none: this.getNoneClient, }; + private readonly logger: Logger; private readonly store: keyof CacheManager['storeFactories']; private readonly connection: string; + private readonly errorHandler: OptionalOnError; /** * Creates a new CacheManager instance by reading from the `backend` config @@ -47,25 +55,38 @@ export class CacheManager { * * @param config The loaded application configuration. */ - static fromConfig(config: Config): CacheManager { + static fromConfig( + config: Config, + options: CacheManagerOptions = {}, + ): CacheManager { // If no `backend.cache` config is provided, instantiate the CacheManager // with a "NoStore" cache client. const store = config.getOptionalString('backend.cache.store') || 'none'; const connectionString = config.getOptionalString('backend.cache.connection') || ''; - return new CacheManager(store, connectionString); + const logger = (options.logger || getRootLogger()).child({ + type: 'cacheManager', + }); + return new CacheManager(store, connectionString, logger, options.onError); } - private constructor(store: string, connectionString: string) { + private constructor( + store: string, + connectionString: string, + logger: Logger, + errorHandler: OptionalOnError, + ) { if (!this.storeFactories.hasOwnProperty(store)) { throw new Error(`Unknown cache store: ${store}`); } + this.logger = logger; this.store = store as keyof CacheManager['storeFactories']; this.connection = connectionString; + this.errorHandler = errorHandler; } /** - * Generates a CacheManagerInstance for consumption by plugins. + * Generates a PluginCacheManager for consumption by plugins. * * @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique. */ @@ -73,6 +94,18 @@ export class CacheManager { return { getClient: (opts = {}): CacheClient => { const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl); + + // Always provide an error handler to avoid killing the process. + concreteClient.on('error', (err: Error) => { + // In all cases, just log the error. + this.logger.error(err); + + // Invoke any custom error handler if provided. + if (typeof this.errorHandler === 'function') { + this.errorHandler(err); + } + }); + return new DefaultCacheClient({ client: concreteClient, }); diff --git a/packages/backend-common/src/cache/types.ts b/packages/backend-common/src/cache/types.ts index 4dd3cf7d1b..30db53d420 100644 --- a/packages/backend-common/src/cache/types.ts +++ b/packages/backend-common/src/cache/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Logger } from 'winston'; import { CacheClient } from './CacheClient'; type ClientOptions = { @@ -25,6 +26,21 @@ type ClientOptions = { defaultTtl?: number; }; +export type OptionalOnError = ((err: Error) => void) | undefined; + +export type CacheManagerOptions = { + /** + * An optional logger for use by the PluginCacheManager. + */ + logger?: Logger; + + /** + * An optional handler for connection errors emitted from the underlying data + * store. + */ + onError?: OptionalOnError; +}; + /** * The PluginCacheManager manages access to cache stores that Plugins get. */ diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 00eaf8410e..890a45b6e7 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -487,6 +487,7 @@ interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } @@ -520,6 +521,7 @@ export interface TemplateEntityV1beta2 extends Entity { output?: { [name: string]: string; }; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index e3d29aad4f..bfb27b4ed6 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => { }, }, }, + owner: 'team-a@example.com', }, }; }); @@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => { (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 index 72479c7efa..64721e3739 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -33,6 +33,7 @@ export interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 958a486687..458bdcd0aa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -59,6 +59,7 @@ describe('templateEntityV1beta2Validator', () => { output: { fetchUrl: '{{ steps.fetch.output.targetUrl }}', }, + owner: 'team-b@example.com', }, }; }); @@ -121,4 +122,16 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps[0].action; await expect(validator.check(entity)).rejects.toThrow(/action/); }); + 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/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 1f4e3bcd90..7a100de4bb 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -41,6 +41,7 @@ export interface TemplateEntityV1beta2 extends Entity { input?: JsonObject; }>; output?: { [name: string]: string }; + owner?: string; }; } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 53109ac7ee..5a0c0efa73 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -85,6 +85,11 @@ "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/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index b88b344626..a3d8e6ba86 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -168,6 +168,11 @@ "additionalProperties": { "type": "string" } + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index cb60c119b6..3a8c061892 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -21,6 +21,7 @@ import { GroupEntity, ResourceEntity, SystemEntity, + TemplateEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -520,5 +521,47 @@ describe('BuiltinKindsEntityProcessor', () => { }, }); }); + it('generates relations for template entities', async () => { + const entity: TemplateEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { name: 'n' }, + spec: { + schema: { + properties: { + description: { + title: 'd', + type: 'string', + description: 'des', + }, + }, + }, + templater: 'cookiecutter', + path: '.', + type: 'service', + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Template', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Template', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 404d7fd30d..f9acae6b4f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -46,6 +46,7 @@ import { resourceEntityV1alpha1Validator, SystemEntity, systemEntityV1alpha1Validator, + TemplateEntity, templateEntityV1alpha1Validator, templateEntityV1beta2Validator, UserEntity, @@ -131,6 +132,19 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { } } + /* + * Emit relations for the Template kind + */ + if (entity.kind === 'Template') { + const template = entity as TemplateEntity; + doEmit( + template.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + /* * Emit relations for the Component kind */ diff --git a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts index d863142169..474e813590 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts @@ -69,7 +69,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { // Be lazy and create the client each time; even though it's pretty // inefficient, we usually only do this once per entire refresh loop and // don't have to worry about timeouts and reconnects etc. - const client = await LdapClient.create(provider.target, provider.bind); + const client = await LdapClient.create( + this.logger, + provider.target, + provider.bind, + ); const { users, groups } = await readLdapOrg( client, provider.users, diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts index bf0d0f740e..0557f21003 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts @@ -15,6 +15,7 @@ */ import ldap, { Client, SearchEntry, SearchOptions } from 'ldapjs'; +import { Logger } from 'winston'; import { BindConfig } from './config'; import { errorString } from './util'; import { @@ -31,8 +32,20 @@ import { export class LdapClient { private vendor: Promise | undefined; - static async create(target: string, bind?: BindConfig): Promise { + static async create( + logger: Logger, + target: string, + bind?: BindConfig, + ): Promise { const client = ldap.createClient({ url: target }); + + // We want to have a catch-all error handler at the top, since the default + // behavior of the client is to blow up the entire process when it fails, + // unless an error handler is set. + client.on('error', (err: ldap.Error) => { + logger.warn(`LDAP client threw an error, ${errorString(err)}`); + }); + if (!bind) { return new LdapClient(client); } @@ -92,7 +105,7 @@ export class LdapClient { }); }); } catch (e) { - throw new Error(`LDAP search at ${dn} failed, ${e.message}`); + throw new Error(`LDAP search at DN "${dn}" failed, ${e.message}`); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 607581e153..755c173d19 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -105,7 +105,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { @@ -153,7 +156,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index aca02e6e10..417657b244 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -180,11 +180,32 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { if (!c) { return {}; } + + const paged = readOptionsPagedConfig(c); + return { scope: c.getOptionalString('scope') as SearchOptions['scope'], filter: formatFilter(c.getOptionalString('filter')), attributes: c.getOptionalStringArray('attributes'), - paged: c.getOptionalBoolean('paged'), + ...(paged !== undefined ? { paged } : undefined), + }; + } + + function readOptionsPagedConfig(c: Config): SearchOptions['paged'] { + const pagedConfig = c.getOptional('paged'); + if (pagedConfig === undefined) { + return undefined; + } + + if (pagedConfig === true || pagedConfig === false) { + return pagedConfig; + } + + const pageSize = c.getOptionalNumber('paged.pageSize'); + const pagePause = c.getOptionalBoolean('paged.pagePause'); + return { + ...(pageSize !== undefined ? { pageSize } : undefined), + ...(pagePause !== undefined ? { pagePause } : undefined), }; } @@ -258,7 +279,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { } function formatFilter(filter?: string): string | undefined { - // Remove extra whitespaces between blocks to support multiline filters from the configuration + // Remove extra whitespace between blocks to support multiline filters from the configuration return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); } diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx index 49bd776db5..09f6230fdc 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -108,7 +108,7 @@ export const StepInitAnalyzeUrl = ({ } } } catch (e) { - setError(e.message); + setError(e.data?.error?.message ?? e.message); setSubmitted(false); } }, diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 3fee89e77a..2311937890 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -25,9 +25,7 @@ yarn add @backstage/plugin-cost-insights 1. Configure `app-config.yaml`. See [Configuration](#configuration). -2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. - -**Note:** We've briefly explored using the AWS Cost Explorer API to implement a CostInsights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). +2. Create a CostInsights client. Clients must implement the [CostInsightsApi](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) interface. Create your own or [use a template](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/example/templates/CostInsightsClient.ts) to get started. ```ts // path/to/CostInsightsClient.ts @@ -36,7 +34,9 @@ import { CostInsightsApi } from '@backstage/plugin-cost-insights'; export class CostInsightsClient implements CostInsightsApi { ... } ``` -3. Import the client and the CostInsights plugin API to your Backstage instance. +**Note:** We've briefly explored using the AWS Cost Explorer API to implement a Cost Insights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). + +3. Import the client and the Cost Insights plugin API to your Backstage instance. ```ts // packages/app/src/api.ts diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index 70ae44bcdc..b686076e17 100644 --- a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -43,4 +43,22 @@ describe('', () => { expect(rendered.getByText(alert.title)).toBeInTheDocument(); expect(rendered.getByText(alert.subtitle)).toBeInTheDocument(); }); + + it('renders custom title elements', async () => { + const rendered = await renderInTestApp( + + Foo, + subtitle: Bar, + }} + avatar={
1
} + /> +
, + ); + + expect(rendered.getByText('Foo')).toBeInTheDocument(); + expect(rendered.getByText('Bar')).toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 093ca1ff0c..f9fe985e43 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -41,14 +41,33 @@ describe('', () => { onAccept={jest.fn()} />, ); - expect(getByText(mockAlert.title)).toBeInTheDocument(); - expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); + expect(getByText(mockAlert.title as string)).toBeInTheDocument(); + expect(getByText(mockAlert.subtitle as string)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); expect(queryByText('Snooze')).not.toBeInTheDocument(); expect(queryByText('Accept')).not.toBeInTheDocument(); expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); + it('renders custom title elements', () => { + const { getByText } = renderInContext( + Foo, + subtitle: Bar, + }} + number={1} + onSnooze={jest.fn()} + onDismiss={jest.fn()} + onAccept={jest.fn()} + />, + ); + + expect(getByText('Foo')).toBeInTheDocument(); + expect(getByText('Bar')).toBeInTheDocument(); + }); + it('Hides instructions button if url is not provided', () => { const alert: Alert = { ...mockAlert, diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx index ab39e0364e..754bc6db5c 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -50,8 +50,8 @@ describe('', () => { , ); [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { - expect(getByText(a.title)).toBeInTheDocument(); - expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByText(a.title as string)).toBeInTheDocument(); + expect(getByText(a.subtitle as string)).toBeInTheDocument(); expect(getByRole('img', { name: a.status })).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx index 4878462f62..8b386f338c 100644 --- a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx @@ -17,6 +17,7 @@ import React from 'react'; import pluralize from 'pluralize'; import { KubernetesMigrationAlertCard } from '../components'; +import { Lifecycle } from '@backstage/core'; import { CostInsightsApi } from '../../api'; import { Alert, @@ -87,11 +88,13 @@ export class KubernetesMigrationAlert implements KubernetesMigrationApi { } get title() { - return `Consider migrating ${pluralize( - 'service', - this.data.services.length, - true, - )} to Kubernetes.`; + return ( + + Consider migrating{' '} + {pluralize('service', this.data.services.length, true)} to Kubernetes{' '} + + + ); } get element() { diff --git a/plugins/cost-insights/src/example/templates/CostInsightsClient.ts b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts new file mode 100644 index 0000000000..f7e4686961 --- /dev/null +++ b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2020 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. + */ + +/* + This is a copy-pastable client template to get up and running quickly. + API Reference: + https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts +*/ + +// IMPORTANT: Remove the lines below to enable type checking and linting +// @ts-nocheck +/* eslint-disable import/no-extraneous-dependencies */ + +import { + CostInsightsApi, + ProductInsightsOptions, + Alert, + Cost, + Entity, + Group, + MetricData, + Project, +} from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { + + async getLastCompleteBillingDate(): Promise { + return '2021-01-01'; // YYYY-MM-DD + } + + async getUserGroups(userId: string): Promise { + return []; + } + + async getGroupProjects(group: string): Promise { + return []; + } + + async getAlerts(group: string): Promise { + return []; + } + + async getDailyMetricData(metric: string, intervals: string): Promise { + return { + id: 'remove-me', + format: 'number', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getGroupDailyCost(group: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProjectDailyCost(project: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProductInsights(options: ProductInsightsOptions): Promise { + return { + id: 'remove-me', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0 + }, + entities: {} + } + } +} diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 84c37a7c15..a8b041a67e 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -34,8 +34,8 @@ import { Maybe } from './Maybe'; */ export type Alert = { - title: string; - subtitle: string; + title: string | JSX.Element; + subtitle: string | JSX.Element; element?: JSX.Element; status?: AlertStatus; url?: string; diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx index 9fbd2bc925..ba3ee536f6 100644 --- a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx @@ -76,7 +76,7 @@ export function InDepth() { - Longest release:{' '} + Lengthiest release:{' '} 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 66dfc1d655..e56c94a5b0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -19,7 +19,10 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from '@octokit/rest'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from '../../../stages/publish/helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; @@ -171,7 +174,7 @@ export function createPublishGithubAction(options: { description: description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); await client.teams.addOrUpdateRepoPermissionsInOrg({ @@ -212,8 +215,8 @@ export function createPublishGithubAction(options: { } } - const remoteUrl = data.clone_url; - const repoContentsUrl = `${data.html_url}/blob/master`; + const remoteUrl = newRepo.clone_url; + const repoContentsUrl = `${newRepo.html_url}/blob/master`; await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), @@ -225,6 +228,18 @@ export function createPublishGithubAction(options: { logger: ctx.logger, }); + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: newRepo.name, + }); + } catch (e) { + throw new Error( + `Failed to add branch protection to '${newRepo.name}', ${e}`, + ); + } + ctx.output('remoteUrl', remoteUrl); ctx.output('repoContentsUrl', repoContentsUrl); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index c782037ec8..c1da51c40e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -60,6 +60,7 @@ describe('JobProcessor', () => { }, }, }, + owner: 'example@email.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index fc36eb64e5..c314f7c727 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -55,6 +55,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-d@example.com', }, }; @@ -103,6 +104,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -151,6 +153,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-a@example.com', }, }; @@ -198,6 +201,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -243,6 +247,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-c@example.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 829856ec0c..2647284698 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,7 +15,10 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { initRepoAndPush } from './helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from './helpers'; import { GitHubIntegrationConfig, GithubCredentialsProvider, @@ -26,6 +29,7 @@ import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; +/** @deprecated use createPublishGithubAction instead */ export class GithubPublisher implements PublisherBase { static async fromConfig( config: GitHubIntegrationConfig, @@ -99,6 +103,17 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); + + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: name, + }); + } catch (e) { + throw new Error(`Failed to add branch protection to '${name}', ${e}`); + } + return { remoteUrl, catalogInfoUrl }; } @@ -130,7 +145,7 @@ export class GithubPublisher implements PublisherBase { description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; try { if (access?.startsWith(`${owner}/`)) { @@ -156,6 +171,7 @@ export class GithubPublisher implements PublisherBase { `Failed to add access to '${access}'. Status ${e.status} ${e.message}`, ); } - return data?.clone_url; + + return newRepo.clone_url; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts index ffefb88cf0..79c93128c6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -17,6 +17,7 @@ import globby from 'globby'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; +import { Octokit } from '@octokit/rest'; export async function initRepoAndPush({ dir, @@ -67,3 +68,50 @@ export async function initRepoAndPush({ remote: 'origin', }); } + +type BranchProtectionOptions = { + client: Octokit; + owner: string; + repoName: string; + isRetry?: boolean; +}; + +export const enableBranchProtectionOnDefaultRepoBranch = async ({ + repoName, + client, + owner, +}: BranchProtectionOptions): Promise => { + const tryOnce = () => { + return client.repos.updateBranchProtection({ + mediaType: { + /** + * 👇 we need this preview because allowing a custom + * reviewer count on branch protection is a preview + * feature + * + * More here: https://docs.github.com/en/rest/overview/api-previews#require-multiple-approving-reviews + */ + previews: ['luke-cage-preview'], + }, + owner, + repo: repoName, + branch: 'master', + required_status_checks: { strict: true, contexts: [] }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { required_approving_review_count: 1 }, + }); + }; + + try { + await tryOnce(); + } catch (e) { + if (!e.message.includes('Branch not found')) { + throw e; + } + + // GitHub has eventual consistency. Fail silently, wait, and try again. + await new Promise(resolve => setTimeout(resolve, 600)); + await tryOnce(); + } +}; diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 7bdaa0a671..bb56f7fb6e 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -32,24 +32,42 @@ import React from 'react'; import WarningIcon from '@material-ui/icons/Warning'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + TemplateEntityV1alpha1, + Entity, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; +import { + getEntityRelations, + EntityRefLinks, +} from '@backstage/plugin-catalog-react'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ cardHeader: { position: 'relative', }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, - description: { + box: { overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', '-webkit-line-clamp': 10, '-webkit-box-orient': 'vertical', + paddingBottom: '0.8em', }, -}); + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '0.65rem', + fontWeight: 'bold', + letterSpacing: 0.5, + lineHeight: 1, + paddingBottom: '0.2rem', + }, +})); const useDeprecationStyles = makeStyles(theme => ({ deprecationIcon: { @@ -116,7 +134,10 @@ export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); const templateProps = getTemplateCardProps(template); - + const ownedByRelations = getEntityRelations( + template as Entity, + RELATION_OWNED_BY, + ); const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); @@ -135,13 +156,27 @@ export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => { classes={{ root: classes.title }} /> - + + + + Description + + {templateProps.description} + + + + Owner + + + + + Tags + {templateProps.tags?.map(tag => ( ))} - {templateProps.description}