Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@backstage/plugin-search-backend-node": "backstage:^",
"@backstage/plugin-techdocs-backend": "backstage:^",
"@giantswarm/backstage-plugin-auth-backend-module-gs": "^0.8.0",
"@giantswarm/backstage-plugin-catalog-backend-module-gs": "^0.1.0",
"@giantswarm/backstage-plugin-scaffolder-backend-module-gs": "^0.7.0",
"@giantswarm/backstage-plugin-techdocs-backend-module-gs": "^0.7.0",
"app": "link:../app",
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ backend.add(import('@backstage/plugin-catalog-backend-module-aws'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
backend.add(import('@giantswarm/backstage-plugin-catalog-backend-module-gs'));

// permission plugin
backend.add(import('@backstage/plugin-permission-backend'));
Expand Down
1 change: 1 addition & 0 deletions plugins/catalog-backend-module-gs/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
3 changes: 3 additions & 0 deletions plugins/catalog-backend-module-gs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @giantswarm/backstage-plugin-catalog-backend-module-gs

The GS backend module for the catalog plugin.
45 changes: 45 additions & 0 deletions plugins/catalog-backend-module-gs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@giantswarm/backstage-plugin-catalog-backend-module-gs",
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"description": "The GS backend module for the catalog plugin.",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-plugin-api": "backstage:^",
"@backstage/catalog-model": "backstage:^",
"@backstage/config": "backstage:^",
"@backstage/integration": "backstage:^",
"@backstage/plugin-catalog-common": "backstage:^",
"@backstage/plugin-catalog-node": "backstage:^",
"@giantswarm/backstage-plugin-gs-common": "^0.12.0",
"@octokit/graphql": "^8.2.1",
"@types/lodash": "^4.17.5",
"lodash": "^4.17.21"
},
"devDependencies": {
"@backstage/backend-test-utils": "backstage:^",
"@backstage/cli": "backstage:^"
},
"files": [
"dist"
]
}
8 changes: 8 additions & 0 deletions plugins/catalog-backend-module-gs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/***/
/**
* The GS backend module for the catalog plugin.
*
* @packageDocumentation
*/

export { catalogModuleGS as default } from './module';
31 changes: 31 additions & 0 deletions plugins/catalog-backend-module-gs/src/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import {
ServiceDeploymentsProcessor,
ServiceReleaseInfoProcessor,
} from './processors';

export const catalogModuleGS = createBackendModule({
pluginId: 'catalog',
moduleId: 'gs',
register(reg) {
reg.registerInit({
deps: {
catalog: catalogProcessingExtensionPoint,
config: coreServices.rootConfig,
logger: coreServices.logger,
},
async init({ catalog, config, logger }) {
catalog.addProcessor([
new ServiceDeploymentsProcessor(),
ServiceReleaseInfoProcessor.fromConfig(config, {
logger,
}),
]);
},
});
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ServiceDeploymentsProcessor } from './ServiceDeploymentsProcessor';
import { Entity } from '@backstage/catalog-model';
import { setupEntities } from './testUtils';

describe('ServiceDeploymentsProcessor', () => {
let processor: ServiceDeploymentsProcessor;

beforeEach(() => {
processor = new ServiceDeploymentsProcessor();
});

it('should return the entity unchanged if it is not a GS service', async () => {
const { component, website, service, api, system, user, group } =
setupEntities();

expect(await processor.preProcessEntity(component)).toEqual(component);
expect(await processor.preProcessEntity(website)).toEqual(website);
expect(await processor.preProcessEntity(service)).toEqual(service);
expect(await processor.preProcessEntity(api)).toEqual(api);
expect(await processor.preProcessEntity(system)).toEqual(system);
expect(await processor.preProcessEntity(user)).toEqual(user);
expect(await processor.preProcessEntity(group)).toEqual(group);
});

it('should process GS service entity and add GS_DEPLOYMENT_NAMES annotation', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'gs-service',
annotations: {
'backstage.io/source-location':
'url:https://github.com/giantswarm/gs-service',
},
},
spec: {
type: 'service',
},
};

expect(await processor.preProcessEntity(entity)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'gs-service',
annotations: {
'backstage.io/source-location':
'url:https://github.com/giantswarm/gs-service',
'giantswarm.io/deployment-names': 'gs-service,gs-service-app',
},
},
spec: {
type: 'service',
},
});
});

it('should handle entity name with "-app" suffix correctly', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'gs-service-app',
annotations: {
'backstage.io/source-location':
'url:https://github.com/giantswarm/gs-service',
},
},
spec: {
type: 'service',
},
};

expect(await processor.preProcessEntity(entity)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'gs-service-app',
annotations: {
'backstage.io/source-location':
'url:https://github.com/giantswarm/gs-service',
'giantswarm.io/deployment-names': 'gs-service,gs-service-app',
},
},
spec: {
type: 'service',
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';
import { GS_DEPLOYMENT_NAMES } from '@giantswarm/backstage-plugin-gs-common';
import merge from 'lodash/merge';
import { isGSService } from './utils';

export class ServiceDeploymentsProcessor implements CatalogProcessor {
getProcessorName(): string {
return 'ServiceDeploymentsProcessor';
}

async preProcessEntity(entity: Entity): Promise<Entity> {
if (!isGSService(entity)) {
return entity;
}

const nameWithoutAppSuffix = entity.metadata.name.replace(/-app$/, '');
const nameWithAppSuffix = `${nameWithoutAppSuffix}-app`;

const names = [nameWithoutAppSuffix, nameWithAppSuffix];

return merge(
{
metadata: {
annotations: {
[GS_DEPLOYMENT_NAMES]: names.join(','),
},
},
},
entity,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Entity, getEntitySourceLocation } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
GithubCredentialType,
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import {
GS_LATEST_RELEASE_DATE,
GS_LATEST_RELEASE_TAG,
} from '@giantswarm/backstage-plugin-gs-common';
import { graphql } from '@octokit/graphql';
import merge from 'lodash/merge';
import { formatVersion, isGSService } from './utils';
import { getRepositoryLatestRelease } from './github';

type GraphQL = typeof graphql;

export class ServiceReleaseInfoProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrationRegistry;
private readonly logger: LoggerService;
private readonly githubCredentialsProvider: GithubCredentialsProvider;

static fromConfig(
config: Config,
options: {
logger: LoggerService;
githubCredentialsProvider?: GithubCredentialsProvider;
},
) {
const integrations = ScmIntegrations.fromConfig(config);

return new ServiceReleaseInfoProcessor({
...options,
integrations,
});
}

constructor(options: {
integrations: ScmIntegrationRegistry;
logger: LoggerService;
githubCredentialsProvider?: GithubCredentialsProvider;
}) {
this.integrations = options.integrations;
this.logger = options.logger;
this.githubCredentialsProvider =
options.githubCredentialsProvider ||
DefaultGithubCredentialsProvider.fromIntegrations(this.integrations);
}

getProcessorName(): string {
return 'ServiceReleaseInfoProcessor';
}

async preProcessEntity(entity: Entity): Promise<Entity> {
if (!isGSService(entity)) {
return entity;
}

const sourceLocation = getEntitySourceLocation(entity);

const { client } = await this.createClient(sourceLocation.target);
const { org, repoName } = parseGithubRepoUrl(sourceLocation.target);

this.logger.info(
`Reading latest release information for ${sourceLocation.target}`,
);
const response = await getRepositoryLatestRelease(client, org, repoName);

if (!response) {
return entity;
}

return merge(
{
metadata: {
annotations: {
[GS_LATEST_RELEASE_TAG]: formatVersion(response.name),
[GS_LATEST_RELEASE_DATE]: response.createdAt,
},
},
},
entity,
);
}

private async createClient(
repoUrl: string,
): Promise<{ client: GraphQL; tokenType: GithubCredentialType }> {
const gitHubConfig = this.integrations.github.byUrl(repoUrl)?.config;

if (!gitHubConfig) {
throw new Error(
`There is no GitHub provider that matches ${repoUrl}. Please add a configuration for an integration.`,
);
}

const { headers, type: tokenType } =
await this.githubCredentialsProvider.getCredentials({
url: repoUrl,
});

const client = graphql.defaults({
baseUrl: gitHubConfig.apiBaseUrl,
headers,
});

return { client, tokenType };
}
}

export function parseGithubRepoUrl(urlString: string): {
org: string;
repoName: string;
} {
const path = new URL(urlString).pathname.slice(1).split('/');

if (path.length === 2 && path[0].length && path[1].length) {
return {
org: decodeURIComponent(path[0]),
repoName: decodeURIComponent(path[1]),
};
}

throw new Error(`Expected a URL pointing to /<org>/<repo>, got ${urlString}`);
}
Loading