Merge branch 'master' into blam/deprecate-v1alpha1

This commit is contained in:
Ben Lambert
2021-05-20 12:08:35 +02:00
committed by GitHub
44 changed files with 669 additions and 80 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Fix error handling in `LdapOrgReaderProcessor`, and support complex paging options
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': minor
---
support jsx in alert titles
+11
View File
@@ -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
+17
View File
@@ -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);
}
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
Make UI errors friendlier when importing existing components
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Added example client
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add branch protection for default branches of scaffolded GitHub repositories
+30 -28
View File
@@ -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 teams 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 teams 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. |
@@ -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:
@@ -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
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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;
}
+59
View File
@@ -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);
});
});
});
+38 -5
View File
@@ -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,
});
+16
View File
@@ -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.
*/
+2
View File
@@ -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;
};
}
@@ -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/);
});
});
@@ -33,6 +33,7 @@ export interface TemplateEntityV1alpha1 extends Entity {
templater: string;
path?: string;
schema: JSONSchema;
owner?: string;
};
}
@@ -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/);
});
});
@@ -41,6 +41,7 @@ export interface TemplateEntityV1beta2 extends Entity {
input?: JsonObject;
}>;
output?: { [name: string]: string };
owner?: string;
};
}
@@ -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
}
}
}
@@ -168,6 +168,11 @@
"additionalProperties": {
"type": "string"
}
},
"owner": {
"type": "string",
"description": "The user (or group) owner of the template",
"minLength": 1
}
}
}
@@ -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' },
},
});
});
});
});
@@ -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
*/
@@ -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,
@@ -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<LdapVendor> | undefined;
static async create(target: string, bind?: BindConfig): Promise<LdapClient> {
static async create(
logger: Logger,
target: string,
bind?: BindConfig,
): Promise<LdapClient> {
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}`);
}
}
@@ -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: {
@@ -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();
}
@@ -108,7 +108,7 @@ export const StepInitAnalyzeUrl = ({
}
}
} catch (e) {
setError(e.message);
setError(e.data?.error?.message ?? e.message);
setSubmitted(false);
}
},
+4 -4
View File
@@ -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
@@ -43,4 +43,22 @@ describe('<ActionItemCard/>', () => {
expect(rendered.getByText(alert.title)).toBeInTheDocument();
expect(rendered.getByText(alert.subtitle)).toBeInTheDocument();
});
it('renders custom title elements', async () => {
const rendered = await renderInTestApp(
<MockScrollProvider>
<ActionItemCard
alert={{
...alert,
title: <span>Foo</span>,
subtitle: <span>Bar</span>,
}}
avatar={<div>1</div>}
/>
</MockScrollProvider>,
);
expect(rendered.getByText('Foo')).toBeInTheDocument();
expect(rendered.getByText('Bar')).toBeInTheDocument();
});
});
@@ -41,14 +41,33 @@ describe('<AlertInsightsSection/>', () => {
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(
<AlertInsightsSection
alert={{
...mockAlert,
title: <span>Foo</span>,
subtitle: <span>Bar</span>,
}}
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,
@@ -50,8 +50,8 @@ describe('<AlertStatusSummary />', () => {
</MockScrollProvider>,
);
[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();
});
});
@@ -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 (
<span>
Consider migrating{' '}
{pluralize('service', this.data.services.length, true)} to Kubernetes{' '}
<Lifecycle shorthand />
</span>
);
}
get element() {
@@ -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<string> {
return '2021-01-01'; // YYYY-MM-DD
}
async getUserGroups(userId: string): Promise<Group[]> {
return [];
}
async getGroupProjects(group: string): Promise<Project[]> {
return [];
}
async getAlerts(group: string): Promise<Alert[]> {
return [];
}
async getDailyMetricData(metric: string, intervals: string): Promise<MetricData> {
return {
id: 'remove-me',
format: 'number',
aggregation: [],
change: {
ratio: 0,
amount: 0
}
}
}
async getGroupDailyCost(group: string, intervals: string): Promise<Cost> {
return {
id: 'remove-me',
aggregation: [],
change: {
ratio: 0,
amount: 0
}
}
}
async getProjectDailyCost(project: string, intervals: string): Promise<Cost> {
return {
id: 'remove-me',
aggregation: [],
change: {
ratio: 0,
amount: 0
}
}
}
async getProductInsights(options: ProductInsightsOptions): Promise<Entity> {
return {
id: 'remove-me',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0
},
entities: {}
}
}
}
+2 -2
View File
@@ -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;
@@ -76,7 +76,7 @@ export function InDepth() {
</Typography>
<Typography variant="body2" color="textSecondary">
<strong>Longest release</strong>:{' '}
<strong>Lengthiest release</strong>:{' '}
<LongestReleaseTime averageReleaseTime={averageReleaseTime} />
</Typography>
</Box>
@@ -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);
},
@@ -60,6 +60,7 @@ describe('JobProcessor', () => {
},
},
},
owner: 'example@email.com',
},
};
@@ -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',
},
};
@@ -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;
}
}
@@ -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<void> => {
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();
}
};
@@ -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<BackstageTheme>();
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 }}
/>
</CardMedia>
<CardContent>
<CardContent style={{ display: 'grid' }}>
<Box className={classes.box}>
<Typography variant="body2" className={classes.label}>
Description
</Typography>
{templateProps.description}
</Box>
<Box className={classes.box}>
<Typography variant="body2" className={classes.label}>
Owner
</Typography>
<EntityRefLinks entityRefs={ownedByRelations} defaultKind="Group" />
</Box>
<Box>
<Typography variant="body2" className={classes.label}>
Tags
</Typography>
{templateProps.tags?.map(tag => (
<Chip size="small" label={tag} key={tag} />
))}
</Box>
<Box className={classes.description}>{templateProps.description}</Box>
</CardContent>
<CardActions>
<Button
+6 -1
View File
@@ -20635,11 +20635,16 @@ pg-connection-string@0.1.3, pg-connection-string@^0.1.3:
resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7"
integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=
pg-connection-string@2.4.0, pg-connection-string@^2.3.0, pg-connection-string@^2.4.0:
pg-connection-string@2.4.0:
version "2.4.0"
resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ==
pg-connection-string@^2.3.0, pg-connection-string@^2.4.0:
version "2.5.0"
resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"