Merge branch 'backstage:master' into patch-1

This commit is contained in:
Tomás Mota
2023-07-31 13:19:06 +02:00
committed by GitHub
172 changed files with 2672 additions and 1033 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/config-loader': minor
---
Loading invalid TypeScript configuration schemas will now throw an error rather than silently being ignored.
In particular this includes defining any additional types other than `Config` in the schema file, or use of unsupported types such as `Record` or `Partial`.
+91
View File
@@ -0,0 +1,91 @@
---
'@backstage/plugin-scaffolder-backend-module-confluence-to-markdown': patch
'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch
'@backstage/plugin-events-backend-module-bitbucket-cloud': patch
'@backstage/plugin-search-backend-module-elasticsearch': patch
'@backstage/plugin-catalog-backend-module-unprocessed': patch
'@backstage/plugin-scaffolder-backend-module-sentry': patch
'@backstage/plugin-scaffolder-backend-module-yeoman': patch
'@backstage/plugin-scaffolder-backend-module-rails': patch
'@backstage/plugin-search-backend-module-techdocs': patch
'@backstage/plugin-cicd-statistics-module-gitlab': patch
'@backstage/plugin-events-backend-module-aws-sqs': patch
'@backstage/plugin-search-backend-module-catalog': patch
'@backstage/plugin-search-backend-module-explore': patch
'@backstage/plugin-events-backend-module-gerrit': patch
'@backstage/plugin-events-backend-module-github': patch
'@backstage/plugin-events-backend-module-gitlab': patch
'@backstage/plugin-events-backend-module-azure': patch
'@backstage/plugin-events-backend-test-utils': patch
'@backstage/plugin-search-backend-module-pg': patch
'@backstage/plugin-entity-feedback-backend': patch
'@backstage/backend-openapi-utils': patch
'@backstage/plugin-bitbucket-cloud-common': patch
'@backstage/plugin-entity-feedback-common': patch
'@backstage/plugin-code-coverage-backend': patch
'@backstage/plugin-user-settings-backend': patch
'@backstage/plugin-analytics-module-ga4': patch
'@backstage/plugin-azure-devops-backend': patch
'@backstage/plugin-analytics-module-ga': patch
'@backstage/plugin-git-release-manager': patch
'@backstage/plugin-search-backend-node': patch
'@backstage/integration-react': patch
'@backstage/plugin-github-deployments': patch
'@backstage/plugin-microsoft-calendar': patch
'@backstage/plugin-newrelic-dashboard': patch
'@backstage/plugin-permission-backend': patch
'@backstage/plugin-entity-validation': patch
'@backstage/plugin-sonarqube-backend': patch
'@backstage/plugin-airbrake-backend': patch
'@backstage/plugin-devtools-backend': patch
'@backstage/plugin-linguist-backend': patch
'@backstage/plugin-periskop-backend': patch
'@backstage/plugin-playlist-backend': patch
'@backstage/plugin-apollo-explorer': patch
'@backstage/plugin-devtools-common': patch
'@backstage/plugin-entity-feedback': patch
'@backstage/plugin-explore-backend': patch
'@backstage/plugin-graphql-voyager': patch
'@backstage/plugin-jenkins-backend': patch
'@backstage/plugin-linguist-common': patch
'@backstage/plugin-playlist-common': patch
'@backstage/plugin-scaffolder-node': patch
'@backstage/plugin-apache-airflow': patch
'@backstage/plugin-bazaar-backend': patch
'@backstage/plugin-events-backend': patch
'@backstage/plugin-jenkins-common': patch
'@backstage/plugin-octopus-deploy': patch
'@backstage/plugin-search-backend': patch
'@backstage/plugin-stack-overflow': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-code-coverage': patch
'@backstage/plugin-config-schema': patch
'@backstage/plugin-github-issues': patch
'@backstage/plugin-nomad-backend': patch
'@backstage/plugin-tech-insights': patch
'@backstage/plugin-catalog-node': patch
'@backstage/plugin-code-climate': patch
'@backstage/plugin-events-node': patch
'@backstage/plugin-firehydrant': patch
'@backstage/cli-node': patch
'@backstage/plugin-auth-node': patch
'@backstage/plugin-codescene': patch
'@backstage/plugin-dynatrace': patch
'@backstage/plugin-gcalendar': patch
'@backstage/plugin-org-react': patch
'@backstage/plugin-shortcuts': patch
'@backstage/plugin-xcmetrics': patch
'@backstage/plugin-airbrake': patch
'@backstage/plugin-periskop': patch
'@backstage/plugin-playlist': patch
'@backstage/plugin-bitrise': patch
'@backstage/plugin-allure': patch
'@backstage/plugin-bazaar': patch
'@backstage/plugin-ilert': patch
'@backstage/plugin-kafka': patch
'@backstage/plugin-nomad': patch
'@backstage/plugin-adr': patch
'@backstage/plugin-org': patch
---
Add package repository and homepage metadata
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
Added option to skip forked repos in GitlabDiscoveryEntityProvider
+14
View File
@@ -0,0 +1,14 @@
---
'@backstage/plugin-devtools-backend': patch
---
Add DevTools configuration to enable dependency listing to be filtered with custom prefixes. For instance, in your `app-config.yaml`:
```yaml
devTools:
info:
packagePrefixes:
- @backstage/
- @roadiehq/backstage-
- @spotify/backstage-
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-sonarqube': patch
---
Remove sonarQube card disable class
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/plugin-catalog-backend-module-bitbucket-server': patch
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch
'@backstage/plugin-catalog-backend-module-puppetdb': patch
'@backstage/plugin-catalog-backend-module-msgraph': patch
'@backstage/plugin-catalog-backend-module-gerrit': patch
'@backstage/plugin-catalog-backend-module-github': patch
'@backstage/plugin-catalog-backend-module-gitlab': patch
'@backstage/plugin-events-backend-module-aws-sqs': patch
'@backstage/plugin-catalog-backend-module-azure': patch
'@backstage/plugin-catalog-backend-module-aws': patch
'@backstage/backend-common': patch
'@backstage/plugin-jenkins-backend': patch
'@backstage/plugin-proxy-backend': patch
---
Fixed invalid configuration schema. The configuration schema may be more strict as a result.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-confluence-to-markdown': patch
---
Use `fetchContents` directly instead of a `fetchPlainAction`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Updated config schema to match what was being used in code
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-plugin-api': minor
'@backstage/backend-defaults': minor
---
**BREAKING**: Removing shared environments concept from the new experimental backend system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Added OpenTelemetry spans for catalog processing
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-aws': patch
---
Added option to configure AWS `accountId` in `AwsS3EntityProvider`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gcp': minor
---
Added GCP catalog plugin with GKE provider
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-microsoft-calendar': patch
---
README update - example of apiRef definition and fixed component name
+2
View File
@@ -254,3 +254,5 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/
| [Celonis](https://celonis.com) | [@georgeyord](https://github.com/georgeyord), [@LauraMoraB](https://github.com/LauraMoraB), [@mariosant](https://github.com/mariosant), [@Rbillon59](https://github.com/Rbillon59) | Internal developer and product portal implementation. |
| [Rabobank](https://www.rabobank.com) | [Willem Dekker](https://github.com/wdekker), [Sridhar Gnanasekaran](https://github.com/srid99), [Ruben Ernst](https://github.com/Ruben-E) | At Rabobank, our mission is to make life better for engineers. After exploring various options in the market, we've discovered that Backstage provides the perfect foundation for creating a platform that meets our engineers' needs and helps us achieve our goals. We want to make our engineers happy by offering standardized services, freeing them up to focus on delivering value. Our vision is to create a one-stop platform where engineers can find everything related to software and services. In practice, we're excited about using all the cool features in Backstage, and we're even planning to build our own plugins. |
| [JB Hi-Fi](https://www.jbhifi.com.au) | [@bahman](https://github.com/bahman-jb/) | We use Backstage as a central place for all of our components/apis across multiple teams. It helps us to quickly identify the ownership of a component/api, find the related links and have a quick access to CI/CD pipelines. |
| [Zenklub](https://www.zenklub.com.br) | [@zenklub](https://github.com/zenklub), [@gioufop](https://github.com/gioufop) | Developer portal, services catalog and centralization of metrics from Grafana Stack and AWS. Furthermore, centralization of documentation and infra details like Tools, Network services and so on. |
| [Platzi](https://platzi.com/) | [@juancarestre](https://github.com/juancarestre/), [Engineering at Platzi](https://github.com/PlatziDev/) | Backstage allow our developers to get easily engaged with all the internal components, technical documentations and software templates. All new developers reduce its onboarding time via Backstage, and after a couple of integrations it allowed us to create new components with in a couple of clicks. |
+4 -4
View File
@@ -211,7 +211,7 @@ integrations:
# clientEmail: 'example@example.com'
# privateKey: ${GCS_PRIVATE_KEY}
awsS3:
- host: amazonaws.com
- endpoint: ${AWS_S3_ENDPOINT}
accessKeyId: ${AWS_ACCESS_KEY_ID}
secretAccessKey: ${AWS_SECRET_ACCESS_KEY}
@@ -352,8 +352,6 @@ auth:
metadataUrl: ${AUTH_OIDC_METADATA_URL}
clientId: ${AUTH_OIDC_CLIENT_ID}
clientSecret: ${AUTH_OIDC_CLIENT_SECRET}
authorizationUrl: ${AUTH_OIDC_AUTH_URL}
tokenUrl: ${AUTH_OIDC_TOKEN_URL}
tokenEndpointAuthMethod: ${AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD} # default='client_secret_basic'
tokenSignedResponseAlg: ${AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG} # default='RS256'
scope: ${AUTH_OIDC_SCOPE} # default='openid profile email'
@@ -434,8 +432,10 @@ costInsights:
kind: 'PINTS_OF_ICE_CREAM'
unit: 'ice cream pint'
rate: 5.5
pagerduty:
pagerDuty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
jenkins:
instances:
- name: default
+23 -16
View File
@@ -58,6 +58,7 @@ When using ALB authentication Backstage will only be loaded once the user has su
import React from 'react';
import { UserIdentity } from '@backstage/core-components';
import { SignInPageProps } from '@backstage/core-app-api';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
const SampleSignInComponent: any = (props: SignInPageProps) => {
const [error, setError] = React.useState<string | undefined>();
@@ -90,8 +91,8 @@ const SampleSignInComponent: any = (props: SignInPageProps) => {
},
}),
);
} catch (err) {
setError(err.message);
} catch (err: any) {
setError(err.message as string);
}
}
}, [config]);
@@ -135,12 +136,14 @@ export default async function createPlugin({
database,
config,
discovery,
tokenManager,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
config,
database,
discovery,
tokenManager,
providerFactories: {
awsalb: providers.awsAlb.create({
authHandler: async ({ fullProfile }) => {
@@ -168,25 +171,29 @@ export default async function createPlugin({
};
},
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
const [id] = email?.split('@') ?? '';
// Fetch from an external system that returns entity claims like:
// ['user:default/breanna.davison', ...]
const userEntityRef = stringifyEntityRef({
resolver: async ({ profile }, ctx) => {
if (!profile.email) {
throw new Error('Profile contained no email');
}
const [id] = profile.email.split('@');
if (!id) {
throw new Error('Invalid email format');
}
const userRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
namespace: DEFAULT_NAMESPACE,
});
// Resolve group membership from the Backstage catalog
const fullEnt =
await ctx.catalogIdentityClient.resolveCatalogMembership({
entityRefs: [id].concat([userEntityRef]),
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userEntityRef, ent: fullEnt },
const { token } = await ctx.issueToken({
claims: {
sub: userRef,
ent: [userRef],
},
});
return { id, token };
},
},
+1 -1
View File
@@ -59,7 +59,7 @@ Just like plugins, modules also have access to services and can depend on their
A detailed explanation of the package architecture can be found in the
[Backstage Architecture
Overview](../../overview/architecture-overview.md#package-architecture). The
Overview](../../overview/architecture-overview/#package-architecture). The
most important packages to consider for this system are the following:
- `plugin-<pluginId>-backend` houses the implementation of the backend plugins
@@ -37,4 +37,4 @@ At a high level, when you call `createBackend`, it will create a new backend ins
Underneath the hood, `createBackend` calls `createSpecializedBackend` from `@backstage/backend-app-api` which is responsible for actually creating the backend instance, but with no services or no features. You can think of `createBackend` more of a 'batteries included' approach, and `createSpecializedBackend` a little more low level.
As mentioned previously there's also the ability to create multiple of these backends in your project so that you can split apart your backend and deploy different backends that can scale independently of each other. For instance you might choose to deploy a backend with only the catalog plugin enabled, and one with just the scaffolder plugin enabled. We've provided some tools to be able to share services and defaults across your backend system, and you can find out more about that in the [shared environments docs](../building-backends/01-index.md#shared-environments).
As mentioned previously there's also the ability to create multiple of these backends in your project so that you can split apart your backend and deploy different backends that can scale independently of each other. For instance you might choose to deploy a backend with only the catalog plugin enabled, and one with just the scaffolder plugin enabled.
@@ -148,35 +148,3 @@ backend.start();
```
We've now split the backend into two separate deployments, but we still need to make sure that they can communicate with each other. This is the hard and somewhat tedious part, as Backstage currently doesn't provide an out of the box solution that solves this. You'll need to manually configure the two backends with custom implementations of the `DiscoveryService` and have them return the correct URLs for each other. Likewise, you'll also need to provide a custom implementation of the `DiscoveryApi` in the frontend, unless you surface the two backends via a proxy that handles the routing instead.
### Shared Environments
To make it a bit easier to manage multiple backends, it's possible to create a shared environment that can be used across multiple backends. You would typically house it in a separate package that can be referenced by backends in your monorepo, or published to a package registry for broader use.
A shared environment contains a set of service implementations that should be used across all backends. These services will override the default ones, but if a service is provided directly to the backend, it will override the one in the shared environment.
A shared environment is defined using `createSharedEnvironment`. In this example we place it in a new and separate package called `backend-env`:
```ts
// packages/backend-env/src/index.ts
import { createSharedEnvironment } from '@backstage/backend-plugin-api';
import { customDiscoveryServiceFactory } from './customDiscoveryServiceFactory';
export const env = createSharedEnvironment({
services: [
customDiscoveryServiceFactory(), // custom DiscoveryService implementation
],
});
```
And passed on to backends using the `env` option:
```ts
// packages/backend-b/src/index.ts, imports omitted
import { env } from '@internal/backend-env';
const backend = createBackend({ env });
backend.add(scaffolderPlugin());
backend.start();
```
+3 -3
View File
@@ -51,7 +51,7 @@ The TechDocs plugin has supported integrations to Search, meaning that it
provides a default collator factory ready to be used.
The purpose of this guide is to walk you through how to register the
[DefaultTechDocsCollatorFactory](https://github.com/backstage/backstage/blob/de294ce5c410c9eb56da6870a1fab795268f60e3/plugins/techdocs-backend/src/search/DefaultTechDocsCollatorFactory.ts)
[DefaultTechDocsCollatorFactory](https://github.com/backstage/backstage/blob/1adc2c7/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts)
in your App, so that you can get TechDocs documents indexed.
If you have been through the
@@ -61,10 +61,10 @@ so, you can go ahead and follow this guide - if not, start by going through the
getting started guide.
1. Import the `DefaultTechDocsCollatorFactory` from
`@backstage/plugin-techdocs-backend`.
`@backstage/plugin-search-backend-module-techdocs`.
```typescript
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-search-backend-module-techdocs';
```
2. If there isn't an existing schedule you'd like to run the collator on, be
+1
View File
@@ -23,6 +23,7 @@ catalog:
host: gitlab-host # Identifies one of the hosts set up in the integrations
branch: main # Optional. Used to discover on a specific branch
fallbackBranch: main # Optional. Fallback to be used if there is no default branch configured at the Gitlab repository. It is only used, if `branch` is undefined. Uses `master` as default
skipForkedRepos: false # Optional. If the project is a fork, skip repository
group: example-group # Optional. Group and subgroup (if needed) to look for repositories. If not present the whole instance will be scanned
entityFilename: catalog-info.yaml # Optional. Defaults to `catalog-info.yaml`
projectPattern: '[\s\S]*' # Optional. Filters found projects based on provided patter. Defaults to `[\s\S]*`, which means to not filter anything
@@ -26,7 +26,6 @@ When we set out on this project, we had a few primary goals. First, we intended
- Make it easier to create and maintain backend installations.
- Align how plugins provide points of customization and how those customizations are installed.
- Make it much easier to maintain plugins, in particular keeping the API stable.
- Simplify the process of splitting plugins out into separate deployments with shared environments.
- Improve the local development and testing experience.
Prioritizing simplicity is often a guiding principle that we use for designs that span multiple ownership roles. We decide on which parts of the system that we think are the most important to have as simple as possible, or, viewed from the opposite end, where in the system we put necessary complexity. In this case, we optimized for keeping the backend setup as simple as possible, followed by modules and plugins, then libraries, and lastly the framework itself. What this guidance means in practice is that when there is complexity that needs to be added to implement a certain feature, we place as much of it as possible within the framework itself, then libraries, plugins and modules, and if absolutely needed, the backend setup.
@@ -0,0 +1,51 @@
---
# prettier-ignore
title: "Five common traits of successful Backstage adopters"
author: Taras Mankovski, CXO, Frontside
---
![backstage header](assets/2023-07-28/header.png)
At [Frontside](https://frontside.com/), our goal is to help cloud-native teams create cohesive development experiences. We've been at the Backstage party since the beginning helping companies adopt and extend Backstage to fit the needs of their unique ecosystems.
Through our experience, we've uncovered five common traits of organizations that have improved developer experience through successful Backstage implementation and adoption. In this blog, we'll dive into these traits with some thoughts on how you can follow their blueprint.
{/* truncate */}
## **1) They take a product-centric approach**
The biggest factor for successful adopters of Backstage is their approach. There is a high correlation of success within organizations that approach building a developer portal like they're building a product. Some organizations will jump straight into solutioning instead of taking their time to identify what the problems are, how they affect users, and how those issues can be addressed.
The team building out Backstage should assess or work from existing insights on the core challenges that developers are facing in their organization — both qualitative and quantitative data counts here — and connect those issues to a product roadmap with initial use cases and adoption milestones marked. By doing their research in advance, successful orgs have a clear vision of what they want to accomplish with their Backstage instance. They know exactly what goals need to be met and have pressure-tested potential issues and barriers.
If you've already started your Backstage adoption journey but haven't mapped a Product Requirements Document (or PRD), don't panic! There is always time to do the foundational knowledge-building necessary to deploy something successful.
## **2) They tie Backstage to a larger platform journey**
Platform engineering has emerged in response to the growing complexity of cloud-native software architecture. Backstage has a huge role to play in implementing a platform strategy but its part of a larger story. Having buy-in from both higher ups and the teams tasked with platform goals is an essential step in successful Backstage adoption. Without the support at the top, it can be difficult to implement platforming as a priority within your organization.
And by "the top", we don't necessarily mean your CTO needs to rubber stamp your Backstage POC. But it's important that higher-level Platform goals have been set among VPs and directors with the understanding that a well-constructed developer portal can be the catalyst to achieving not only better developer experience but broader cultural shifts.
Having this kind of executive buy-in means more than just getting stakeholders on board with a portal build or clearing pathways towards Backstage adoption; it also means ensuring they understand the realities of your organization's journey.
## **3) They prioritize developer experience**
Having a dedicated developer experience or DevOps team tasked not only with building out a Backstage instance — but with leading adoption — is another big factor to success.
Competing platforms are very common at big companies, with many teams doing different things or serving different parts of a business. But developer experience teams usually have a very clear mandate and are often one of the only commonalities between these competing teams. This team should have the bandwidth available to oversee the adoption process from start to finish, and partner with the right folks (research, design, internal comms and marketing) to create an effective adoption plan tailored specifically for a company's needs.
If you don't have established developer experience resources in place, then the questions the team leading the Backstage work needs to ask are "what are the common problems to be solved at a platform level?" and "once we've proved our initial use cases, how are we internally evangelizing this amongst our competing teams?"
## **4) They collaborate with other platform teams**
A developer experience team can not single-handedly transform an organization's development structures, processes, and culture. It requires collaboration with teams responsible for other aspects of the platform. Successful adopters collaborate with other teams to bring them along on the journey of improving developer experience. They use Backstage as an opportunity to foster their inner-source contribution practice by allowing multiple platform teams to contribute plugins to their portal.
The developer experience team takes on the role of host in that collaborative process. They support other teams in integrating their features into the portal, provide guidance on best practices, and advocate for the needs of developers.
## **5) They engage, learn, and grow with the community**
One of the key benefits of building a developer platform based off an open source framework is leveraging the contributions and best practices from the community. The Backstage community is the largest gathering of developer experience practitioners in the world. There are now [thousands of adopters](https://github.com/backstage/backstage/blob/63aa3a65ec05f638ba76c06878d6635194620b34/ADOPTERS.md#L4) with a wealth of experience and best practices to learn from — and they are excited to share!
By participating in the community, developer experience leaders can learn from their peers at other companies about strategies that work and can keep up with the latest features of the ever-evolving Backstage platform. They're aware of what solutions exist within the open source framework and plugin marketplace as well as what they may need to build in-house.
In short, successful Backstage adopters engage within the Backstage community and their peer group to find solutions that fit their common challenges as well as their unique needs.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+6
View File
@@ -6,6 +6,12 @@
"role": "frontend"
},
"bundled": true,
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/app"
},
"dependencies": {
"@backstage/app-defaults": "workspace:^",
"@backstage/catalog-model": "workspace:^",
+3 -3
View File
@@ -77,17 +77,17 @@ export interface Config {
*/
connection:
| string
| Partial<{
| {
/**
* Password that belongs to the client User
* @visibility secret
*/
password: string;
password?: string;
/**
* Other connection settings
*/
[key: string]: unknown;
}>;
};
/** Database name prefix override */
prefix?: string;
/**
-3
View File
@@ -5,15 +5,12 @@
```ts
import { Backend } from '@backstage/backend-app-api';
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
import { SharedBackendEnvironment } from '@backstage/backend-plugin-api';
// @public (undocumented)
export function createBackend(options?: CreateBackendOptions): Backend;
// @public (undocumented)
export interface CreateBackendOptions {
// (undocumented)
env?: SharedBackendEnvironment;
// (undocumented)
services?: ServiceFactoryOrFunction[];
}
@@ -16,17 +16,10 @@
import {
coreServices,
createBackendPlugin,
createServiceFactory,
createServiceRef,
createSharedEnvironment,
} from '@backstage/backend-plugin-api';
import { mockServices } from '@backstage/backend-test-utils';
import { createBackend } from './CreateBackend';
const fooServiceRef = createServiceRef<string>({ id: 'foo', scope: 'root' });
const barServiceRef = createServiceRef<string>({ id: 'bar', scope: 'root' });
describe('createBackend', () => {
it('should not throw when overriding a default service implementation', () => {
expect(() =>
@@ -85,91 +78,4 @@ describe('createBackend', () => {
}),
).toThrow('The core.pluginMetadata service cannot be overridden');
});
it('should throw if an unsupported InternalSharedEnvironment version is passed in', () => {
expect(() =>
createBackend({
env: {} as any,
}),
).toThrow(
"Shared environment version 'undefined' is invalid or not supported",
);
expect(() =>
createBackend({
env: { version: {} } as any,
}),
).toThrow(
"Shared environment version '[object Object]' is invalid or not supported",
);
expect(() =>
createBackend({
env: { version: 'v2' } as any,
}),
).toThrow("Shared environment version 'v2' is invalid or not supported");
});
it('should prioritize services correctly', async () => {
const backend = createBackend({
env: createSharedEnvironment({
services: [
createServiceFactory({
service: coreServices.rootHttpRouter,
deps: {},
async factory() {
return {
use() {},
};
},
}),
mockServices.config.factory({
data: { root: 'root-env' },
}),
createServiceFactory({
service: fooServiceRef,
deps: {},
async factory() {
return 'foo-env';
},
}),
createServiceFactory({
service: barServiceRef,
deps: {},
async factory() {
return 'bar-env';
},
}),
],
})(),
services: [
createServiceFactory({
service: fooServiceRef,
deps: {},
factory: async () => 'foo-backend',
}),
],
});
expect.assertions(3);
backend.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {
config: coreServices.config,
foo: fooServiceRef,
bar: barServiceRef,
},
async init({ config, foo, bar }) {
expect(config.get('root')).toBe('root-env');
expect(foo).toBe('foo-backend');
expect(bar).toBe('bar-env');
},
});
},
})(),
);
await backend.start();
});
});
@@ -36,13 +36,8 @@ import {
import {
ServiceFactory,
ServiceFactoryOrFunction,
SharedBackendEnvironment,
} from '@backstage/backend-plugin-api';
// Internal import of the type to avoid needing to export this.
// eslint-disable-next-line @backstage/no-forbidden-package-imports
import type { InternalSharedBackendEnvironment } from '@backstage/backend-plugin-api/src/wiring/createSharedEnvironment';
export const defaultServiceFactories = [
cacheServiceFactory(),
configServiceFactory(),
@@ -65,7 +60,6 @@ export const defaultServiceFactories = [
* @public
*/
export interface CreateBackendOptions {
env?: SharedBackendEnvironment;
services?: ServiceFactoryOrFunction[];
}
@@ -81,22 +75,6 @@ export function createBackend(options?: CreateBackendOptions): Backend {
);
services.push(...providedServices);
// Middle priority: Services from the shared environment
if (options?.env) {
const env = options.env as unknown as InternalSharedBackendEnvironment;
if (env.version !== 'v1') {
throw new Error(
`Shared environment version '${env.version}' is invalid or not supported`,
);
}
const environmentServices =
env.services?.filter(
sf => !services.some(({ service }) => sf.service.id === service.id),
) ?? [];
services.push(...environmentServices);
}
// Lowest priority: Default services that are not already provided by environment or directly to createBackend
const defaultServices = defaultServiceFactories.filter(
sf => !services.some(({ service }) => service.id === sf.service.id),
@@ -11,6 +11,12 @@
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/backend-openapi-utils"
},
"backstage": {
"role": "node-library"
},
-21
View File
@@ -222,15 +222,6 @@ export function createServiceRef<TService>(
config: ServiceRefConfig<TService, 'root'>,
): ServiceRef<TService, 'root'>;
// @public
export function createSharedEnvironment<
TOptions extends [options?: object] = [],
>(
config:
| SharedBackendEnvironmentConfig
| ((...params: TOptions) => SharedBackendEnvironmentConfig),
): (...options: TOptions) => SharedBackendEnvironment;
// @public
export interface DatabaseService {
getClient(): Promise<Knex>;
@@ -476,18 +467,6 @@ export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
scope?: TScope;
}
// @public
export interface SharedBackendEnvironment {
// (undocumented)
$$type: '@backstage/SharedBackendEnvironment';
}
// @public
export interface SharedBackendEnvironmentConfig {
// (undocumented)
services?: ServiceFactoryOrFunction[];
}
// @public
export interface TokenManagerService {
authenticate(token: string): Promise<void>;
@@ -1,100 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceFactory,
createServiceRef,
ServiceFactoryOrFunction,
} from '../services';
import {
createSharedEnvironment,
InternalSharedBackendEnvironment,
} from './createSharedEnvironment';
const fooService = createServiceRef<string>({ id: 'foo', scope: 'root' });
const fooFactory = createServiceFactory({
service: fooService,
deps: {},
async factory() {
return 'foo';
},
});
const barService = createServiceRef<number>({ id: 'bar', scope: 'root' });
const barFactory = createServiceFactory({
service: barService,
deps: {},
async factory() {
return 0xba5;
},
});
describe('createSharedEnvironment', () => {
it('should create an empty shared environment', () => {
const env = createSharedEnvironment({});
expect(env).toBeDefined();
const internalEnv = env() as unknown as InternalSharedBackendEnvironment;
expect(internalEnv).toEqual({
$$type: '@backstage/SharedBackendEnvironment',
version: 'v1',
services: undefined,
});
});
it('should create a shared environment with services', () => {
const env = createSharedEnvironment({
services: [fooFactory, barFactory()],
});
const internalEnv = env() as unknown as InternalSharedBackendEnvironment;
expect(internalEnv.version).toBe('v1');
expect(internalEnv.services?.length).toBe(2);
expect(internalEnv.services?.[0]?.service.id).toBe('foo');
expect(internalEnv.services?.[1]?.service.id).toBe('bar');
});
it('should create a shared environment with options', () => {
const env = createSharedEnvironment((options?: { withFoo?: boolean }) => {
const services = new Array<ServiceFactoryOrFunction>();
if (options?.withFoo) {
services.push(fooFactory());
}
services.push(barFactory);
return { services };
});
const internalEnv1 = env() as unknown as InternalSharedBackendEnvironment;
expect(internalEnv1.version).toBe('v1');
expect(internalEnv1.services?.length).toBe(1);
expect(internalEnv1.services?.[0]?.service.id).toBe('bar');
const internalEnv2 = env({
withFoo: true,
}) as unknown as InternalSharedBackendEnvironment;
expect(internalEnv2.version).toBe('v1');
expect(internalEnv2.services?.length).toBe(2);
expect(internalEnv2.services?.[0]?.service.id).toBe('foo');
expect(internalEnv2.services?.[1]?.service.id).toBe('bar');
});
it('should not allow duplicate service factories', () => {
expect(() =>
createSharedEnvironment({
services: [fooFactory, fooFactory()],
})(),
).toThrow(
"Duplicate service implementations provided in shared environment for 'foo'",
);
});
});
@@ -1,102 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ServiceFactory, ServiceFactoryOrFunction } from '../services';
/**
* The configuration options passed to {@link createSharedEnvironment}.
*
* @public
*/
export interface SharedBackendEnvironmentConfig {
services?: ServiceFactoryOrFunction[];
}
/**
* An opaque type that represents the contents of a shared backend environment.
*
* @public
*/
export interface SharedBackendEnvironment {
$$type: '@backstage/SharedBackendEnvironment';
// NOTE: This type is opaque in order to allow for future API evolution without
// cluttering the external API. For example we might want to add support
// for more powerful callback based backend modifications.
//
// By making this opaque we also ensure that the type doesn't become an input
// type that we need to care about, as it would otherwise be possible to pass
// a custom environment definition to `createBackend`, which we don't want.
}
/**
* This type is NOT supposed to be used by anyone except internally by the
* backend-app-api package.
*
* @internal
*/
export interface InternalSharedBackendEnvironment {
version: 'v1';
services?: ServiceFactory[];
}
/**
* Creates a shared backend environment which can be used to create multiple
* backends.
*
* @public
*/
export function createSharedEnvironment<
TOptions extends [options?: object] = [],
>(
config:
| SharedBackendEnvironmentConfig
| ((...params: TOptions) => SharedBackendEnvironmentConfig),
): (...options: TOptions) => SharedBackendEnvironment {
const configCallback = typeof config === 'function' ? config : () => config;
return (...options) => {
const actualConfig = configCallback(...options);
const services = actualConfig?.services?.map(sf =>
typeof sf === 'function' ? sf() : sf,
);
const exists = new Set<string>();
const duplicates = new Set<string>();
for (const { service } of services ?? []) {
if (exists.has(service.id)) {
duplicates.add(service.id);
} else {
exists.add(service.id);
}
}
if (duplicates.size > 0) {
const dupStr = [...duplicates].map(id => `'${id}'`).join(', ');
throw new Error(
`Duplicate service implementations provided in shared environment for ${dupStr}`,
);
}
// Here to ensure type safety in this internal implementation.
const env: SharedBackendEnvironment & InternalSharedBackendEnvironment = {
$$type: '@backstage/SharedBackendEnvironment',
version: 'v1',
services,
};
return env;
};
}
@@ -14,11 +14,6 @@
* limitations under the License.
*/
export { createSharedEnvironment } from './createSharedEnvironment';
export type {
SharedBackendEnvironment,
SharedBackendEnvironmentConfig,
} from './createSharedEnvironment';
export type {
BackendModuleConfig,
BackendPluginConfig,
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "node-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/cli-node"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
+29 -5
View File
@@ -15,6 +15,7 @@
*/
import fs from 'fs-extra';
import { EOL } from 'os';
import {
resolve as resolvePath,
relative as relativePath,
@@ -163,7 +164,7 @@ async function compileTsSchemas(paths: string[]) {
// Lazy loaded, because this brings up all of TypeScript and we don't
// want that eagerly loaded in tests
const { getProgramFromFiles, generateSchema } = await import(
const { getProgramFromFiles, buildGenerator } = await import(
'typescript-json-schema'
);
@@ -183,17 +184,40 @@ async function compileTsSchemas(paths: string[]) {
const tsSchemas = paths.map(path => {
let value;
try {
value = generateSchema(
const generator = buildGenerator(
program,
// All schemas should export a `Config` symbol
'Config',
// This enables the use of these tags in TSDoc comments
{
required: true,
validationKeywords: ['visibility', 'deepVisibility', 'deprecated'],
},
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
) as JsonObject | null;
);
// All schemas should export a `Config` symbol
value = generator?.getSchemaForSymbol('Config') as JsonObject | null;
// This makes sure that no additional symbols are defined in the schema. We don't allow
// this because they share a global namespace and will be merged together, leading to
// unpredictable behavior.
const userSymbols = new Set(generator?.getUserSymbols());
userSymbols.delete('Config');
if (userSymbols.size !== 0) {
const names = Array.from(userSymbols).join("', '");
throw new Error(
`Invalid configuration schema in ${path}, additional symbol definitions are not allowed, found '${names}'`,
);
}
// This makes sure that no unsupported types are used in the schema, for example `Record<,>`.
// The generator will extract these as a schema reference, which will in turn be broken for our usage.
const reffedDefs = Object.keys(generator?.ReffedDefinitions ?? {});
if (reffedDefs.length !== 0) {
const lines = reffedDefs.join(`${EOL} `);
throw new Error(
`Invalid configuration schema in ${path}, the following definitions are not supported:${EOL}${EOL} ${lines}`,
);
}
} catch (error) {
assertError(error);
if (error.message !== 'type Config not found') {
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "web-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/integration-react"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
@@ -6,6 +6,12 @@
"role": "frontend"
},
"bundled": true,
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/techdocs-cli-embedded-app"
},
"dependencies": {
"@backstage/app-defaults": "workspace:^",
"@backstage/catalog-model": "workspace:^",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/adr"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/airbrake-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/airbrake"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/allure"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/analytics-module-ga"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/plugins/analytics-module-ga4"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/apache-airflow"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/apollo-explorer"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+2
View File
@@ -152,6 +152,8 @@ export interface Config {
clientSecret: string;
callbackUrl?: string;
metadataUrl: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "node-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-node"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
@@ -12,6 +12,12 @@
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/azure-devops-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/bazaar-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build --experimental-type-build",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/bazaar"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
@@ -14,6 +14,12 @@
"backstage": {
"role": "common-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/bitbucket-cloud-common"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/bitrise"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+16 -5
View File
@@ -54,14 +54,19 @@ export interface Config {
* @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html
*/
region?: string;
/**
* (Optional) AWS Account id.
* If not set, main account is used.
* @see https://github.com/backstage/backstage/blob/master/packages/integration-aws-node/README.md
*/
accountId?: string;
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
| Record<
string,
{
| {
[name: string]: {
/**
* (Required) AWS S3 Bucket Name
*/
@@ -77,12 +82,18 @@ export interface Config {
* @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html
*/
region?: string;
/**
* (Optional) AWS Account id.
* If not set, main account is used.
* @see https://github.com/backstage/backstage/blob/master/packages/integration-aws-node/README.md
*/
accountId?: string;
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
>;
};
};
};
};
}
@@ -146,20 +146,22 @@ export class AwsS3EntityProvider implements EntityProvider {
/** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
const credProvider =
await this.awsCredentialsManager.getCredentialProvider();
const { accountId, region, bucketName } = this.config;
const credProvider = await this.awsCredentialsManager.getCredentialProvider(
accountId ? { accountId } : undefined,
);
this.s3 = new S3({
apiVersion: '2006-03-01',
credentialDefaultProvider: () => credProvider.sdkCredentialProvider,
endpoint: this.integration.config.endpoint,
region: this.config.region,
region,
forcePathStyle: this.integration.config.s3ForcePathStyle,
});
// https://github.com/aws/aws-sdk-js-v3/issues/4122#issuecomment-1298968804
const endpoint = await getEndpointFromInstructions(
{
Bucket: this.config.bucketName,
Bucket: bucketName,
},
ListObjectsV2Command,
this.s3.config as unknown as Record<string, unknown>,
@@ -46,6 +46,7 @@ function readAwsS3Config(id: string, config: Config): AwsS3Config {
const bucketName = config.getString('bucketName');
const region = config.getOptionalString('region');
const prefix = config.getOptionalString('prefix');
const accountId = config.getOptionalString('accountId');
const schedule = config.has('schedule')
? readTaskScheduleDefinitionFromConfig(config.getConfig('schedule'))
@@ -57,5 +58,6 @@ function readAwsS3Config(id: string, config: Config): AwsS3Config {
region,
prefix,
schedule,
accountId,
};
}
@@ -22,4 +22,5 @@ export type AwsS3Config = {
prefix?: string;
region?: string;
schedule?: TaskScheduleDefinition;
accountId?: string;
};
+30 -30
View File
@@ -16,35 +16,6 @@
import { TaskScheduleDefinitionConfig } from '@backstage/backend-tasks';
interface AzureDevOpsConfig {
/**
* (Optional) The DevOps host; leave empty for `dev.azure.com`, otherwise set to your self-hosted instance host.
*/
host: string;
/**
* (Required) Your organization slug.
*/
organization: string;
/**
* (Required) Your project slug.
*/
project: string;
/**
* (Optional) The repository name. Wildcards are supported as show on the examples above.
* If not set, all repositories will be searched.
*/
repository?: string;
/**
* (Optional) Where to find catalog-info.yaml files. Wildcards are supported.
* If not set, defaults to /catalog-info.yaml.
*/
path?: string;
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
export interface Config {
catalog?: {
/**
@@ -54,7 +25,36 @@ export interface Config {
/**
* AzureDevopsEntityProvider configuration
*/
azureDevOps?: Record<string, AzureDevOpsConfig>;
azureDevOps?: {
[name: string]: {
/**
* (Optional) The DevOps host; leave empty for `dev.azure.com`, otherwise set to your self-hosted instance host.
*/
host: string;
/**
* (Required) Your organization slug.
*/
organization: string;
/**
* (Required) Your project slug.
*/
project: string;
/**
* (Optional) The repository name. Wildcards are supported as show on the examples above.
* If not set, all repositories will be searched.
*/
repository?: string;
/**
* (Optional) Where to find catalog-info.yaml files. Wildcards are supported.
* If not set, defaults to /catalog-info.yaml.
*/
path?: string;
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
};
};
};
};
}
+12 -13
View File
@@ -45,24 +45,23 @@ export interface Config {
*/
filters?: {
/**
* (Optional) Filter for the repository slug.
* (Optional) Regular expression filter for the repository slug.
* @visibility frontend
*/
repoSlug?: RegExp;
repoSlug?: string;
/**
* (Optional) Filter for the project key.
* (Optional) Regular expression filter for the project key.
* @visibility frontend
*/
projectKey?: RegExp;
projectKey?: string;
};
/**
* (Optional) TaskScheduleDefinition for the discovery.
*/
schedule?: TaskScheduleDefinitionConfig;
}
| Record<
string,
{
| {
[name: string]: {
/**
* (Optional) Path to the catalog file. Default to "/catalog-info.yaml".
* @visibility frontend
@@ -79,22 +78,22 @@ export interface Config {
*/
filters?: {
/**
* (Optional) Filter for the repository slug.
* (Optional) Regular expression filter for the repository slug.
* @visibility frontend
*/
repoSlug?: RegExp;
repoSlug?: string;
/**
* (Optional) Filter for the project key.
* (Optional) Regular expression filter for the project key.
* @visibility frontend
*/
projectKey?: RegExp;
projectKey?: string;
};
/**
* (Optional) TaskScheduleDefinition for the discovery.
*/
schedule?: TaskScheduleDefinitionConfig;
}
>;
};
};
};
};
}
+12 -13
View File
@@ -37,24 +37,23 @@ export interface Config {
*/
filters?: {
/**
* (Optional) Filter for the repository slug.
* (Optional) Regular expression filter for the repository slug.
* @visibility frontend
*/
repoSlug?: RegExp;
repoSlug?: string;
/**
* (Optional) Filter for the project key.
* (Optional) Regular expression filter for the project key.
* @visibility frontend
*/
projectKey?: RegExp;
projectKey?: string;
};
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
| Record<
string,
{
| {
[name: string]: {
/**
* (Optional) Path to the catalog file. Default to "/catalog-info.yaml".
* @visibility frontend
@@ -66,22 +65,22 @@ export interface Config {
*/
filters?: {
/**
* (Optional) Filter for the repository slug.
* (Optional) Regular expression filter for the repository slug.
* @visibility frontend
*/
repoSlug?: RegExp;
repoSlug?: string;
/**
* (Optional) Filter for the project key.
* (Optional) Regular expression filter for the project key.
* @visibility frontend
*/
projectKey?: RegExp;
projectKey?: string;
};
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
>;
};
};
};
};
}
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,41 @@
# Catalog Backend Module for GCP
This is an extension module to the plugin-catalog-backend plugin, containing catalog processors and providers to ingest GCP resources as `Resource` kind entities.
## installation
Register the plugin in `catalog.ts``
```typescript
import { GkeEntityProvider } from '@backstage/plugin-catalog-backend-module-gcp';
...
builder.addEntityProvider(
GkeEntityProvider.fromConfig({
logger: env.logger,
scheduler: env.scheduler,
config: env.config
})
);
```
Update `app-config.yaml` as follows:
```yaml
catalog:
providers:
gcp:
gke:
parents:
# consult https://cloud.google.com/kubernetes-engine/docs/ for valid values
# list all clusters in the project
- 'projects/some-project/locations/-'
# list all clusters in the region, in the project
- 'projects/some-other-project/locations/some-region'
schedule: # optional; same options as in TaskScheduleDefinition
# supports cron, ISO duration, "human duration" as used in code
frequency: { minutes: 30 }
# supports ISO duration, "human duration" as used in code
timeout: { minutes: 3 }
```
@@ -0,0 +1,7 @@
## API Report File for "@backstage/plugin-catalog-backend-module-gcp"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
```
@@ -0,0 +1,48 @@
## API Report File for "@backstage/plugin-catalog-backend-module-gcp"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import * as container from '@google-cloud/container';
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import { SchedulerService } from '@backstage/backend-plugin-api';
// @public
export const catalogModuleGcpGkeEntityProvider: () => BackendFeature;
// @public
export class GkeEntityProvider implements EntityProvider {
// (undocumented)
connect(connection: EntityProviderConnection): Promise<void>;
// (undocumented)
static fromConfig({
logger,
scheduler,
config,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
}): GkeEntityProvider;
// (undocumented)
static fromConfigWithClient({
logger,
scheduler,
config,
clusterManagerClient,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
clusterManagerClient: container.v1.ClusterManagerClient;
}): GkeEntityProvider;
// (undocumented)
getProviderName(): string;
// (undocumented)
refresh(): Promise<void>;
}
```
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TaskScheduleDefinitionConfig } from '@backstage/backend-tasks';
export interface Config {
catalog?: {
/**
* List of provider-specific options and attributes
*/
providers?: {
/**
* GCPCatalogModuleConfig configuration
*/
gcp?: {
/**
* Config for GKE clusters
*/
gke?: {
/**
* Locations to list clusters from
*/
parents: string[];
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule: TaskScheduleDefinitionConfig;
};
};
};
};
}
@@ -0,0 +1,66 @@
{
"name": "@backstage/plugin-catalog-backend-module-gcp",
"description": "A Backstage catalog backend module that helps integrate towards GCP",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-backend-module-gcp"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@google-cloud/container": "^4.15.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
},
"files": [
"config.d.ts",
"dist"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,23 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A Backstage catalog backend module that helps integrate towards GCP
*
* @packageDocumentation
*/
export {};
@@ -0,0 +1,24 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A Backstage catalog backend module that helps integrate towards GCP
*
* @packageDocumentation
*/
export * from './providers';
export * from './module';
@@ -0,0 +1,52 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { loggerToWinstonLogger } from '@backstage/backend-common';
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { GkeEntityProvider } from '../providers/GkeEntityProvider';
/**
* Registers the GcpGkeEntityProvider with the catalog processing extension point.
*
* @public
*/
export const catalogModuleGcpGkeEntityProvider = createBackendModule({
pluginId: 'catalog',
moduleId: 'gcpGkeEntityProvider',
register(env) {
env.registerInit({
deps: {
config: coreServices.config,
catalog: catalogProcessingExtensionPoint,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
},
async init({ config, catalog, logger, scheduler }) {
catalog.addEntityProvider(
GkeEntityProvider.fromConfig({
logger: loggerToWinstonLogger(logger),
scheduler,
config,
}),
);
},
});
},
});
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { catalogModuleGcpGkeEntityProvider } from './catalogModuleGcpGkeEntityProvider';
@@ -0,0 +1,251 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GkeEntityProvider } from './GkeEntityProvider';
import { TaskRunner } from '@backstage/backend-tasks';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import * as container from '@google-cloud/container';
import { ConfigReader } from '@backstage/config';
describe('GkeEntityProvider', () => {
const clusterManagerClientMock = {
listClusters: jest.fn(),
};
const connectionMock = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const taskRunner = {
createScheduleFn: jest.fn(),
run: jest.fn(),
} as TaskRunner;
const schedulerMock = {
createScheduledTaskRunner: jest.fn(),
} as any;
const logger = {
info: jest.fn(),
error: jest.fn(),
};
let gkeEntityProvider: GkeEntityProvider;
beforeEach(async () => {
jest.resetAllMocks();
schedulerMock.createScheduledTaskRunner.mockReturnValue(taskRunner);
gkeEntityProvider = GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['parent1', 'parent2'],
schedule: {
frequency: {
minutes: 3,
},
timeout: {
minutes: 3,
},
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
await gkeEntityProvider.connect(connectionMock);
});
it('should return clusters as Resources', async () => {
clusterManagerClientMock.listClusters.mockImplementation(req => {
if (req.parent === 'parent1') {
return [
{
clusters: [
{
name: 'some-cluster',
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
},
];
} else if (req.parent === 'parent2') {
return [
{
clusters: [
{
name: 'some-other-cluster',
endpoint: 'http://127.0.0.1:5678',
location: 'some-other-location',
selfLink: 'http://127.0.0.1/some-other-link',
masterAuth: {
// no CA cert is ok
},
},
],
},
];
}
throw new Error(`unexpected parent ${req.parent}`);
});
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
locationKey: 'gcp-gke:some-location',
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: 'http://127.0.0.1:1234',
[ANNOTATION_KUBERNETES_API_SERVER_CA]: 'abcdefg',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location': 'gcp-gke:some-location',
'backstage.io/managed-by-origin-location':
'gcp-gke:some-location',
},
name: 'some-cluster',
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
},
{
locationKey: 'gcp-gke:some-other-location',
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: 'http://127.0.0.1:5678',
[ANNOTATION_KUBERNETES_API_SERVER_CA]: '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location':
'gcp-gke:some-other-location',
'backstage.io/managed-by-origin-location':
'gcp-gke:some-other-location',
},
name: 'some-other-cluster',
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
},
],
});
});
const ignoredPartialClustersTests: [
string,
container.protos.google.container.v1.ICluster,
][] = [
[
'no-cluster-name',
{
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-self-link',
{
// no selfLink
name: 'some-name',
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-endpoint',
{
name: 'some-name',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-location',
{
name: 'some-name',
endpoint: 'http://127.0.0.1:1234',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
];
it.each(ignoredPartialClustersTests)(
'ignore cluster - %s',
async (_name, ignoredCluster) => {
clusterManagerClientMock.listClusters.mockImplementation(req => {
if (req.parent === 'parent1') {
return [ignoredCluster];
}
return [
{
clusters: [],
},
];
});
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [],
});
},
);
it('should log GKE API errors', async () => {
clusterManagerClientMock.listClusters.mockRejectedValue(
new Error('some-error'),
);
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,223 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TaskRunner,
readTaskScheduleDefinitionFromConfig,
} from '@backstage/backend-tasks';
import {
DeferredEntity,
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import * as container from '@google-cloud/container';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import { Config } from '@backstage/config';
import { SchedulerService } from '@backstage/backend-plugin-api';
/**
* Catalog provider to ingest GKE clusters
*
* @public
*/
export class GkeEntityProvider implements EntityProvider {
private readonly logger: Logger;
private readonly scheduleFn: () => Promise<void>;
private readonly gkeParents: string[];
private readonly clusterManagerClient: container.v1.ClusterManagerClient;
private connection?: EntityProviderConnection;
private constructor(
logger: Logger,
taskRunner: TaskRunner,
gkeParents: string[],
clusterManagerClient: container.v1.ClusterManagerClient,
) {
this.logger = logger;
this.scheduleFn = this.createScheduleFn(taskRunner);
this.gkeParents = gkeParents;
this.clusterManagerClient = clusterManagerClient;
}
public static fromConfig({
logger,
scheduler,
config,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
}) {
return GkeEntityProvider.fromConfigWithClient({
logger,
scheduler: scheduler,
config,
clusterManagerClient: new container.v1.ClusterManagerClient(),
});
}
public static fromConfigWithClient({
logger,
scheduler,
config,
clusterManagerClient,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
clusterManagerClient: container.v1.ClusterManagerClient;
}) {
const gkeProviderConfig = config.getConfig('catalog.providers.gcp.gke');
const schedule = readTaskScheduleDefinitionFromConfig(
gkeProviderConfig.getConfig('schedule'),
);
return new GkeEntityProvider(
logger,
scheduler.createScheduledTaskRunner(schedule),
gkeProviderConfig.getStringArray('parents'),
clusterManagerClient,
);
}
getProviderName(): string {
return `gcp-gke`;
}
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
await this.scheduleFn();
}
private filterOutUndefinedDeferredEntity(
e: DeferredEntity | undefined,
): e is DeferredEntity {
return e !== undefined;
}
private filterOutUndefinedCluster(
c: container.protos.google.container.v1.ICluster | null | undefined,
): c is container.protos.google.container.v1.ICluster {
return c !== undefined && c !== null;
}
private clusterToResource(
cluster: container.protos.google.container.v1.ICluster,
): DeferredEntity | undefined {
const location = `${this.getProviderName()}:${cluster.location}`;
if (!cluster.name || !cluster.selfLink || !location || !cluster.endpoint) {
this.logger.warn(
`ignoring partial cluster, one of name=${cluster.name}, endpoint=${cluster.endpoint}, selfLink=${cluster.selfLink} or location=${cluster.location} is missing`,
);
return undefined;
}
// TODO fix location type
return {
locationKey: location,
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: cluster.endpoint,
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
cluster.masterAuth?.clusterCaCertificate || '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location': location,
'backstage.io/managed-by-origin-location': location,
},
name: cluster.name,
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
};
}
private createScheduleFn(taskRunner: TaskRunner): () => Promise<void> {
return async () => {
const taskId = `${this.getProviderName()}:refresh`;
return taskRunner.run({
id: taskId,
fn: async () => {
try {
await this.refresh();
} catch (error) {
this.logger.error(error);
}
},
});
};
}
private async getClusters(): Promise<
container.protos.google.container.v1.ICluster[]
> {
const clusters = await Promise.all(
this.gkeParents.map(async parent => {
const request = {
parent: parent,
};
const [response] = await this.clusterManagerClient.listClusters(
request,
);
return response.clusters?.filter(this.filterOutUndefinedCluster) ?? [];
}),
);
return clusters.flat();
}
async refresh() {
if (!this.connection) {
throw new Error('Not initialized');
}
this.logger.info('Discovering GKE clusters');
let clusters: container.protos.google.container.v1.ICluster[];
try {
clusters = await this.getClusters();
} catch (e) {
this.logger.error('error fetching GKE clusters', e);
return;
}
const resources =
clusters
.map(c => this.clusterToResource(c))
.filter(this.filterOutUndefinedDeferredEntity) ?? [];
this.logger.info(
`Ingesting GKE clusters [${resources
.map(r => r.entity.metadata.name)
.join(', ')}]`,
);
await this.connection.applyMutation({
type: 'full',
entities: resources,
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { GkeEntityProvider } from './GkeEntityProvider';
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+4 -5
View File
@@ -25,9 +25,8 @@ export interface Config {
*
* Maps provider id with configuration.
*/
gerrit?: Record<
string,
{
gerrit?: {
[name: string]: {
/**
* (Required) The host of the Gerrit integration to use.
*/
@@ -42,8 +41,8 @@ export interface Config {
* The branch where the provider will try to find entities. Defaults to "master".
*/
branch?: string;
}
>;
};
};
};
};
}
+4 -5
View File
@@ -118,9 +118,8 @@ export interface Config {
*/
schedule?: TaskScheduleDefinitionConfig;
}
| Record<
string,
{
| {
[name: string]: {
/**
* (Optional) The hostname of your GitHub Enterprise instance.
* Default: `github.com`.
@@ -182,8 +181,8 @@ export interface Config {
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
>;
};
};
};
};
}
+11 -8
View File
@@ -22,9 +22,8 @@ export interface Config {
/**
* GitlabDiscoveryEntityProvider configuration
*/
gitlab?: Record<
string,
{
gitlab?: {
[name: string]: {
/**
* (Required) Gitlab's host name.
*/
@@ -51,17 +50,21 @@ export interface Config {
/**
* (Optional) RegExp for the Project Name Pattern
*/
projectPattern?: RegExp;
projectPattern?: string;
/**
* (Optional) RegExp for the User Name Pattern
*/
userPattern?: RegExp;
userPattern?: string;
/**
* (Optional) RegExp for the Group Name Pattern
*/
groupPattern?: RegExp;
}
>;
groupPattern?: string;
/**
* (Optional) Skip forked repository
*/
skipForkedRepos?: boolean;
};
};
};
};
}
@@ -90,4 +90,5 @@ export type GitlabProviderConfig = {
groupPattern: RegExp;
orgEnabled?: boolean;
schedule?: TaskScheduleDefinition;
skipForkedRepos?: boolean;
};
@@ -343,6 +343,119 @@ describe('GitlabDiscoveryEntityProvider', () => {
});
});
it('should filter fork projects', async () => {
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'test-gitlab',
apiBaseUrl: 'https://api.gitlab.example/api/v4',
token: '1234',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'test-gitlab',
skipForkedRepos: true,
},
},
},
},
});
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
})[0];
server.use(
rest.get(
`https://api.gitlab.example/api/v4/projects`,
(_req, res, ctx) => {
const response = [
{
id: 123,
default_branch: 'master',
archived: false,
last_activity_at: new Date().toString(),
web_url: 'https://api.gitlab.example/test-group/test-repo',
path_with_namespace: 'test-group/test-repo',
forked_from_project: {
id: 13083,
},
},
{
id: 124,
default_branch: 'master',
archived: false,
last_activity_at: new Date().toString(),
web_url: 'https://api.gitlab.example/john/example',
path_with_namespace: 'john/example',
},
];
return res(ctx.json(response));
},
),
rest.head(
'https://api.gitlab.example/api/v4/projects/test-group%2Ftest-repo/repository/files/catalog-info.yaml',
(req, res, ctx) => {
if (req.url.searchParams.get('ref') === 'master') {
return res(ctx.status(200));
}
return res(ctx.status(404, 'Not Found'));
},
),
rest.head(
'https://api.gitlab.example/api/v4/projects/john%2Fexample/repository/files/catalog-info.yaml',
(req, res, ctx) => {
if (req.url.searchParams.get('ref') === 'master') {
return res(ctx.status(200));
}
return res(ctx.status(404, 'Not Found'));
},
),
);
await provider.connect(entityProviderConnection);
await provider.refresh(logger);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'url:https://api.gitlab.example/john/example/-/blob/master/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://api.gitlab.example/john/example/-/blob/master/catalog-info.yaml',
},
name: 'generated-2045212e5b3e9e6bacf51cec709e362282e3cda9',
},
spec: {
presence: 'optional',
target:
'https://api.gitlab.example/john/example/-/blob/master/catalog-info.yaml',
type: 'url',
},
},
locationKey: 'GitlabDiscoveryEntityProvider:test-id',
},
],
});
});
it('fail without schedule and scheduler', () => {
const config = new ConfigReader({
integrations: {
@@ -177,6 +177,13 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
continue;
}
if (
this.config.skipForkedRepos &&
project.hasOwnProperty('forked_from_project')
) {
continue;
}
if (
!this.config.branch &&
this.config.fallbackBranch === '*' &&
@@ -59,6 +59,7 @@ describe('config', () => {
userPattern: /[\s\S]*/,
orgEnabled: false,
schedule: undefined,
skipForkedRepos: false,
}),
);
});
@@ -95,6 +96,45 @@ describe('config', () => {
userPattern: /[\s\S]*/,
orgEnabled: false,
schedule: undefined,
skipForkedRepos: false,
}),
);
});
it('valid config with skipForkedRepos', () => {
const config = new ConfigReader({
catalog: {
providers: {
gitlab: {
test: {
group: 'group',
host: 'host',
branch: 'not-master',
fallbackBranch: 'main',
entityFilename: 'custom-file.yaml',
skipForkedRepos: true,
},
},
},
},
});
const result = readGitlabConfigs(config);
expect(result).toHaveLength(1);
result.forEach(r =>
expect(r).toStrictEqual({
id: 'test',
group: 'group',
branch: 'not-master',
fallbackBranch: 'main',
host: 'host',
catalogFile: 'custom-file.yaml',
projectPattern: /[\s\S]*/,
groupPattern: /[\s\S]*/,
userPattern: /[\s\S]*/,
orgEnabled: false,
schedule: undefined,
skipForkedRepos: true,
}),
);
});
@@ -133,6 +173,7 @@ describe('config', () => {
groupPattern: /[\s\S]*/,
userPattern: /[\s\S]*/,
orgEnabled: false,
skipForkedRepos: false,
schedule: {
frequency: Duration.fromISO('PT30M'),
timeout: {
@@ -43,6 +43,8 @@ function readGitlabConfig(id: string, config: Config): GitlabProviderConfig {
config.getOptionalString('groupPattern') ?? /[\s\S]*/,
);
const orgEnabled: boolean = config.getOptionalBoolean('orgEnabled') ?? false;
const skipForkedRepos: boolean =
config.getOptionalBoolean('skipForkedRepos') ?? false;
const schedule = config.has('schedule')
? readTaskScheduleDefinitionFromConfig(config.getConfig('schedule'))
@@ -60,6 +62,7 @@ function readGitlabConfig(id: string, config: Config): GitlabProviderConfig {
groupPattern,
schedule,
orgEnabled,
skipForkedRepos,
};
}
+4 -5
View File
@@ -209,9 +209,8 @@ export interface Config {
*/
schedule?: TaskScheduleDefinitionConfig;
}
| Record<
string,
{
| {
[name: string]: {
/**
* The prefix of the target that this matches on, e.g.
* "https://graph.microsoft.com/v1.0", with no trailing slash.
@@ -296,8 +295,8 @@ export interface Config {
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule?: TaskScheduleDefinitionConfig;
}
>;
};
};
};
};
}
+4 -5
View File
@@ -46,9 +46,8 @@ export interface Config {
*/
schedule?: TaskScheduleDefinition;
}
| Record<
string,
{
| {
[name: string]: {
/**
* (Required) The base URL of PuppetDB API instance.
*/
@@ -61,8 +60,8 @@ export interface Config {
* (Optional) Task schedule definition for the refresh.
*/
schedule?: TaskScheduleDefinition;
}
>;
};
};
};
};
}
@@ -13,6 +13,12 @@
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-backend-module-unprocessed"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
@@ -23,7 +23,7 @@ import { assertError, serializeError, stringifyError } from '@backstage/errors';
import { Hash } from 'crypto';
import stableStringify from 'fast-json-stable-stringify';
import { Logger } from 'winston';
import { metrics } from '@opentelemetry/api';
import { metrics, trace } from '@opentelemetry/api';
import { ProcessingDatabase, RefreshStateItem } from '../database/types';
import { createCounterMetric, createSummaryMetric } from '../util/metrics';
import {
@@ -35,9 +35,16 @@ import { Stitcher } from '../stitching/Stitcher';
import { startTaskPipeline } from './TaskPipeline';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { Config } from '@backstage/config';
import {
addEntityAttributes,
TRACER_ID,
withActiveSpan,
} from '../util/opentelemetry';
const CACHE_TTL = 5;
const tracer = trace.getTracer(TRACER_ID);
export type ProgressTracker = ReturnType<typeof progressTracker>;
export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
@@ -131,177 +138,181 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
}
},
processTask: async item => {
const track = this.tracker.processStart(item, this.logger);
await withActiveSpan(tracer, 'ProcessingRun', async span => {
const track = this.tracker.processStart(item, this.logger);
addEntityAttributes(span, item.unprocessedEntity);
try {
const {
id,
state,
unprocessedEntity,
entityRef,
locationKey,
resultHash: previousResultHash,
} = item;
const result = await this.orchestrator.process({
entity: unprocessedEntity,
state,
});
try {
const {
id,
state,
unprocessedEntity,
entityRef,
locationKey,
resultHash: previousResultHash,
} = item;
const result = await this.orchestrator.process({
entity: unprocessedEntity,
state,
});
track.markProcessorsCompleted(result);
track.markProcessorsCompleted(result);
if (result.ok) {
const { ttl: _, ...stateWithoutTtl } = state ?? {};
if (
stableStringify(stateWithoutTtl) !== stableStringify(result.state)
) {
if (result.ok) {
const { ttl: _, ...stateWithoutTtl } = state ?? {};
if (
stableStringify(stateWithoutTtl) !==
stableStringify(result.state)
) {
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateEntityCache(tx, {
id,
state: {
ttl: CACHE_TTL,
...result.state,
},
});
});
}
} else {
const maybeTtl = state?.ttl;
const ttl = Number.isInteger(maybeTtl) ? (maybeTtl as number) : 0;
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateEntityCache(tx, {
id,
state: {
ttl: CACHE_TTL,
...result.state,
},
state: ttl > 0 ? { ...state, ttl: ttl - 1 } : {},
});
});
}
} else {
const maybeTtl = state?.ttl;
const ttl = Number.isInteger(maybeTtl) ? (maybeTtl as number) : 0;
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateEntityCache(tx, {
id,
state: ttl > 0 ? { ...state, ttl: ttl - 1 } : {},
const location =
unprocessedEntity?.metadata?.annotations?.[ANNOTATION_LOCATION];
for (const error of result.errors) {
this.logger.warn(error.message, {
entity: entityRef,
location,
});
});
}
}
const errorsString = JSON.stringify(
result.errors.map(e => serializeError(e)),
);
const location =
unprocessedEntity?.metadata?.annotations?.[ANNOTATION_LOCATION];
for (const error of result.errors) {
this.logger.warn(error.message, {
entity: entityRef,
location,
});
}
const errorsString = JSON.stringify(
result.errors.map(e => serializeError(e)),
);
let hashBuilder = this.createHash().update(errorsString);
let hashBuilder = this.createHash().update(errorsString);
if (result.ok) {
const { entityRefs: parents } =
await this.processingDatabase.transaction(tx =>
this.processingDatabase.listParents(tx, {
entityRef,
}),
);
hashBuilder = hashBuilder
.update(stableStringify({ ...result.completedEntity }))
.update(stableStringify([...result.deferredEntities]))
.update(stableStringify([...result.relations]))
.update(stableStringify([...result.refreshKeys]))
.update(stableStringify([...parents]));
}
const resultHash = hashBuilder.digest('hex');
if (resultHash === previousResultHash) {
// If nothing changed in our produced outputs, we cannot have any
// significant effect on our surroundings; therefore, we just abort
// without any updates / stitching.
track.markSuccessfulWithNoChanges();
return;
}
// If the result was marked as not OK, it signals that some part of the
// processing pipeline threw an exception. This can happen both as part of
// non-catastrophic things such as due to validation errors, as well as if
// something fatal happens inside the processing for other reasons. In any
// case, this means we can't trust that anything in the output is okay. So
// just store the errors and trigger a stich so that they become visible to
// the outside.
if (!result.ok) {
// notify the error listener if the entity can not be processed.
Promise.resolve(undefined)
.then(() =>
this.onProcessingError?.({
unprocessedEntity,
errors: result.errors,
}),
)
.catch(error => {
this.logger.debug(
`Processing error listener threw an exception, ${stringifyError(
error,
)}`,
if (result.ok) {
const { entityRefs: parents } =
await this.processingDatabase.transaction(tx =>
this.processingDatabase.listParents(tx, {
entityRef,
}),
);
});
hashBuilder = hashBuilder
.update(stableStringify({ ...result.completedEntity }))
.update(stableStringify([...result.deferredEntities]))
.update(stableStringify([...result.relations]))
.update(stableStringify([...result.refreshKeys]))
.update(stableStringify([...parents]));
}
const resultHash = hashBuilder.digest('hex');
if (resultHash === previousResultHash) {
// If nothing changed in our produced outputs, we cannot have any
// significant effect on our surroundings; therefore, we just abort
// without any updates / stitching.
track.markSuccessfulWithNoChanges();
return;
}
// If the result was marked as not OK, it signals that some part of the
// processing pipeline threw an exception. This can happen both as part of
// non-catastrophic things such as due to validation errors, as well as if
// something fatal happens inside the processing for other reasons. In any
// case, this means we can't trust that anything in the output is okay. So
// just store the errors and trigger a stich so that they become visible to
// the outside.
if (!result.ok) {
// notify the error listener if the entity can not be processed.
Promise.resolve(undefined)
.then(() =>
this.onProcessingError?.({
unprocessedEntity,
errors: result.errors,
}),
)
.catch(error => {
this.logger.debug(
`Processing error listener threw an exception, ${stringifyError(
error,
)}`,
);
});
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateProcessedEntityErrors(tx, {
id,
errors: errorsString,
resultHash,
});
});
await this.stitcher.stitch(
new Set([stringifyEntityRef(unprocessedEntity)]),
);
track.markSuccessfulWithErrors();
return;
}
result.completedEntity.metadata.uid = id;
let oldRelationSources: Map<string, string>;
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateProcessedEntityErrors(tx, {
id,
errors: errorsString,
resultHash,
});
const { previous } =
await this.processingDatabase.updateProcessedEntity(tx, {
id,
processedEntity: result.completedEntity,
resultHash,
errors: errorsString,
relations: result.relations,
deferredEntities: result.deferredEntities,
locationKey,
refreshKeys: result.refreshKeys,
});
oldRelationSources = new Map(
previous.relations.map(r => [
`${r.source_entity_ref}:${r.type}`,
r.source_entity_ref,
]),
);
});
await this.stitcher.stitch(
new Set([stringifyEntityRef(unprocessedEntity)]),
const newRelationSources = new Map<string, string>(
result.relations.map(relation => {
const sourceEntityRef = stringifyEntityRef(relation.source);
return [`${sourceEntityRef}:${relation.type}`, sourceEntityRef];
}),
);
track.markSuccessfulWithErrors();
return;
const setOfThingsToStitch = new Set<string>([
stringifyEntityRef(result.completedEntity),
]);
newRelationSources.forEach((sourceEntityRef, uniqueKey) => {
if (!oldRelationSources.has(uniqueKey)) {
setOfThingsToStitch.add(sourceEntityRef);
}
});
oldRelationSources!.forEach((sourceEntityRef, uniqueKey) => {
if (!newRelationSources.has(uniqueKey)) {
setOfThingsToStitch.add(sourceEntityRef);
}
});
await this.stitcher.stitch(setOfThingsToStitch);
track.markSuccessfulWithChanges(setOfThingsToStitch.size);
} catch (error) {
assertError(error);
track.markFailed(error);
}
result.completedEntity.metadata.uid = id;
let oldRelationSources: Map<string, string>;
await this.processingDatabase.transaction(async tx => {
const { previous } =
await this.processingDatabase.updateProcessedEntity(tx, {
id,
processedEntity: result.completedEntity,
resultHash,
errors: errorsString,
relations: result.relations,
deferredEntities: result.deferredEntities,
locationKey,
refreshKeys: result.refreshKeys,
});
oldRelationSources = new Map(
previous.relations.map(r => [
`${r.source_entity_ref}:${r.type}`,
r.source_entity_ref,
]),
);
});
const newRelationSources = new Map<string, string>(
result.relations.map(relation => {
const sourceEntityRef = stringifyEntityRef(relation.source);
return [`${sourceEntityRef}:${relation.type}`, sourceEntityRef];
}),
);
const setOfThingsToStitch = new Set<string>([
stringifyEntityRef(result.completedEntity),
]);
newRelationSources.forEach((sourceEntityRef, uniqueKey) => {
if (!oldRelationSources.has(uniqueKey)) {
setOfThingsToStitch.add(sourceEntityRef);
}
});
oldRelationSources!.forEach((sourceEntityRef, uniqueKey) => {
if (!newRelationSources.has(uniqueKey)) {
setOfThingsToStitch.add(sourceEntityRef);
}
});
await this.stitcher.stitch(setOfThingsToStitch);
track.markSuccessfulWithChanges(setOfThingsToStitch.size);
} catch (error) {
assertError(error);
track.markFailed(error);
}
});
},
});
}
@@ -194,10 +194,12 @@ describe('DefaultCatalogProcessingOrchestrator', () => {
it('runs all processor validations when asked to', async () => {
const validate = jest.fn(async () => true);
const processor1: Partial<CatalogProcessor> = {
const processor1: CatalogProcessor = {
getProcessorName: () => 'processor1',
validateEntityKind: validate,
};
const processor2: Partial<CatalogProcessor> = {
const processor2: CatalogProcessor = {
getProcessorName: () => 'processor2',
validateEntityKind: validate,
};
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { Span, trace } from '@opentelemetry/api';
import {
Entity,
EntityPolicy,
@@ -55,6 +56,13 @@ import {
} from './util';
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { ProcessorCacheManager } from './ProcessorCacheManager';
import {
addEntityAttributes,
TRACER_ID,
withActiveSpan,
} from '../util/opentelemetry';
const tracer = trace.getTracer(TRACER_ID);
type Context = {
entityRef: string;
@@ -64,6 +72,18 @@ type Context = {
cache: ProcessorCacheManager;
};
function addProcessorAttributes(
span: Span,
stage: string,
processor: CatalogProcessor,
) {
span.setAttribute('backstage.catalog.processor.stage', stage);
span.setAttribute(
'backstage.catalog.processor.name',
processor.getProcessorName(),
);
}
/** @public */
export class DefaultCatalogProcessingOrchestrator
implements CatalogProcessingOrchestrator
@@ -179,54 +199,71 @@ export class DefaultCatalogProcessingOrchestrator
entity: Entity,
context: Context,
): Promise<Entity> {
let res = entity;
return await withActiveSpan(tracer, 'ProcessingStage', async stageSpan => {
addEntityAttributes(stageSpan, entity);
stageSpan.setAttribute('backstage.catalog.processor.stage', 'preProcess');
let res = entity;
for (const processor of this.options.processors) {
if (processor.preProcessEntity) {
try {
res = await processor.preProcessEntity(
res,
context.location,
context.collector.forProcessor(processor),
context.originLocation,
context.cache.forProcessor(processor),
);
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while preprocessing`,
e,
);
for (const processor of this.options.processors) {
if (processor.preProcessEntity) {
let innerRes = res;
res = await withActiveSpan(tracer, 'ProcessingStep', async span => {
addEntityAttributes(span, entity);
addProcessorAttributes(span, 'preProcessEntity', processor);
try {
innerRes = await processor.preProcessEntity!(
innerRes,
context.location,
context.collector.forProcessor(processor),
context.originLocation,
context.cache.forProcessor(processor),
);
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while preprocessing`,
e,
);
}
return innerRes;
});
}
}
}
return res;
return res;
});
}
/**
* Enforce entity policies making sure that entities conform to a general schema
*/
private async runPolicyStep(entity: Entity): Promise<Entity> {
let policyEnforcedEntity: Entity | undefined;
try {
policyEnforcedEntity = await this.options.policy.enforce(entity);
} catch (e) {
throw new InputError(
`Policy check failed for ${stringifyEntityRef(entity)}`,
e,
return await withActiveSpan(tracer, 'ProcessingStage', async stageSpan => {
addEntityAttributes(stageSpan, entity);
stageSpan.setAttribute(
'backstage.catalog.processor.stage',
'enforcePolicy',
);
}
let policyEnforcedEntity: Entity | undefined;
if (!policyEnforcedEntity) {
throw new Error(
`Policy unexpectedly returned no data for ${stringifyEntityRef(
entity,
)}`,
);
}
try {
policyEnforcedEntity = await this.options.policy.enforce(entity);
} catch (e) {
throw new InputError(
`Policy check failed for ${stringifyEntityRef(entity)}`,
e,
);
}
return policyEnforcedEntity;
if (!policyEnforcedEntity) {
throw new Error(
`Policy unexpectedly returned no data for ${stringifyEntityRef(
entity,
)}`,
);
}
return policyEnforcedEntity;
});
}
/**
@@ -236,50 +273,62 @@ export class DefaultCatalogProcessingOrchestrator
entity: Entity,
context: Context,
): Promise<void> {
// Double check that none of the previous steps tried to change something
// related to the entity ref, which would break downstream
if (stringifyEntityRef(entity) !== context.entityRef) {
throw new ConflictError(
'Fatal: The entity kind, namespace, or name changed during processing',
);
}
return await withActiveSpan(tracer, 'ProcessingStage', async stageSpan => {
addEntityAttributes(stageSpan, entity);
stageSpan.setAttribute('backstage.catalog.processor.stage', 'validate');
// Double check that none of the previous steps tried to change something
// related to the entity ref, which would break downstream
if (stringifyEntityRef(entity) !== context.entityRef) {
throw new ConflictError(
'Fatal: The entity kind, namespace, or name changed during processing',
);
}
// Validate that the end result is a valid Entity at all
try {
validateEntity(entity);
} catch (e) {
throw new ConflictError(
`Entity envelope for ${context.entityRef} failed validation after preprocessing`,
e,
);
}
// Validate that the end result is a valid Entity at all
try {
validateEntity(entity);
} catch (e) {
throw new ConflictError(
`Entity envelope for ${context.entityRef} failed validation after preprocessing`,
e,
);
}
let valid = false;
let valid = false;
for (const processor of this.options.processors) {
if (processor.validateEntityKind) {
try {
const thisValid = await processor.validateEntityKind(entity);
if (thisValid) {
valid = true;
if (this.options.legacySingleProcessorValidation) {
break;
for (const processor of this.options.processors) {
if (processor.validateEntityKind) {
try {
const thisValid = await withActiveSpan(
tracer,
'ProcessingStep',
async span => {
addEntityAttributes(span, entity);
addProcessorAttributes(span, 'validateEntityKind', processor);
return await processor.validateEntityKind!(entity);
},
);
if (thisValid) {
valid = true;
if (this.options.legacySingleProcessorValidation) {
break;
}
}
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while validating the entity ${context.entityRef}`,
e,
);
}
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while validating the entity ${context.entityRef}`,
e,
);
}
}
}
if (!valid) {
throw new InputError(
`No processor recognized the entity ${context.entityRef} as valid, possibly caused by a foreign kind or apiVersion`,
);
}
if (!valid) {
throw new InputError(
`No processor recognized the entity ${context.entityRef} as valid, possibly caused by a foreign kind or apiVersion`,
);
}
});
}
/**
@@ -289,65 +338,81 @@ export class DefaultCatalogProcessingOrchestrator
entity: LocationEntity,
context: Context,
): Promise<void> {
const { type = context.location.type, presence = 'required' } = entity.spec;
const targets = new Array<string>();
if (entity.spec.target) {
targets.push(entity.spec.target);
}
if (entity.spec.targets) {
targets.push(...entity.spec.targets);
}
for (const maybeRelativeTarget of targets) {
if (type === 'file' && maybeRelativeTarget.endsWith(path.sep)) {
context.collector.generic()(
processingResult.inputError(
context.location,
`LocationEntityProcessor cannot handle ${type} type location with target ${context.location.target} that ends with a path separator`,
),
);
continue;
}
const target = toAbsoluteUrl(
this.options.integrations,
context.location,
type,
maybeRelativeTarget,
return await withActiveSpan(tracer, 'ProcessingStage', async stageSpan => {
addEntityAttributes(stageSpan, entity);
stageSpan.setAttribute(
'backstage.catalog.processor.stage',
'readLocation',
);
const { type = context.location.type, presence = 'required' } =
entity.spec;
const targets = new Array<string>();
if (entity.spec.target) {
targets.push(entity.spec.target);
}
if (entity.spec.targets) {
targets.push(...entity.spec.targets);
}
let didRead = false;
for (const processor of this.options.processors) {
if (processor.readLocation) {
try {
const read = await processor.readLocation(
{
type,
target,
presence,
},
presence === 'optional',
context.collector.forProcessor(processor),
this.options.parser,
context.cache.forProcessor(processor, target),
);
if (read) {
didRead = true;
break;
for (const maybeRelativeTarget of targets) {
if (type === 'file' && maybeRelativeTarget.endsWith(path.sep)) {
context.collector.generic()(
processingResult.inputError(
context.location,
`LocationEntityProcessor cannot handle ${type} type location with target ${context.location.target} that ends with a path separator`,
),
);
continue;
}
const target = toAbsoluteUrl(
this.options.integrations,
context.location,
type,
maybeRelativeTarget,
);
let didRead = false;
for (const processor of this.options.processors) {
if (processor.readLocation) {
try {
const read = await withActiveSpan(
tracer,
'ProcessingStep',
async span => {
addEntityAttributes(span, entity);
addProcessorAttributes(span, 'readLocation', processor);
return await processor.readLocation!(
{
type,
target,
presence,
},
presence === 'optional',
context.collector.forProcessor(processor),
this.options.parser,
context.cache.forProcessor(processor, target),
);
},
);
if (read) {
didRead = true;
break;
}
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while reading ${type}:${target}`,
e,
);
}
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while reading ${type}:${target}`,
e,
);
}
}
if (!didRead) {
throw new InputError(
`No processor was able to handle reading of ${type}:${target}`,
);
}
}
if (!didRead) {
throw new InputError(
`No processor was able to handle reading of ${type}:${target}`,
);
}
}
});
}
/**
@@ -357,26 +422,39 @@ export class DefaultCatalogProcessingOrchestrator
entity: Entity,
context: Context,
): Promise<Entity> {
let res = entity;
return await withActiveSpan(tracer, 'ProcessingStage', async stageSpan => {
addEntityAttributes(stageSpan, entity);
stageSpan.setAttribute(
'backstage.catalog.processor.stage',
'postProcessEntity',
);
let res = entity;
for (const processor of this.options.processors) {
if (processor.postProcessEntity) {
try {
res = await processor.postProcessEntity(
res,
context.location,
context.collector.forProcessor(processor),
context.cache.forProcessor(processor),
);
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while postprocessing`,
e,
);
for (const processor of this.options.processors) {
if (processor.postProcessEntity) {
let innerRes = res;
res = await withActiveSpan(tracer, 'ProcessingStep', async span => {
addEntityAttributes(span, entity);
addProcessorAttributes(span, 'postProcessEntity', processor);
try {
innerRes = await processor.postProcessEntity!(
innerRes,
context.location,
context.collector.forProcessor(processor),
context.cache.forProcessor(processor),
);
} catch (e) {
throw new InputError(
`Processor ${processor.constructor.name} threw an error while postprocessing`,
e,
);
}
return innerRes;
});
}
}
}
return res;
return res;
});
}
}
@@ -0,0 +1,101 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Span, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
import { Entity } from '@backstage/catalog-model';
export const TRACER_ID = 'backstage-plugin-catalog-backend';
function setAttributeIfDefined(span: Span, attribute: string, value?: string) {
if (value !== null && value !== undefined) {
span.setAttribute(attribute, value);
}
}
export function addEntityAttributes(span: Span, entity: Entity) {
setAttributeIfDefined(span, 'backstage.entity.apiVersion', entity.apiVersion);
setAttributeIfDefined(span, 'backstage.entity.kind', entity.kind);
setAttributeIfDefined(
span,
'backstage.entity.metadata.namespace',
entity.metadata?.namespace,
);
setAttributeIfDefined(
span,
'backstage.entity.metadata.name',
entity.metadata?.name,
);
}
// Adapted from https://github.com/open-telemetry/opentelemetry-js/blob/359fbcc40a859057a02b14e84599eac399b8dba7/api/src/trace/SugaredTracer.ts
// While waiting for something like https://github.com/open-telemetry/opentelemetry-js/pull/3317 to land upstream
const onException = (e: Error, span: Span) => {
span.recordException(e);
span.setStatus({
code: SpanStatusCode.ERROR,
});
};
function isPromiseLike<T, S>(obj: PromiseLike<T> | S): obj is PromiseLike<T> {
return (
!!obj &&
(typeof obj === 'object' || typeof obj === 'function') &&
'then' in obj &&
typeof obj.then === 'function'
);
}
function handleFn<F extends (span: Span) => ReturnType<F>>(
span: Span,
fn: F,
): ReturnType<F> {
try {
const ret = fn(span);
// if fn is an async function attach a recordException and spanEnd callback to the promise
if (isPromiseLike(ret)) {
ret.then(
() => {
span.end();
},
e => {
onException(e, span);
span.end();
},
);
} else {
span.end();
}
return ret;
} catch (e) {
onException(e, span);
span.end();
throw e;
}
}
export function withActiveSpan<F extends (span: Span) => ReturnType<F>>(
tracer: Tracer,
name: string,
fn: F,
spanOptions: SpanOptions = {},
): ReturnType<F> {
return tracer.startActiveSpan(name, spanOptions, (span: Span) => {
return handleFn(span, fn);
});
}
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-graph"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -26,6 +26,12 @@
"backstage": {
"role": "node-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-node"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
@@ -13,6 +13,12 @@
"backstage": {
"role": "frontend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/cicd-statistics-module-gitlab"
},
"keywords": [
"backstage",
"cicd statistics",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/code-climate"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
@@ -13,6 +13,12 @@
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/code-coverage-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/code-coverage"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/codescene"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
+6
View File
@@ -13,6 +13,12 @@
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/config-schema"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
+10
View File
@@ -40,5 +40,15 @@ export interface Config {
target: string;
}>;
};
/**
* Info configuration
*/
info?: {
/**
* A list of package prefixes that DevTools will use for filtering all available dependencies
* (default is ["@backstage"])
*/
packagePrefixes?: string[];
};
};
}
+6
View File
@@ -12,6 +12,12 @@
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/devtools-backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
@@ -219,7 +219,12 @@ export class DevToolsBackendApi {
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const deps = [...lockfile.keys()].filter(n => n.startsWith('@backstage/'));
const prefixes = ['@backstage', '@internal'].concat(
this.config.getOptionalStringArray('devTools.info.packagePrefixes') ?? [],
);
const deps = [...lockfile.keys()].filter(n =>
prefixes.some(prefix => n.startsWith(prefix)),
);
const infoDependencies: PackageDependency[] = [];
for (const dep of deps) {

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