Merge branch 'master' into new-release-31-aug-20
This commit is contained in:
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import { notFoundHandler } from '@backstage/backend-common';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
@@ -23,19 +23,14 @@ import { injectEnvConfig } from '../lib/config';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
appPackageName?: string;
|
||||
appPackageName: string;
|
||||
staticFallbackHandler?: express.Handler;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const appDistDir = resolvePath(
|
||||
dirname(
|
||||
__non_webpack_require__.resolve(`${options.appPackageName}/package.json`),
|
||||
),
|
||||
'dist',
|
||||
);
|
||||
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
|
||||
options.logger.info(`Serving static app content from ${appDistDir}`);
|
||||
|
||||
await injectEnvConfig({
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@backstage/backend-common": "^0.1.1-alpha.21",
|
||||
"@backstage/config": "^0.1.1-alpha.21",
|
||||
"@types/express": "^4.17.6",
|
||||
"body-parser": "^1.19.0",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
*/
|
||||
|
||||
import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { utc } from 'moment';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { AnyJWK, KeyStore, StoredKey } from './types';
|
||||
|
||||
const migrationsDir = path.resolve(
|
||||
require.resolve('@backstage/plugin-auth-backend/package.json'),
|
||||
'../migrations',
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-auth-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
const TABLE = 'signing_keys';
|
||||
|
||||
@@ -18,8 +18,6 @@ import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../identity';
|
||||
import { Config } from '@backstage/config';
|
||||
import { OAuthProvider } from '../lib/OAuthProvider';
|
||||
import { SamlAuthProvider } from './saml/provider';
|
||||
|
||||
export type OAuthProviderOptions = {
|
||||
/**
|
||||
@@ -174,7 +172,7 @@ export type AuthProviderFactory = (
|
||||
envConfig: Config,
|
||||
logger: Logger,
|
||||
issuer: TokenIssuer,
|
||||
) => OAuthProvider | SamlAuthProvider | undefined;
|
||||
) => AuthProviderRouteHandlers | undefined;
|
||||
|
||||
export type AuthResponse<ProviderInfo> = {
|
||||
providerInfo: ProviderInfo;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import bodyParser from 'body-parser';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { createAuthProviderRouter } from '../providers';
|
||||
@@ -53,8 +52,8 @@ export async function createRouter(
|
||||
});
|
||||
|
||||
router.use(cookieParser());
|
||||
router.use(bodyParser.urlencoded({ extended: false }));
|
||||
router.use(bodyParser.json());
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
router.use(express.json());
|
||||
|
||||
const providersConfig = options.config.getConfig('auth.providers');
|
||||
const providers = providersConfig.keys();
|
||||
|
||||
@@ -14,17 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
|
||||
import { makeValidator } from '@backstage/catalog-model';
|
||||
import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { CommonDatabase } from './CommonDatabase';
|
||||
import { Database } from './types';
|
||||
|
||||
const migrationsDir = path.resolve(
|
||||
require.resolve('@backstage/plugin-catalog-backend/package.json'),
|
||||
'../migrations',
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-catalog-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
export type CreateDatabaseOptions = {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LocationSpec, Entity } from '@backstage/catalog-model';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
const entity = {
|
||||
user: {
|
||||
kind: 'User',
|
||||
} as Entity,
|
||||
group: {
|
||||
kind: 'Group',
|
||||
} as Entity,
|
||||
component: {
|
||||
kind: 'component',
|
||||
} as Entity,
|
||||
location: {
|
||||
kind: 'Location',
|
||||
} as Entity,
|
||||
};
|
||||
|
||||
const location: Record<string, LocationSpec> = {
|
||||
x: {
|
||||
type: 'github',
|
||||
target: 'https://github.com/a/b/blob/master/x.yaml',
|
||||
},
|
||||
y: {
|
||||
type: 'github',
|
||||
target: 'https://github.com/a/b/blob/master/y.yaml',
|
||||
},
|
||||
z: {
|
||||
type: 'file',
|
||||
target: '/root/z.yaml',
|
||||
},
|
||||
};
|
||||
|
||||
describe('CatalogRulesEnforcer', () => {
|
||||
it('should deny by default', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny all', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([{ allow: [] }]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow all', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([
|
||||
{
|
||||
allow: ['User', 'Group', 'Component', 'Location'].map(kind => ({
|
||||
kind,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny groups', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([
|
||||
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
|
||||
]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny groups from github', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([
|
||||
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
|
||||
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
|
||||
]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow groups from files', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([
|
||||
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
|
||||
]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be sensitive to kind case', () => {
|
||||
const enforcer = new CatalogRulesEnforcer([
|
||||
{ allow: [{ kind: 'group' }] },
|
||||
{ allow: [{ kind: 'Component' }] },
|
||||
]);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
describe('fromConfig', () => {
|
||||
it('should allow components by default', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(new ConfigReader({}));
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny all', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({ catalog: { rules: [] } }),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow all', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({
|
||||
catalog: {
|
||||
rules: [{ allow: ['User', 'Group'] }, { allow: ['Component'] }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny groups', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({
|
||||
catalog: { rules: [{ allow: ['User'] }, { allow: ['Component'] }] },
|
||||
}),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow groups from a specific github location', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({
|
||||
catalog: {
|
||||
rules: [{ allow: ['user'] }],
|
||||
locations: [
|
||||
{
|
||||
type: 'github',
|
||||
target: 'https://github.com/a/b/blob/master/x.yaml',
|
||||
rules: [
|
||||
{
|
||||
allow: ['Group'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not care about location configuration in catalog.rules', () => {
|
||||
const enforcer = CatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({
|
||||
catalog: {
|
||||
rules: [{ allow: ['Group'], locations: [{ type: 'github' }] }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { LocationSpec, Entity } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* A structure for matching entities to a given rule.
|
||||
*/
|
||||
type EntityMatcher = {
|
||||
kind: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A structure for matching locations to a given rule.
|
||||
*/
|
||||
type LocationMatcher = {
|
||||
target?: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rules to apply to catalog entities
|
||||
*
|
||||
* An undefined list of matchers means match all, an empty list of matchers means match none
|
||||
*/
|
||||
type CatalogRule = {
|
||||
allow: EntityMatcher[];
|
||||
locations?: LocationMatcher[];
|
||||
};
|
||||
|
||||
export class CatalogRulesEnforcer {
|
||||
/**
|
||||
* Default rules used by the catalog.
|
||||
*
|
||||
* Denies any location from specifying user or group entities.
|
||||
*/
|
||||
static readonly defaultRules: CatalogRule[] = [
|
||||
{
|
||||
allow: ['Component', 'API', 'Location'].map(kind => ({ kind })),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Loads catalog rules from config.
|
||||
*
|
||||
* This reads `catalog.rules` and defaults to the default rules if no value is present.
|
||||
* The value of the config should be a list of config objects, each with a single `allow`
|
||||
* field which in turn is a list of entity kinds to allow.
|
||||
*
|
||||
* If there is no matching rule to allow an ingested entity, it will be rejected by the catalog.
|
||||
*
|
||||
* It also reads in rules from `catalog.locations`, where each location can have a list
|
||||
* of rules for that specific location, specified in a `rules` field.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```yaml
|
||||
* catalog:
|
||||
* rules:
|
||||
* - allow: [Component, API]
|
||||
*
|
||||
* locations:
|
||||
* - type: github
|
||||
* target: https://github.com/org/repo/blob/master/users.yaml
|
||||
* rules:
|
||||
* - allow: [User, Group]
|
||||
* - type: github
|
||||
* target: https://github.com/org/repo/blob/master/systems.yaml
|
||||
* rules:
|
||||
* - allow: [System]
|
||||
* ```
|
||||
*/
|
||||
static fromConfig(config: Config) {
|
||||
const rules = new Array<CatalogRule>();
|
||||
|
||||
if (config.has('catalog.rules')) {
|
||||
const globalRules = config.getConfigArray('catalog.rules').map(sub => ({
|
||||
allow: sub.getStringArray('allow').map(kind => ({ kind })),
|
||||
}));
|
||||
rules.push(...globalRules);
|
||||
} else {
|
||||
rules.push(...CatalogRulesEnforcer.defaultRules);
|
||||
}
|
||||
|
||||
if (config.has('catalog.locations')) {
|
||||
const locationRules = config
|
||||
.getConfigArray('catalog.locations')
|
||||
.flatMap(locConf => {
|
||||
if (!locConf.has('rules')) {
|
||||
return [];
|
||||
}
|
||||
const type = locConf.getString('type');
|
||||
const target = locConf.getString('target');
|
||||
|
||||
return locConf.getConfigArray('rules').map(ruleConf => ({
|
||||
allow: ruleConf.getStringArray('allow').map(kind => ({ kind })),
|
||||
locations: [{ type, target }],
|
||||
}));
|
||||
});
|
||||
|
||||
rules.push(...locationRules);
|
||||
}
|
||||
|
||||
return new CatalogRulesEnforcer(rules);
|
||||
}
|
||||
|
||||
constructor(private readonly rules: CatalogRule[]) {}
|
||||
|
||||
/**
|
||||
* Checks wether a specific entity/location combination is allowed
|
||||
* according to the configured rules.
|
||||
*/
|
||||
isAllowed(entity: Entity, location: LocationSpec) {
|
||||
for (const rule of this.rules) {
|
||||
if (!this.matchLocation(location, rule.locations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.matchEntity(entity, rule.allow)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private matchLocation(
|
||||
location: LocationSpec,
|
||||
matchers?: LocationMatcher[],
|
||||
): boolean {
|
||||
if (!matchers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
if (matcher.type !== location.type) {
|
||||
continue;
|
||||
}
|
||||
if (matcher.target && matcher.target !== location.target) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean {
|
||||
if (!matchers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from './processors/types';
|
||||
import { YamlProcessor } from './processors/YamlProcessor';
|
||||
import { LocationReader, ReadLocationResult } from './types';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
|
||||
// The max amount of nesting depth of generated work items
|
||||
const MAX_DEPTH = 10;
|
||||
@@ -63,6 +64,7 @@ type Options = {
|
||||
export class LocationReaders implements LocationReader {
|
||||
private readonly logger: Logger;
|
||||
private readonly processors: LocationProcessor[];
|
||||
private readonly rulesEnforcer: CatalogRulesEnforcer;
|
||||
|
||||
static defaultProcessors(options: {
|
||||
config?: Config;
|
||||
@@ -96,6 +98,9 @@ export class LocationReaders implements LocationReader {
|
||||
}: Options) {
|
||||
this.logger = logger;
|
||||
this.processors = processors;
|
||||
this.rulesEnforcer = config
|
||||
? CatalogRulesEnforcer.fromConfig(config)
|
||||
: new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules);
|
||||
}
|
||||
|
||||
async read(location: LocationSpec): Promise<ReadLocationResult> {
|
||||
@@ -112,11 +117,20 @@ export class LocationReaders implements LocationReader {
|
||||
} else if (item.type === 'data') {
|
||||
await this.handleData(item, emit);
|
||||
} else if (item.type === 'entity') {
|
||||
const entity = await this.handleEntity(item, emit);
|
||||
output.entities.push({
|
||||
entity,
|
||||
location: item.location,
|
||||
});
|
||||
if (this.rulesEnforcer.isAllowed(item.entity, item.location)) {
|
||||
const entity = await this.handleEntity(item, emit);
|
||||
output.entities.push({
|
||||
entity,
|
||||
location: item.location,
|
||||
});
|
||||
} else {
|
||||
output.errors.push({
|
||||
location: item.location,
|
||||
error: new Error(
|
||||
`Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'error') {
|
||||
await this.handleError(item, emit);
|
||||
output.errors.push({
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"@backstage/plugin-github-actions": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-jenkins": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-techdocs": "^0.1.1-alpha.21",
|
||||
"@backstage/theme": "^0.1.1-alpha.21",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AboutCard } from './AboutCard';
|
||||
|
||||
describe('<AboutCard />', () => {
|
||||
it('renders info and "view source" link', () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'github:https://github.com/spotify/backstage/blob/master/software.yaml',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const { getByText } = render(<AboutCard entity={entity} />);
|
||||
expect(getByText('service')).toBeInTheDocument();
|
||||
expect(getByText('View Source').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/spotify/backstage/blob/master/software.yaml',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Typography,
|
||||
makeStyles,
|
||||
Chip,
|
||||
IconButton,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Divider,
|
||||
} from '@material-ui/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import { IconLinkVertical } from './IconLinkVertical';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
links: {
|
||||
margin: theme.spacing(2, 0),
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column',
|
||||
gridAutoColumns: 'min-content',
|
||||
gridGap: theme.spacing(3),
|
||||
},
|
||||
label: {
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 0.5,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
value: {
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '24px',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
description: {
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}));
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
github: <GitHubIcon />,
|
||||
};
|
||||
|
||||
type CodeLinkInfo = { icon?: React.ReactNode; href?: string };
|
||||
|
||||
function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
|
||||
const location =
|
||||
entity?.metadata?.annotations?.['backstage.io/managed-by-location'];
|
||||
|
||||
if (location) {
|
||||
// split by first `:`
|
||||
// e.g. "github:https://github.com/spotify/backstage/blob/master/software.yaml"
|
||||
const [type, target] = location.split(/:(.+)/);
|
||||
|
||||
return { icon: iconMap[type], href: target };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
type AboutCardProps = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export function AboutCard({ entity }: AboutCardProps) {
|
||||
const classes = useStyles();
|
||||
const codeLink = getCodeLinkInfo(entity);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="About"
|
||||
action={
|
||||
<IconButton href={codeLink.href || '#'} aria-label="Edit">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
}
|
||||
subheader={
|
||||
<nav className={classes.links}>
|
||||
<IconLinkVertical label="View Source" {...codeLink} />
|
||||
<IconLinkVertical
|
||||
label="View Techdocs"
|
||||
icon={<DocsIcon />}
|
||||
href={`/docs/${''}`}
|
||||
/>
|
||||
</nav>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Grid container>
|
||||
<AboutField label="Description" gridSizes={{ xs: 12 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
paragraph
|
||||
className={classes.description}
|
||||
>
|
||||
{entity?.metadata?.description || 'No description'}
|
||||
</Typography>
|
||||
</AboutField>
|
||||
<AboutField
|
||||
label="Owner"
|
||||
value={entity?.spec?.owner as string}
|
||||
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
|
||||
/>
|
||||
<AboutField
|
||||
label="Type"
|
||||
value={entity?.spec?.type as string}
|
||||
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
|
||||
/>
|
||||
<AboutField
|
||||
label="Lifecycle"
|
||||
value={entity?.spec?.lifecycle as string}
|
||||
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
|
||||
/>
|
||||
<AboutField
|
||||
label="Tags"
|
||||
value="No Tags"
|
||||
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
|
||||
>
|
||||
{(entity?.metadata?.tags || []).map(t => (
|
||||
<Chip key={t} size="small" label={t} />
|
||||
))}
|
||||
</AboutField>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutField({
|
||||
label,
|
||||
value,
|
||||
gridSizes,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
gridSizes?: Record<string, number>;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
// Content is either children or a string prop `value`
|
||||
const content = React.Children.count(children) ? (
|
||||
children
|
||||
) : (
|
||||
<Typography variant="body2" className={classes.value}>
|
||||
{value || `unknown`}
|
||||
</Typography>
|
||||
);
|
||||
return (
|
||||
<Grid item {...gridSizes}>
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{label}
|
||||
</Typography>
|
||||
{content}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { makeStyles, Link } from '@material-ui/core';
|
||||
import LinkIcon from '@material-ui/icons/Link';
|
||||
|
||||
export type IconLinkVerticalProps = {
|
||||
icon?: React.ReactNode;
|
||||
href?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const useIconStyles = makeStyles({
|
||||
link: {
|
||||
display: 'grid',
|
||||
justifyItems: 'center',
|
||||
gridGap: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
label: {
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
});
|
||||
|
||||
export function IconLinkVertical({
|
||||
icon = <LinkIcon />,
|
||||
href = '#',
|
||||
...props
|
||||
}: IconLinkVerticalProps) {
|
||||
const classes = useIconStyles();
|
||||
return (
|
||||
<Link className={classes.link} href={href} {...props}>
|
||||
{icon}
|
||||
<span className={classes.label}>{props.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+1
-13
@@ -14,16 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
|
||||
<InfoCard title="Information">
|
||||
<StructuredMetadataTable metadata={entity.metadata} />
|
||||
</InfoCard>
|
||||
);
|
||||
export { IconLinkVertical } from './IconLinkVertical';
|
||||
+1
-16
@@ -14,19 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { EntityMetadataCard } from './EntityMetadataCard';
|
||||
|
||||
describe('EntityMetadataCard component', () => {
|
||||
it('should display entity name if provided', async () => {
|
||||
const testEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
};
|
||||
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
|
||||
expect(await rendered.findByText('test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
export { AboutCard } from './AboutCard';
|
||||
@@ -75,12 +75,7 @@ const columns: TableColumn<Entity>[] = [
|
||||
<>
|
||||
{entity.metadata.tags &&
|
||||
entity.metadata.tags.map(t => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
color="secondary"
|
||||
style={{ marginBottom: '0px' }}
|
||||
/>
|
||||
<Chip key={t} label={t} style={{ marginBottom: '0px' }} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Content } from '@backstage/core';
|
||||
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
|
||||
import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions';
|
||||
import {
|
||||
JenkinsBuildsWidget,
|
||||
@@ -24,14 +23,14 @@ import {
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { FC } from 'react';
|
||||
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
|
||||
import { AboutCard } from '../AboutCard';
|
||||
|
||||
export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
|
||||
return (
|
||||
<Content>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item sm={4}>
|
||||
<EntityMetadataCard entity={entity} />
|
||||
<AboutCard entity={entity} />
|
||||
</Grid>
|
||||
{entity.metadata?.annotations?.[
|
||||
'backstage.io/jenkins-github-folder'
|
||||
@@ -52,12 +51,6 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
|
||||
<GithubActionsWidget entity={entity} branch="master" />
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item sm={8}>
|
||||
<SentryIssuesWidget
|
||||
sentryProjectId="sample-sentry-project-id"
|
||||
statsFor="24h"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
);
|
||||
|
||||
@@ -14,17 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { errorHandler, resolvePackagePath } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ApolloServer } from 'apollo-server-express';
|
||||
|
||||
const schemaPath = path.resolve(
|
||||
require.resolve('@backstage/plugin-graphql-backend/package.json'),
|
||||
'../schema.gql',
|
||||
const schemaPath = resolvePackagePath(
|
||||
'@backstage/plugin-graphql-backend',
|
||||
'schema.gql',
|
||||
);
|
||||
|
||||
export interface RouterOptions {
|
||||
@@ -39,8 +38,8 @@ export async function createRouter(
|
||||
const server = new ApolloServer({ typeDefs, logger: options.logger });
|
||||
const router = Router();
|
||||
|
||||
const apolloMiddlware = server.getMiddleware({ path: '/' });
|
||||
router.use(apolloMiddlware);
|
||||
const apolloMiddleware = server.getMiddleware({ path: '/' });
|
||||
router.use(apolloMiddleware);
|
||||
|
||||
router.get('/health', (_, response) => {
|
||||
response.send({ status: 'ok' });
|
||||
|
||||
@@ -27,13 +27,15 @@ export interface RouterOptions {
|
||||
|
||||
const makeRouter = (adapter: IdentityApi): express.Router => {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/users/:user/groups', async (req, res) => {
|
||||
const user = req.params.user;
|
||||
const type = req.query.type?.toString() ?? '';
|
||||
|
||||
const response = await adapter.getUserGroups({ user, type });
|
||||
res.send(response);
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface RouterOptions {
|
||||
// given config.
|
||||
function buildMiddleware(
|
||||
pathPrefix: string,
|
||||
logger: Logger,
|
||||
route: string,
|
||||
config: string | ProxyConfig,
|
||||
): Proxy {
|
||||
@@ -54,6 +55,9 @@ function buildMiddleware(
|
||||
fullConfig.changeOrigin = true;
|
||||
}
|
||||
|
||||
// Attach the logger to the proxy config
|
||||
fullConfig.logProvider = () => logger;
|
||||
|
||||
return createProxyMiddleware(fullConfig);
|
||||
}
|
||||
|
||||
@@ -66,7 +70,12 @@ export async function createRouter(
|
||||
Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => {
|
||||
router.use(
|
||||
route,
|
||||
buildMiddleware(options.pathPrefix, route, proxyRouteConfig),
|
||||
buildMiddleware(
|
||||
options.pathPrefix,
|
||||
options.logger,
|
||||
route,
|
||||
proxyRouteConfig,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
const logger = options.logger.child({ plugin: 'rollbar' });
|
||||
const config = options.config.getConfig('rollbar');
|
||||
const accessToken = !options.rollbarApi
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
const {
|
||||
preparers,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ScaffolderPage: React.FC<{}> = () => {
|
||||
}, [error, errorApi]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.other}>
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
title={
|
||||
@@ -95,7 +95,7 @@ export const ScaffolderPage: React.FC<{}> = () => {
|
||||
<Typography variant="body2">
|
||||
Shoot! Looks like you don't have any templates. Check out the
|
||||
documentation{' '}
|
||||
<Link href="docs/backstage/features/software-templates/adding-templates">
|
||||
<Link href="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
here!
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
@@ -136,7 +136,7 @@ export const TemplatePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.other}>
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
title={
|
||||
|
||||
@@ -20,6 +20,8 @@ import { getSentryApiForwarder } from './sentry-api';
|
||||
|
||||
export async function createRouter(logger: Logger): Promise<express.Router> {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
const SENTRY_TOKEN = process.env.SENTRY_TOKEN;
|
||||
if (!SENTRY_TOKEN) {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
|
||||
@@ -19,7 +19,6 @@ import express from 'express';
|
||||
import Knex from 'knex';
|
||||
import fetch from 'node-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import path from 'path';
|
||||
import Docker from 'dockerode';
|
||||
import {
|
||||
GeneratorBuilder,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
PublisherBase,
|
||||
LocalPublish,
|
||||
} from '../techdocs';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
type RouterOptions = {
|
||||
@@ -39,6 +39,11 @@ type RouterOptions = {
|
||||
dockerClient: Docker;
|
||||
};
|
||||
|
||||
const staticDocsDir = resolvePackagePath(
|
||||
'@backstage/plugin-techdocs-backend',
|
||||
'static/docs',
|
||||
);
|
||||
|
||||
export async function createRouter({
|
||||
preparers,
|
||||
generators,
|
||||
@@ -102,10 +107,7 @@ export async function createRouter({
|
||||
});
|
||||
|
||||
if (publisher instanceof LocalPublish) {
|
||||
router.use(
|
||||
'/static/docs/',
|
||||
express.static(path.resolve(__dirname, `../../static/docs`)),
|
||||
);
|
||||
router.use('/static/docs/', express.static(staticDocsDir));
|
||||
router.use(
|
||||
'/static/docs/:kind/:namespace/:name',
|
||||
async (req, res, next) => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Generators, TechdocsGenerator } from './';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const mockEntity = {
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
},
|
||||
};
|
||||
|
||||
describe('generators', () => {
|
||||
it('should return error if no generator is registered', async () => {
|
||||
const generators = new Generators();
|
||||
|
||||
expect(() => generators.get(mockEntity)).toThrowError(
|
||||
'No generator registered for entity: "techdocs"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct registered generator', async () => {
|
||||
const generators = new Generators();
|
||||
const techdocs = new TechdocsGenerator(logger);
|
||||
|
||||
generators.register('techdocs', techdocs);
|
||||
|
||||
expect(generators.get(mockEntity)).toBe(techdocs);
|
||||
});
|
||||
});
|
||||
@@ -15,32 +15,29 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
GeneratorBase,
|
||||
SupportedGeneratorKey,
|
||||
GeneratorBuilder,
|
||||
} from './types';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { getGeneratorKey } from './helpers';
|
||||
|
||||
export class Generators implements GeneratorBuilder {
|
||||
private generatorMap = new Map<SupportedGeneratorKey, GeneratorBase>();
|
||||
|
||||
register(templaterKey: SupportedGeneratorKey, templater: GeneratorBase) {
|
||||
this.generatorMap.set(templaterKey, templater);
|
||||
}
|
||||
|
||||
get(entity: Entity): GeneratorBase {
|
||||
const generatorKey = getGeneratorKey(entity);
|
||||
const generator = this.generatorMap.get(generatorKey);
|
||||
|
||||
if (!generator) {
|
||||
throw new Error(
|
||||
`No generator registered for entity: "${generatorKey}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return generator;
|
||||
}
|
||||
GeneratorBase,
|
||||
SupportedGeneratorKey,
|
||||
GeneratorBuilder,
|
||||
} from './types';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { getGeneratorKey } from './helpers';
|
||||
|
||||
export class Generators implements GeneratorBuilder {
|
||||
private generatorMap = new Map<SupportedGeneratorKey, GeneratorBase>();
|
||||
|
||||
register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase) {
|
||||
this.generatorMap.set(generatorKey, generator);
|
||||
}
|
||||
|
||||
|
||||
get(entity: Entity): GeneratorBase {
|
||||
const generatorKey = getGeneratorKey(entity);
|
||||
const generator = this.generatorMap.get(generatorKey);
|
||||
|
||||
if (!generator) {
|
||||
throw new Error(`No generator registered for entity: "${generatorKey}"`);
|
||||
}
|
||||
|
||||
return generator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Stream, { PassThrough } from 'stream';
|
||||
import os from 'os';
|
||||
import Docker from 'dockerode';
|
||||
import { runDockerContainer, getGeneratorKey } from './helpers';
|
||||
|
||||
const mockEntity = {
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
},
|
||||
};
|
||||
|
||||
const mockDocker = new Docker() as jest.Mocked<Docker>;
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('getGeneratorKey', () => {
|
||||
it('should return techdocs as the only generator key', () => {
|
||||
const key = getGeneratorKey(mockEntity);
|
||||
expect(key).toBe('techdocs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDockerContainer', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
|
||||
_image: string,
|
||||
_something: any,
|
||||
handler: (err: Error | undefined, stream: PassThrough) => void,
|
||||
) => {
|
||||
const mockStream = new PassThrough();
|
||||
handler(undefined, mockStream);
|
||||
mockStream.end();
|
||||
}) as any);
|
||||
|
||||
jest
|
||||
.spyOn(mockDocker, 'run')
|
||||
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
|
||||
});
|
||||
|
||||
const imageName = 'spotify/techdocs';
|
||||
const args = ['build', '-d', '/result'];
|
||||
const docsDir = os.tmpdir();
|
||||
const resultDir = os.tmpdir();
|
||||
|
||||
it('should pull the techdocs docker container', async () => {
|
||||
await runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
docsDir,
|
||||
resultDir,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
|
||||
expect(mockDocker.pull).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should run the techdocs docker container', async () => {
|
||||
await runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
docsDir,
|
||||
resultDir,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
args,
|
||||
expect.any(Stream),
|
||||
{
|
||||
Volumes: {
|
||||
'/content': {},
|
||||
'/result': {},
|
||||
},
|
||||
WorkingDir: '/content',
|
||||
HostConfig: {
|
||||
Binds: [`${docsDir}:/content`, `${resultDir}:/result`],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,9 +79,10 @@ export const checkoutGitRepository = async (
|
||||
|
||||
if (fs.existsSync(repositoryTmpPath)) {
|
||||
const repository = await Repository.open(repositoryTmpPath);
|
||||
const currentBranchName = (await repository.getCurrentBranch()).shorthand();
|
||||
await repository.mergeBranches(
|
||||
parsedGitLocation.ref,
|
||||
`origin/${parsedGitLocation.ref}`,
|
||||
currentBranchName,
|
||||
`origin/${currentBranchName}`,
|
||||
);
|
||||
return repositoryTmpPath;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { PublisherBase } from './types';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
|
||||
export class LocalPublish implements PublisherBase {
|
||||
private readonly logger: Logger;
|
||||
@@ -39,9 +39,9 @@ export class LocalPublish implements PublisherBase {
|
||||
| { remoteUrl: string } {
|
||||
const entityNamespace = entity.metadata.namespace ?? 'default';
|
||||
|
||||
const publishDir = path.resolve(
|
||||
__dirname,
|
||||
'../../../../static/docs/',
|
||||
const publishDir = resolvePackagePath(
|
||||
'@backstage/plugin-techdocs-backend',
|
||||
'static/docs',
|
||||
entity.kind,
|
||||
entityNamespace,
|
||||
entity.metadata.name,
|
||||
|
||||
Reference in New Issue
Block a user