Merge branch 'master' into cloudbuild-plugin
This commit is contained in:
@@ -49,7 +49,9 @@
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9",
|
||||
"@types/swagger-ui-react": "^3.23.3",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.23",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.19.5",
|
||||
"msw": "^0.20.5",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -34,8 +34,8 @@ Follow this link, [Create new OAuth App](https://github.com/settings/application
|
||||
1. Set Application Name to `backstage-dev` or something along those lines.
|
||||
1. You can set the Homepage URL to whatever you want to.
|
||||
1. The Authorization Callback URL should match the redirect URI set in Backstage.
|
||||
1. Set this to `http://localhost:7000/auth/github` for local development.
|
||||
1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/auth/github` for non-local deployments.
|
||||
1. Set this to `http://localhost:7000/api/auth/github` for local development.
|
||||
1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/github` for non-local deployments.
|
||||
|
||||
```bash
|
||||
export AUTH_GITHUB_CLIENT_ID=x
|
||||
@@ -78,14 +78,14 @@ export AUTH_AUTH0_CLIENT_SECRET=x
|
||||
|
||||
#### Creating an Azure AD App Registration
|
||||
|
||||
An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API.
|
||||
An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API.
|
||||
Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one.
|
||||
|
||||
- Click on the `New Registration` button.
|
||||
- Give the app a name. e.g. `backstage-dev`
|
||||
- Select `Accounts in this organizational directory only` under supported account types.
|
||||
- Enter the callback URL for your backstage backend instance:
|
||||
- For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame`
|
||||
- For local development, this is likely `http://localhost:7000/api/auth/microsoft/handler/frame`
|
||||
- For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame`
|
||||
- Click `Register`.
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -18,4 +18,4 @@ fi
|
||||
|
||||
echo "Downloading and starting SAML-IdP"
|
||||
export NPM_CONFIG_REGISTRY=https://registry.npmjs.org
|
||||
exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001
|
||||
exec npx saml-idp --acsUrl "http://localhost:7000/api/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001
|
||||
|
||||
@@ -54,7 +54,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
// TODO: This flow doesn't issue an identity token that can be used to validate
|
||||
// the identity of the user in other backends, which we need in some form.
|
||||
done(undefined, {
|
||||
userId: profile.ID!,
|
||||
userId: profile.nameID!,
|
||||
profile: {
|
||||
email: profile.email!,
|
||||
displayName: profile.displayName as string,
|
||||
|
||||
@@ -22,12 +22,16 @@ import { Logger } from 'winston';
|
||||
import { createAuthProviderRouter } from '../providers';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
NotFoundError,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
database: Knex;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
@@ -37,8 +41,7 @@ export async function createRouter(
|
||||
const logger = options.logger.child({ plugin: 'auth' });
|
||||
|
||||
const appUrl = options.config.getString('app.baseUrl');
|
||||
const backendUrl = options.config.getString('backend.baseUrl');
|
||||
const authUrl = `${backendUrl}/auth`;
|
||||
const authUrl = await options.discovery.getExternalBaseUrl('auth');
|
||||
|
||||
const keyDurationSeconds = 3600;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
createServiceBuilder,
|
||||
useHotMemoize,
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
export interface ServerOptions {
|
||||
@@ -34,6 +35,7 @@ export async function startStandaloneServer(
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'auth-backend' });
|
||||
const config = ConfigReader.fromConfigs(await loadBackendConfig());
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
const knex = Knex({
|
||||
@@ -52,6 +54,7 @@ export async function startStandaloneServer(
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex')} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex('entities')
|
||||
.where({ namespace: null })
|
||||
.update({ namespace: 'default' });
|
||||
await knex('entities_search').update({
|
||||
key: knex.raw('LOWER(key)'),
|
||||
value: knex.raw('LOWER(value)'),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down() {};
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
|
||||
import { EntitiesCatalog } from './types';
|
||||
@@ -115,9 +115,13 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c1.entityByName.mockResolvedValueOnce(undefined);
|
||||
c2.entityByName.mockResolvedValueOnce(e2);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e2,
|
||||
);
|
||||
await expect(
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBe(e2);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
});
|
||||
@@ -127,7 +131,11 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c2.entityByName.mockResolvedValueOnce(undefined);
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(
|
||||
catalog.entityByName('k', undefined, 'n2'),
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
@@ -137,9 +145,13 @@ describe('CoalescedEntitiesCatalog', () => {
|
||||
c1.entityByName.mockResolvedValueOnce(e1);
|
||||
c2.entityByName.mockRejectedValueOnce(new Error('boo'));
|
||||
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
|
||||
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
|
||||
e1,
|
||||
);
|
||||
await expect(
|
||||
catalog.entityByName({
|
||||
kind: 'k',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'n2',
|
||||
}),
|
||||
).resolves.toBe(e1);
|
||||
expect(c1.entityByName).toBeCalledTimes(1);
|
||||
expect(c2.entityByName).toBeCalledTimes(1);
|
||||
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../database';
|
||||
import { EntitiesCatalog } from './types';
|
||||
@@ -72,14 +72,10 @@ export class CoalescedEntitiesCatalog implements EntitiesCatalog {
|
||||
return results.find(Boolean);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined> {
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const ops = this.inner.map(async catalog => {
|
||||
try {
|
||||
return await catalog.entityByName(kind, namespace, name);
|
||||
return await catalog.entityByName(name);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Inner entityByName call failed, ${e}`);
|
||||
return undefined;
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
addEntity: jest.fn(),
|
||||
updateEntity: jest.fn(),
|
||||
entities: jest.fn(),
|
||||
entity: jest.fn(),
|
||||
entityByName: jest.fn(),
|
||||
entityByUid: jest.fn(),
|
||||
removeEntity: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
@@ -61,12 +61,12 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
const catalog = new DatabaseEntitiesCatalog(db);
|
||||
const result = await catalog.addOrUpdateEntity(entity);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'name', values: ['c'] },
|
||||
{ key: 'namespace', values: ['d'] },
|
||||
]);
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
});
|
||||
expect(db.addEntity).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(entity);
|
||||
});
|
||||
@@ -146,18 +146,18 @@ describe('DatabaseEntitiesCatalog', () => {
|
||||
},
|
||||
};
|
||||
|
||||
db.entities.mockResolvedValue([{ entity: existing }]);
|
||||
db.entityByName.mockResolvedValue({ entity: existing });
|
||||
db.updateEntity.mockResolvedValue({ entity: existing });
|
||||
|
||||
const catalog = new DatabaseEntitiesCatalog(db);
|
||||
const result = await catalog.addOrUpdateEntity(added);
|
||||
|
||||
expect(db.entities).toHaveBeenCalledTimes(1);
|
||||
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'name', values: ['c'] },
|
||||
{ key: 'namespace', values: ['d'] },
|
||||
]);
|
||||
expect(db.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
});
|
||||
expect(db.updateEntity).toHaveBeenCalledTimes(1);
|
||||
expect(db.updateEntity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
EntityName,
|
||||
generateUpdatedEntity,
|
||||
getEntityName,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
@@ -34,20 +36,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
|
||||
async entityByUid(uid: string): Promise<Entity | undefined> {
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
|
||||
const response = await this.database.transaction(tx =>
|
||||
this.database.entityByUid(tx, uid),
|
||||
);
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
return response?.entity;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined> {
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const response = await this.database.transaction(tx =>
|
||||
this.entityByNameInternal(tx, kind, name, namespace),
|
||||
this.database.entityByName(tx, name),
|
||||
);
|
||||
return response?.entity;
|
||||
}
|
||||
@@ -61,12 +58,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
// entity) existing entity, to know whether to update or add
|
||||
const existing = entity.metadata.uid
|
||||
? await this.database.entityByUid(tx, entity.metadata.uid)
|
||||
: await this.entityByNameInternal(
|
||||
tx,
|
||||
entity.kind,
|
||||
entity.metadata.name,
|
||||
entity.metadata.namespace,
|
||||
);
|
||||
: await this.database.entityByName(tx, getEntityName(entity));
|
||||
|
||||
// If it's an update, run the algorithm for annotation merging, updating
|
||||
// etag/generation, etc.
|
||||
@@ -113,25 +105,4 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async entityByNameInternal(
|
||||
tx: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const matches = await this.database.entities(tx, [
|
||||
{ key: 'kind', values: [kind] },
|
||||
{ key: 'name', values: [name] },
|
||||
{
|
||||
key: 'namespace',
|
||||
values:
|
||||
!namespace || namespace === 'default'
|
||||
? [null, 'default']
|
||||
: [namespace],
|
||||
},
|
||||
]);
|
||||
|
||||
return matches.length ? matches[0] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName, getEntityName } from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import type { EntitiesCatalog } from './types';
|
||||
|
||||
@@ -34,17 +34,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
return item ? lodash.cloneDeep(item) : undefined;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<Entity | undefined> {
|
||||
const item = this._entities.find(
|
||||
e =>
|
||||
kind === e.kind &&
|
||||
name === e.metadata.name &&
|
||||
namespace === e.metadata.namespace,
|
||||
);
|
||||
async entityByName(name: EntityName): Promise<Entity | undefined> {
|
||||
const item = this._entities.find(e => {
|
||||
const candidate = getEntityName(e);
|
||||
return (
|
||||
name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
|
||||
name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
|
||||
name.name.toLowerCase() === candidate.name.toLowerCase()
|
||||
);
|
||||
});
|
||||
return item ? lodash.cloneDeep(item) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import { Entity, EntityName, Location } from '@backstage/catalog-model';
|
||||
import type { EntityFilters } from '../database';
|
||||
|
||||
//
|
||||
@@ -24,11 +24,7 @@ import type { EntityFilters } from '../database';
|
||||
export type EntitiesCatalog = {
|
||||
entities(filters?: EntityFilters): Promise<Entity[]>;
|
||||
entityByUid(uid: string): Promise<Entity | undefined>;
|
||||
entityByName(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<Entity | undefined>;
|
||||
entityByName(name: EntityName): Promise<Entity | undefined>;
|
||||
addOrUpdateEntity(entity: Entity, locationId?: string): Promise<Entity>;
|
||||
removeEntityByUid(uid: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -395,5 +395,90 @@ describe('CommonDatabase', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters case insensitively)', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'A', kind: 'K1', metadata: { name: 'N' } },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k2',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: 'Some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k3',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await db.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await db.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await db.transaction(async tx =>
|
||||
db.entities(tx, [
|
||||
{ key: 'ApiVersioN', values: ['A'] },
|
||||
{ key: 'spEc.C', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.length).toEqual(3);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'K1' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k2' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k3' }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('can get entities case insensitively', async () => {
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k1',
|
||||
metadata: { name: 'n' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'B',
|
||||
kind: 'K2',
|
||||
metadata: { name: 'N', namespace: 'NS' },
|
||||
},
|
||||
];
|
||||
|
||||
await db.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await db.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const e1 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'k1', namespace: 'default', name: 'n' }),
|
||||
);
|
||||
expect(e1!.entity.metadata.name).toEqual('n');
|
||||
const e2 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'k2', namespace: 'nS', name: 'n' }),
|
||||
);
|
||||
expect(e2!.entity.metadata.name).toEqual('N');
|
||||
const e3 = await db.transaction(async tx =>
|
||||
db.entityByName(tx, { kind: 'unknown', namespace: 'nS', name: 'n' }),
|
||||
);
|
||||
expect(e3).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
import {
|
||||
Entity,
|
||||
EntityMeta,
|
||||
entityMetaGeneratedFields,
|
||||
EntityName,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
ENTITY_META_GENERATED_FIELDS,
|
||||
generateEntityEtag,
|
||||
generateEntityUid,
|
||||
getEntityName,
|
||||
Location,
|
||||
} from '@backstage/catalog-model';
|
||||
import Knex from 'knex';
|
||||
@@ -172,7 +175,7 @@ export class CommonDatabase implements Database {
|
||||
let builder = tx<DbEntitiesRow>('entities');
|
||||
for (const [indexU, filter] of (filters ?? []).entries()) {
|
||||
const index = Number(indexU);
|
||||
const key = filter.key.replace('*', '%');
|
||||
const key = filter.key.toLowerCase().replace('*', '%');
|
||||
const keyOp = filter.key.includes('*') ? 'like' : '=';
|
||||
|
||||
let matchNulls = false;
|
||||
@@ -183,9 +186,9 @@ export class CommonDatabase implements Database {
|
||||
if (!value) {
|
||||
matchNulls = true;
|
||||
} else if (value.includes('*')) {
|
||||
matchLike.push(value.replace('*', '%'));
|
||||
matchLike.push(value.toLowerCase().replace('*', '%'));
|
||||
} else {
|
||||
matchIn.push(value);
|
||||
matchIn.push(value.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,16 +222,19 @@ export class CommonDatabase implements Database {
|
||||
return rows.map(row => this.toEntityResponse(row));
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByName(
|
||||
txOpaque: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace?: string,
|
||||
name: EntityName,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const tx = txOpaque as Knex.Transaction<any, any>;
|
||||
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
.where({ kind, name, namespace: namespace || null })
|
||||
.whereRaw(
|
||||
tx.raw(
|
||||
'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)',
|
||||
[name.kind, name.namespace, name.name],
|
||||
),
|
||||
)
|
||||
.select();
|
||||
|
||||
if (rows.length !== 1) {
|
||||
@@ -240,11 +246,13 @@ export class CommonDatabase implements Database {
|
||||
|
||||
async entityByUid(
|
||||
txOpaque: unknown,
|
||||
id: string,
|
||||
uid: string,
|
||||
): Promise<DbEntityResponse | undefined> {
|
||||
const tx = txOpaque as Knex.Transaction<any, any>;
|
||||
|
||||
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
.where({ id: uid })
|
||||
.select();
|
||||
|
||||
if (rows.length !== 1) {
|
||||
return undefined;
|
||||
@@ -381,36 +389,40 @@ export class CommonDatabase implements Database {
|
||||
tx: Knex.Transaction<any, any>,
|
||||
data: Entity,
|
||||
): Promise<void> {
|
||||
const newKind = data.kind;
|
||||
const newName = data.metadata.name;
|
||||
const newNamespace = data.metadata.namespace;
|
||||
const {
|
||||
kind: newKind,
|
||||
namespace: newNamespace,
|
||||
name: newName,
|
||||
} = getEntityName(data);
|
||||
const newKindNorm = this.normalize(newKind);
|
||||
const newNamespaceNorm = this.normalize(newNamespace);
|
||||
const newNameNorm = this.normalize(newName);
|
||||
const newNamespaceNorm = this.normalize(newNamespace || '');
|
||||
|
||||
for (const item of await this.entities(tx)) {
|
||||
if (data.metadata.uid === item.entity.metadata.uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldKind = item.entity.kind;
|
||||
const oldName = item.entity.metadata.name;
|
||||
const oldNamespace = item.entity.metadata.namespace;
|
||||
const {
|
||||
kind: oldKind,
|
||||
namespace: oldNamespace,
|
||||
name: oldName,
|
||||
} = getEntityName(item.entity);
|
||||
const oldKindNorm = this.normalize(oldKind);
|
||||
const oldNamespaceNorm = this.normalize(oldNamespace);
|
||||
const oldNameNorm = this.normalize(oldName);
|
||||
const oldNamespaceNorm = this.normalize(oldNamespace || '');
|
||||
|
||||
if (
|
||||
oldKindNorm === newKindNorm &&
|
||||
oldNameNorm === newNameNorm &&
|
||||
oldNamespaceNorm === newNamespaceNorm
|
||||
oldNamespaceNorm === newNamespaceNorm &&
|
||||
oldNameNorm === newNameNorm
|
||||
) {
|
||||
// Only throw if things were actually different - for completely equal
|
||||
// things, we let the database handle the conflict
|
||||
if (
|
||||
oldKind !== newKind ||
|
||||
oldName !== newName ||
|
||||
oldNamespace !== newNamespace
|
||||
oldNamespace !== newNamespace ||
|
||||
oldName !== newName
|
||||
) {
|
||||
const message = `Kind, namespace, name are too similar to an existing entity`;
|
||||
throw new ConflictError(message);
|
||||
@@ -431,9 +443,9 @@ export class CommonDatabase implements Database {
|
||||
api_version: entity.apiVersion,
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
namespace: entity.metadata.namespace || null,
|
||||
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
|
||||
metadata: JSON.stringify(
|
||||
lodash.omit(entity.metadata, ...entityMetaGeneratedFields),
|
||||
lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS),
|
||||
),
|
||||
spec: entity.spec ? JSON.stringify(entity.spec) : null,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { ENTITY_DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';
|
||||
import { buildEntitySearch, visitEntityPart } from './search';
|
||||
import type { DbEntitiesSearchRow } from './types';
|
||||
|
||||
@@ -95,6 +95,16 @@ describe('search', () => {
|
||||
{ entity_id: 'eid', key: 'root.list.a', value: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits lowercase version of keys and values', () => {
|
||||
const input = { theRoot: { listItems: [{ a: 'One' }, { a: 2 }] } };
|
||||
const output: DbEntitiesSearchRow[] = [];
|
||||
visitEntityPart('eid', '', input, output);
|
||||
expect(output).toEqual([
|
||||
{ entity_id: 'eid', key: 'theroot.listitems.a', value: 'one' },
|
||||
{ entity_id: 'eid', key: 'theroot.listitems.a', value: '2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEntitySearch', () => {
|
||||
@@ -108,11 +118,17 @@ describe('search', () => {
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.uid', value: null },
|
||||
{ entity_id: 'eid', key: 'apiVersion', value: 'a' },
|
||||
{
|
||||
entity_id: 'eid',
|
||||
key: 'metadata.namespace',
|
||||
value: ENTITY_DEFAULT_NAMESPACE,
|
||||
},
|
||||
{ entity_id: 'eid', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'eid', key: 'kind', value: 'b' },
|
||||
{ entity_id: 'eid', key: 'name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'uid', value: null },
|
||||
{ entity_id: 'eid', key: 'namespace', value: ENTITY_DEFAULT_NAMESPACE },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import type { DbEntitiesSearchRow } from './types';
|
||||
|
||||
// Search entries that start with these prefixes, also get a shorthand without
|
||||
@@ -26,7 +26,7 @@ const SHORTHAND_KEY_PREFIXES = [
|
||||
'spec.',
|
||||
];
|
||||
|
||||
// These are exluded in the generic loop, either because they do not make sense
|
||||
// These are excluded in the generic loop, either because they do not make sense
|
||||
// to index, or because they are special-case always inserted whether they are
|
||||
// null or not
|
||||
const SPECIAL_KEYS = [
|
||||
@@ -42,7 +42,7 @@ function toValue(current: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(current);
|
||||
return String(current).toLowerCase();
|
||||
}
|
||||
|
||||
// Helper for iterating through a nested structure and outputting a list of
|
||||
@@ -106,7 +106,12 @@ export function visitEntityPart(
|
||||
|
||||
// object
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
visitEntityPart(entityId, path ? `${path}.${key}` : key, value, output);
|
||||
visitEntityPart(
|
||||
entityId,
|
||||
(path ? `${path}.${key}` : key).toLowerCase(),
|
||||
value,
|
||||
output,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +146,16 @@ export function buildEntitySearch(
|
||||
},
|
||||
];
|
||||
|
||||
// Namespace not specified has the default value "default", so we want to
|
||||
// match on that as well
|
||||
if (!entity.metadata.namespace) {
|
||||
result.push({
|
||||
entity_id: entityId,
|
||||
key: 'metadata.namespace',
|
||||
value: toValue(ENTITY_DEFAULT_NAMESPACE),
|
||||
});
|
||||
}
|
||||
|
||||
// Visit the entire structure recursively
|
||||
visitEntityPart(entityId, '', entity, result);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Entity, Location } from '@backstage/catalog-model';
|
||||
import type { Entity, EntityName, Location } from '@backstage/catalog-model';
|
||||
|
||||
export type DbEntitiesRow = {
|
||||
id: string;
|
||||
@@ -129,11 +129,9 @@ export type Database = {
|
||||
|
||||
entities(tx: unknown, filters?: EntityFilters): Promise<DbEntityResponse[]>;
|
||||
|
||||
entity(
|
||||
entityByName(
|
||||
tx: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace?: string,
|
||||
name: EntityName,
|
||||
): Promise<DbEntityResponse | undefined>;
|
||||
|
||||
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { LocationUpdateStatus } from '../catalog/types';
|
||||
import { DatabaseLocationUpdateLogStatus } from '../database/types';
|
||||
@@ -200,12 +205,11 @@ describe('HigherOrderOperations', () => {
|
||||
target: 'thing',
|
||||
});
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Component',
|
||||
undefined,
|
||||
'c1',
|
||||
);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, {
|
||||
kind: 'Component',
|
||||
namespace: ENTITY_DEFAULT_NAMESPACE,
|
||||
name: 'c1',
|
||||
});
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { InputError } from '@backstage/backend-common';
|
||||
import {
|
||||
Entity,
|
||||
entityHasChanges,
|
||||
getEntityName,
|
||||
Location,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -162,16 +163,14 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
const { entity } = item;
|
||||
|
||||
this.logger.debug(
|
||||
`Read entity kind="${entity.kind}" name="${
|
||||
entity.metadata.name
|
||||
}" namespace="${entity.metadata.namespace || ''}"`,
|
||||
`Read entity kind="${entity.kind}" namespace="${
|
||||
entity.metadata.namespace || ''
|
||||
}" name="${entity.metadata.name}"`,
|
||||
);
|
||||
|
||||
try {
|
||||
const previous = await this.entitiesCatalog.entityByName(
|
||||
entity.kind,
|
||||
entity.metadata.namespace,
|
||||
entity.metadata.name,
|
||||
getEntityName(entity),
|
||||
);
|
||||
|
||||
if (!previous) {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
import { YamlProcessor } from './processors/YamlProcessor';
|
||||
import { LocationReader, ReadLocationResult } from './types';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor';
|
||||
|
||||
// The max amount of nesting depth of generated work items
|
||||
const MAX_DEPTH = 10;
|
||||
@@ -85,6 +86,7 @@ export class LocationReaders implements LocationReader {
|
||||
new AzureApiReaderProcessor(config),
|
||||
new UrlReaderProcessor(),
|
||||
new YamlProcessor(),
|
||||
new ApiDefinitionAtLocationProcessor(),
|
||||
new EntityPolicyProcessor(entityPolicy),
|
||||
new LocationRefProcessor(),
|
||||
new AnnotateLocationEntityProcessor(),
|
||||
@@ -218,7 +220,12 @@ export class LocationReaders implements LocationReader {
|
||||
for (const processor of this.processors) {
|
||||
if (processor.processEntity) {
|
||||
try {
|
||||
current = await processor.processEntity(current, item.location, emit);
|
||||
current = await processor.processEntity(
|
||||
current,
|
||||
item.location,
|
||||
emit,
|
||||
this.readLocation.bind(this),
|
||||
);
|
||||
} catch (e) {
|
||||
const message = `Processor ${processor.constructor.name} threw an error while processing entity at ${item.location.type} ${item.location.target}, ${e}`;
|
||||
emit(result.generalError(item.location, message));
|
||||
@@ -248,4 +255,25 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocation(
|
||||
location: LocationSpec,
|
||||
): Promise<LocationProcessorResult> {
|
||||
let locationResult: LocationProcessorResult | undefined;
|
||||
|
||||
await this.handleLocation(
|
||||
{
|
||||
type: 'location',
|
||||
location,
|
||||
optional: false,
|
||||
},
|
||||
r => (locationResult = r),
|
||||
);
|
||||
|
||||
if (!locationResult) {
|
||||
throw new Error('No location loaded');
|
||||
}
|
||||
|
||||
return locationResult;
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
/*
|
||||
* 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 { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor';
|
||||
import { LocationProcessorResult } from './types';
|
||||
|
||||
describe('ApiDefinitionAtLocationProcessor', () => {
|
||||
let processor: ApiDefinitionAtLocationProcessor;
|
||||
let entity: Entity;
|
||||
let location: LocationSpec;
|
||||
|
||||
beforeEach(() => {
|
||||
processor = new ApiDefinitionAtLocationProcessor();
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
spec: {
|
||||
lifecycle: 'production',
|
||||
owner: 'info@example.com',
|
||||
type: 'openapi',
|
||||
definition: 'Hello',
|
||||
},
|
||||
};
|
||||
location = {
|
||||
type: 'url',
|
||||
target: `http://example.com/api.yaml`,
|
||||
};
|
||||
});
|
||||
|
||||
it('should skip entities without annotation', async () => {
|
||||
const read = jest.fn(
|
||||
(): Promise<LocationProcessorResult> => {
|
||||
throw new Error();
|
||||
},
|
||||
);
|
||||
|
||||
const generated = (await processor.processEntity(
|
||||
entity,
|
||||
location,
|
||||
() => {},
|
||||
read,
|
||||
)) as ApiEntity;
|
||||
|
||||
expect(generated.spec.definition).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should load from location', async () => {
|
||||
entity.metadata.annotations = {
|
||||
'backstage.io/definition-at-location':
|
||||
'url:http://example.com/openapi.yaml',
|
||||
};
|
||||
|
||||
const read = jest.fn(
|
||||
(l: LocationSpec): Promise<LocationProcessorResult> =>
|
||||
Promise.resolve({
|
||||
type: 'data',
|
||||
data: Buffer.from('Hello'),
|
||||
location: l,
|
||||
}),
|
||||
);
|
||||
|
||||
const generated = (await processor.processEntity(
|
||||
entity,
|
||||
location,
|
||||
() => {},
|
||||
read,
|
||||
)) as ApiEntity;
|
||||
|
||||
expect(generated.spec.definition).toBe('Hello');
|
||||
expect(read.mock.calls[0][0]).toStrictEqual({
|
||||
type: 'url',
|
||||
target: 'http://example.com/openapi.yaml',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw errors while loading', async () => {
|
||||
entity.metadata.annotations = {
|
||||
'backstage.io/definition-at-location': 'missing',
|
||||
};
|
||||
|
||||
const read = jest.fn(
|
||||
(l: LocationSpec): Promise<LocationProcessorResult> =>
|
||||
Promise.resolve({
|
||||
type: 'error',
|
||||
error: new Error('Failed to load location'),
|
||||
location: l,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
processor.processEntity(entity, location, () => {}, read),
|
||||
).rejects.toThrow('Failed to read location: Failed to load location');
|
||||
});
|
||||
|
||||
it('should throw errors if location read has wrong type', async () => {
|
||||
entity.metadata.annotations = {
|
||||
'backstage.io/definition-at-location': 'wrong',
|
||||
};
|
||||
|
||||
const read = jest.fn(
|
||||
(l: LocationSpec): Promise<LocationProcessorResult> =>
|
||||
Promise.resolve({
|
||||
type: 'location',
|
||||
optional: false,
|
||||
location: l,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
processor.processEntity(entity, location, () => {}, read),
|
||||
).rejects.toThrow(
|
||||
`Only supports location processor results of type 'data', but got 'location'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
LocationProcessor,
|
||||
LocationProcessorEmit,
|
||||
LocationProcessorRead,
|
||||
} from './types';
|
||||
|
||||
const DEFINITION_AT_LOCATION_ANNOTATION = 'backstage.io/definition-at-location';
|
||||
|
||||
export class ApiDefinitionAtLocationProcessor implements LocationProcessor {
|
||||
async processEntity(
|
||||
entity: Entity,
|
||||
_location: LocationSpec,
|
||||
_emit: LocationProcessorEmit,
|
||||
read: LocationProcessorRead,
|
||||
): Promise<Entity> {
|
||||
if (
|
||||
entity.kind !== 'API' ||
|
||||
!entity.metadata.annotations ||
|
||||
!entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION]
|
||||
) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const reference =
|
||||
entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION];
|
||||
const { type, target } = extractReference(reference);
|
||||
const result = await read({ type, target });
|
||||
|
||||
if (result.type === 'error') {
|
||||
throw new Error(`Failed to read location: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.type !== 'data') {
|
||||
throw new Error(
|
||||
`Only supports location processor results of type 'data', but got '${result.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const definition = result.data.toString();
|
||||
const apiEntity = entity as ApiEntity;
|
||||
apiEntity.spec.definition = definition;
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
function extractReference(reference: string): { type: string; target: string } {
|
||||
const delimiterIndex = reference.indexOf(':');
|
||||
const type = reference.slice(0, delimiterIndex);
|
||||
const target = reference.slice(delimiterIndex + 1);
|
||||
|
||||
return { type, target };
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export type LocationProcessor = {
|
||||
*
|
||||
* @param entity The entity to process
|
||||
* @param location The location that the entity came from
|
||||
* @param read Reads the contents of a location
|
||||
* @param emit A sink for auxiliary items resulting from the processing
|
||||
* @returns The same entity or a modifid version of it
|
||||
*/
|
||||
@@ -57,6 +58,7 @@ export type LocationProcessor = {
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
read: LocationProcessorRead,
|
||||
): Promise<Entity>;
|
||||
|
||||
/**
|
||||
@@ -107,3 +109,7 @@ export type LocationProcessorResult =
|
||||
| LocationProcessorDataResult
|
||||
| LocationProcessorEntityResult
|
||||
| LocationProcessorErrorResult;
|
||||
|
||||
export type LocationProcessorRead = (
|
||||
location: LocationSpec,
|
||||
) => Promise<LocationProcessorResult>;
|
||||
|
||||
@@ -136,7 +136,11 @@ describe('createRouter', () => {
|
||||
const response = await request(app).get('/entities/by-name/k/ns/n');
|
||||
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n');
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({
|
||||
kind: 'k',
|
||||
namespace: 'ns',
|
||||
name: 'n',
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
@@ -147,7 +151,11 @@ describe('createRouter', () => {
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c');
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({
|
||||
kind: 'b',
|
||||
namespace: 'd',
|
||||
name: 'c',
|
||||
});
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/name/);
|
||||
});
|
||||
|
||||
@@ -67,11 +67,11 @@ export async function createRouter(
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const entity = await entitiesCatalog.entityByName(
|
||||
const entity = await entitiesCatalog.entityByName({
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
);
|
||||
});
|
||||
if (!entity) {
|
||||
res
|
||||
.status(404)
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"eslint-plugin-graphql": "^4.0.0",
|
||||
"msw": "^0.19.5",
|
||||
"msw": "^0.20.5",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-node": "^8.10.2"
|
||||
},
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"react-test-renderer": "^16.13.1",
|
||||
"whatwg-fetch": "^3.4.0"
|
||||
"whatwg-fetch": "^3.4.0",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-lazylog": "^4.5.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
@@ -39,7 +38,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
jest.mock('../useWorkflowRuns', () => ({
|
||||
useWorkflowRuns: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
|
||||
post: jest.fn(),
|
||||
error$: jest.fn(),
|
||||
};
|
||||
|
||||
describe('<RecentWorkflowRunsCard />', () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
annotations: {
|
||||
'github.com/project-slug': 'theorg/the-service',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
const workflowRuns = [1, 2, 3, 4, 5].map(n => ({
|
||||
id: `run-id-${n}`,
|
||||
message: `Commit message for workflow ${n}`,
|
||||
source: { branchName: `branch-${n}` },
|
||||
status: 'completed',
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
(useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: workflowRuns }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const renderSubject = (props: RecentWorkflowRunsCardProps = { entity }) =>
|
||||
render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<MemoryRouter>
|
||||
<ApiProvider apis={ApiRegistry.with(errorApiRef, mockErrorApi)}>
|
||||
<RecentWorkflowRunsCard {...props} />
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
it('renders a table with a row for each workflow', async () => {
|
||||
const subject = renderSubject();
|
||||
|
||||
workflowRuns.forEach(run => {
|
||||
expect(subject.getByText(run.message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a workflow row correctly', async () => {
|
||||
const subject = renderSubject();
|
||||
const [run] = workflowRuns;
|
||||
expect(subject.getByText(run.message).closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
`/ci-cd/${run.id}`,
|
||||
);
|
||||
expect(subject.getByText(run.source.branchName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requests only the required number of workflow runs', async () => {
|
||||
const limit = 3;
|
||||
renderSubject({ entity, limit });
|
||||
expect(useWorkflowRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialPageSize: limit }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the github repo and owner from the entity annotation', async () => {
|
||||
renderSubject();
|
||||
expect(useWorkflowRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ owner: 'theorg', repo: 'the-service' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters workflows by branch if one is specified', async () => {
|
||||
const branch = 'master';
|
||||
renderSubject({ entity, branch });
|
||||
expect(useWorkflowRuns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('where there is an error fetching workflows', () => {
|
||||
const error = 'error getting workflows';
|
||||
beforeEach(() => {
|
||||
(useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: [], error }]);
|
||||
});
|
||||
|
||||
it('sends the error to the errorApi', async () => {
|
||||
renderSubject();
|
||||
expect(mockErrorApi.post).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { errorApiRef, useApi } from '@backstage/core-api';
|
||||
import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Table } from '@backstage/core';
|
||||
import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
import { Card, Link, TableContainer } from '@material-ui/core';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const firstLine = (message: string): string => message.split('\n')[0];
|
||||
|
||||
export type Props = {
|
||||
entity: Entity;
|
||||
branch?: string;
|
||||
dense?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export const RecentWorkflowRunsCard = ({
|
||||
entity,
|
||||
branch,
|
||||
dense = false,
|
||||
limit = 5,
|
||||
}: Props) => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const [owner, repo] = (
|
||||
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
|
||||
).split('/');
|
||||
const [{ runs = [], loading, error }] = useWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
initialPageSize: limit,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
|
||||
return (
|
||||
<TableContainer component={Card}>
|
||||
<Table
|
||||
title="Recent Workflow Runs"
|
||||
subtitle={branch ? `Branch: ${branch}` : 'All Branches'}
|
||||
isLoading={loading}
|
||||
options={{
|
||||
search: false,
|
||||
paging: false,
|
||||
padding: dense ? 'dense' : 'default',
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: 'Commit Message',
|
||||
field: 'message',
|
||||
render: data => (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath('./ci-cd/:id', { id: data.id! })}
|
||||
>
|
||||
{firstLine(data.message)}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: 'Branch', field: 'source.branchName' },
|
||||
{ title: 'Status', field: 'status', render: WorkflowRunStatus },
|
||||
]}
|
||||
data={runs}
|
||||
/>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards';
|
||||
export { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard';
|
||||
|
||||
@@ -24,10 +24,12 @@ export function useWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
initialPageSize = 5,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch?: string;
|
||||
initialPageSize?: number;
|
||||
}) {
|
||||
const api = useApi(githubActionsApiRef);
|
||||
const auth = useApi(githubAuthApiRef);
|
||||
@@ -36,7 +38,7 @@ export function useWorkflowRuns({
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
|
||||
const { loading, value: runs, retry, error } = useAsyncRetry<
|
||||
WorkflowRun[]
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"react-router-dom": "6.0.0-beta.0"
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.23",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/testing-library__jest-dom": "^5.9.1",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Dice roller
|
||||
|
||||
An app to roll dice (it doesn't actually do that).
|
||||
|
||||
# Viewing in local Minikube running Backstage locally
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- kubectl installed
|
||||
- Minikube installed
|
||||
- jq installed
|
||||
- Backstage locally built and ready to run
|
||||
|
||||
## Steps
|
||||
|
||||
1. Start minikube
|
||||
2. Get the Kubernetes master base url `kubectl cluster-info`
|
||||
3. Apply manifests `kubectl apply -f dice-roller-manifests.yaml`
|
||||
4. Get service account token (see below)
|
||||
5. Start Backstage UI and backend
|
||||
6. Register existing component in Backstage
|
||||
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
|
||||
|
||||
Update `app-config.yaml` as follows.
|
||||
|
||||
```yaml
|
||||
---
|
||||
kubernetes:
|
||||
clusterLocatorMethod: 'configMultiTenant'
|
||||
clusters:
|
||||
- url: <KUBERNETES MASTER BASE URL FROM STEP 2>
|
||||
name: minikube
|
||||
serviceAccountToken: <TOKEN FROM STEP 4>
|
||||
```
|
||||
|
||||
### Getting the service account token
|
||||
|
||||
```
|
||||
kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
|
||||
```
|
||||
|
||||
Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken`
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: dice-roller
|
||||
description: It rolls dice
|
||||
tags:
|
||||
- go
|
||||
annotations:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: production
|
||||
owner: guest
|
||||
@@ -0,0 +1,79 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: dice-roller
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: dice-roller
|
||||
replicas: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dice-roller
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dice-roller
|
||||
namespace: default
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
data:
|
||||
foo: bar
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: dice-roller
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
type: Opaque
|
||||
data:
|
||||
username: YWRtaW4=
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: dice-roller
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
selector:
|
||||
app: dice-roller
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 9376
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: dice-roller
|
||||
automountServiceAccountToken: false
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: sa-admin
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: dice-roller
|
||||
namespace: default
|
||||
@@ -21,7 +21,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.23",
|
||||
"@backstage/config": "^0.1.1-alpha.23",
|
||||
"@types/express": "^4.17.6",
|
||||
"@kubernetes/client-node": "^0.12.1",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
@@ -29,12 +31,14 @@
|
||||
"fs-extra": "^9.0.0",
|
||||
"helmet": "^4.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"stream-buffers": "^3.0.2",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.23",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"supertest": "^4.0.2",
|
||||
"@backstage/cli": "^0.1.1-alpha.23"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 '@backstage/backend-common';
|
||||
import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator';
|
||||
import { ConfigReader, Config } from '@backstage/config';
|
||||
|
||||
describe('MultiTenantConfigClusterLocator', () => {
|
||||
it('empty clusters returns empty cluster details', async () => {
|
||||
const config: Config = new ConfigReader(
|
||||
{
|
||||
clusters: [],
|
||||
},
|
||||
'ctx',
|
||||
);
|
||||
|
||||
const sut = MultiTenantConfigClusterLocator.fromConfig(
|
||||
config.getConfigArray('clusters'),
|
||||
);
|
||||
|
||||
const result = await sut.getClusterByServiceId('ignored');
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('one clusters returns one cluster details', async () => {
|
||||
const config: Config = new ConfigReader(
|
||||
{
|
||||
clusters: [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
],
|
||||
},
|
||||
'ctx',
|
||||
);
|
||||
|
||||
const sut = MultiTenantConfigClusterLocator.fromConfig(
|
||||
config.getConfigArray('clusters'),
|
||||
);
|
||||
|
||||
const result = await sut.getClusterByServiceId('ignored');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('two clusters returns two cluster details', async () => {
|
||||
const config: Config = new ConfigReader(
|
||||
{
|
||||
clusters: [
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8081',
|
||||
},
|
||||
],
|
||||
},
|
||||
'ctx',
|
||||
);
|
||||
|
||||
const sut = MultiTenantConfigClusterLocator.fromConfig(
|
||||
config.getConfigArray('clusters'),
|
||||
);
|
||||
|
||||
const result = await sut.getClusterByServiceId('ignored');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8080',
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
serviceAccountToken: undefined,
|
||||
url: 'http://localhost:8081',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 { Config } from '@backstage/config';
|
||||
import { ClusterDetails, KubernetesClusterLocator } from '..';
|
||||
|
||||
// This cluster locator assumes that every service is located on every cluster
|
||||
// Therefore it will always return all clusters in an app configuration file
|
||||
export class MultiTenantConfigClusterLocator
|
||||
implements KubernetesClusterLocator {
|
||||
private readonly clusterDetails: ClusterDetails[];
|
||||
|
||||
constructor(clusterDetails: ClusterDetails[]) {
|
||||
this.clusterDetails = clusterDetails;
|
||||
}
|
||||
|
||||
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
|
||||
return new MultiTenantConfigClusterLocator(
|
||||
config.map(c => {
|
||||
return {
|
||||
name: c.getString('name'),
|
||||
url: c.getString('url'),
|
||||
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// As this implementation always returns all clusters serviceId is ignored here
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getClusterByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
|
||||
return this.clusterDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export * from './service/router';
|
||||
export * from './types/types';
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 '@backstage/backend-common';
|
||||
import { KubernetesClientProvider } from './KubernetesClientProvider';
|
||||
|
||||
describe('KubernetesClientProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('can get core client by cluster details', async () => {
|
||||
const sut = new KubernetesClientProvider();
|
||||
|
||||
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
|
||||
|
||||
sut.getKubeConfig = mockGetKubeConfig;
|
||||
|
||||
const result = sut.getCoreClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
expect(result.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth = (result as any).authentications.default;
|
||||
expect(auth.users[0].token).toBe('TOKEN');
|
||||
expect(auth.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('can get cached core client by cluster details', async () => {
|
||||
const sut = new KubernetesClientProvider();
|
||||
|
||||
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
|
||||
|
||||
sut.getKubeConfig = mockGetKubeConfig;
|
||||
|
||||
const result1 = sut.getCoreClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
const result2 = sut.getCoreClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
expect(result1.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth1 = (result1 as any).authentications.default;
|
||||
expect(auth1.users[0].token).toBe('TOKEN');
|
||||
expect(auth1.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(result2.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth2 = (result2 as any).authentications.default;
|
||||
expect(auth2.users[0].token).toBe('TOKEN');
|
||||
expect(auth2.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('can get apps client by cluster details', async () => {
|
||||
const sut = new KubernetesClientProvider();
|
||||
|
||||
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
|
||||
|
||||
sut.getKubeConfig = mockGetKubeConfig;
|
||||
|
||||
const result = sut.getAppsClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
expect(result.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth = (result as any).authentications.default;
|
||||
expect(auth.users[0].token).toBe('TOKEN');
|
||||
expect(auth.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('can get cached apps client by cluster details', async () => {
|
||||
const sut = new KubernetesClientProvider();
|
||||
|
||||
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
|
||||
|
||||
sut.getKubeConfig = mockGetKubeConfig;
|
||||
|
||||
const result1 = sut.getAppsClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
const result2 = sut.getAppsClientByClusterDetails({
|
||||
name: 'cluster-name',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: 'TOKEN',
|
||||
});
|
||||
|
||||
expect(result1.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth1 = (result1 as any).authentications.default;
|
||||
expect(auth1.users[0].token).toBe('TOKEN');
|
||||
expect(auth1.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(result2.basePath).toBe('http://localhost:9999');
|
||||
// These fields aren't on the type but are there
|
||||
const auth2 = (result2 as any).authentications.default;
|
||||
expect(auth2.users[0].token).toBe('TOKEN');
|
||||
expect(auth2.clusters[0].name).toBe('cluster-name');
|
||||
|
||||
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 { ClusterDetails } from '..';
|
||||
import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
|
||||
export class KubernetesClientProvider {
|
||||
private readonly coreClientMap: {
|
||||
[key: string]: CoreV1Api;
|
||||
};
|
||||
|
||||
private readonly appsClientMap: {
|
||||
[key: string]: AppsV1Api;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.coreClientMap = {};
|
||||
this.appsClientMap = {};
|
||||
}
|
||||
|
||||
// visible for testing
|
||||
getKubeConfig(clusterDetails: ClusterDetails) {
|
||||
const cluster = {
|
||||
name: clusterDetails.name,
|
||||
server: clusterDetails.url,
|
||||
// TODO configure this
|
||||
skipTLSVerify: true,
|
||||
};
|
||||
|
||||
// TODO configure
|
||||
const user = {
|
||||
name: 'service-account',
|
||||
token: clusterDetails.serviceAccountToken,
|
||||
};
|
||||
|
||||
const context = {
|
||||
name: `${clusterDetails.name}`,
|
||||
user: user.name,
|
||||
cluster: cluster.name,
|
||||
};
|
||||
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromOptions({
|
||||
clusters: [cluster],
|
||||
users: [user],
|
||||
contexts: [context],
|
||||
currentContext: context.name,
|
||||
});
|
||||
return kc;
|
||||
}
|
||||
|
||||
getCoreClientByClusterDetails(clusterDetails: ClusterDetails) {
|
||||
const clientMapKey = clusterDetails.name;
|
||||
|
||||
if (this.coreClientMap.hasOwnProperty(clientMapKey)) {
|
||||
return this.coreClientMap[clientMapKey];
|
||||
}
|
||||
|
||||
const kc = this.getKubeConfig(clusterDetails);
|
||||
|
||||
const client = kc.makeApiClient(CoreV1Api);
|
||||
|
||||
this.coreClientMap[clientMapKey] = client;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
getAppsClientByClusterDetails(clusterDetails: ClusterDetails) {
|
||||
const clientMapKey = clusterDetails.name;
|
||||
|
||||
if (this.appsClientMap.hasOwnProperty(clientMapKey)) {
|
||||
return this.appsClientMap[clientMapKey];
|
||||
}
|
||||
|
||||
const kc = this.getKubeConfig(clusterDetails);
|
||||
|
||||
const client = kc.makeApiClient(AppsV1Api);
|
||||
|
||||
this.appsClientMap[clientMapKey] = client;
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
|
||||
describe('KubernetesClientProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return pods, services', async () => {
|
||||
const clientMock: any = {
|
||||
listPodForAllNamespaces: jest.fn(),
|
||||
listServiceForAllNamespaces: jest.fn(),
|
||||
};
|
||||
|
||||
const kubernetesClientProvider: any = {
|
||||
getCoreClientByClusterDetails: jest.fn(() => clientMock),
|
||||
getAppsClientByClusterDetails: jest.fn(() => clientMock),
|
||||
};
|
||||
|
||||
const sut = new KubernetesClientBasedFetcher({
|
||||
kubernetesClientProvider,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'service-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sut.fetchObjectsByServiceId(
|
||||
'some-service',
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
serviceAccountToken: undefined,
|
||||
},
|
||||
new Set(['pods', 'services']),
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
type: 'pods',
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'services',
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'service-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1);
|
||||
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1);
|
||||
|
||||
expect(
|
||||
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
|
||||
).toBe(2);
|
||||
expect(
|
||||
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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 {
|
||||
AppsV1Api,
|
||||
CoreV1Api,
|
||||
V1ConfigMap,
|
||||
V1Deployment,
|
||||
V1Pod,
|
||||
V1ReplicaSet,
|
||||
V1Secret,
|
||||
} from '@kubernetes/client-node';
|
||||
import { KubernetesClientProvider } from './KubernetesClientProvider';
|
||||
import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
KubernetesFetcher,
|
||||
ClusterDetails,
|
||||
KubernetesObjectTypes,
|
||||
FetchResponse,
|
||||
} from '..';
|
||||
|
||||
export interface Clients {
|
||||
core: CoreV1Api;
|
||||
apps: AppsV1Api;
|
||||
}
|
||||
|
||||
export interface KubernetesClientBasedFetcherOptions {
|
||||
kubernetesClientProvider: KubernetesClientProvider;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class KubernetesClientBasedFetcher implements KubernetesFetcher {
|
||||
private readonly kubernetesClientProvider: KubernetesClientProvider;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor({
|
||||
kubernetesClientProvider,
|
||||
logger,
|
||||
}: KubernetesClientBasedFetcherOptions) {
|
||||
this.kubernetesClientProvider = kubernetesClientProvider;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
fetchObjectsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
objectTypesToFetch: Set<KubernetesObjectTypes>,
|
||||
): Promise<FetchResponse[]> {
|
||||
return Promise.all(
|
||||
Array.from(objectTypesToFetch).map(type => {
|
||||
return this.fetchByObjectType(serviceId, clusterDetails, type);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchByObjectType(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
type: KubernetesObjectTypes,
|
||||
): Promise<FetchResponse> {
|
||||
switch (type) {
|
||||
case 'pods':
|
||||
return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({
|
||||
type: type,
|
||||
resources: r,
|
||||
}));
|
||||
case 'configmaps':
|
||||
return this.fetchConfigMapsByServiceId(
|
||||
serviceId,
|
||||
clusterDetails,
|
||||
).then(r => ({ type: type, resources: r }));
|
||||
case 'deployments':
|
||||
return this.fetchDeploymentsByServiceId(
|
||||
serviceId,
|
||||
clusterDetails,
|
||||
).then(r => ({ type: type, resources: r }));
|
||||
case 'replicasets':
|
||||
return this.fetchReplicaSetsByServiceId(
|
||||
serviceId,
|
||||
clusterDetails,
|
||||
).then(r => ({ type: type, resources: r }));
|
||||
case 'secrets':
|
||||
return this.fetchSecretsByServiceId(
|
||||
serviceId,
|
||||
clusterDetails,
|
||||
).then(r => ({ type: type, resources: r }));
|
||||
case 'services':
|
||||
return this.fetchServicesByServiceId(
|
||||
serviceId,
|
||||
clusterDetails,
|
||||
).then(r => ({ type: type, resources: r }));
|
||||
default:
|
||||
// unrecognised type
|
||||
throw new Error(`unrecognised type=${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private singleClusterFetch<T>(
|
||||
clusterDetails: ClusterDetails,
|
||||
fn: (client: Clients) => Promise<{ body: { items: Array<T> } }>,
|
||||
): Promise<Array<T>> {
|
||||
const core = this.kubernetesClientProvider.getCoreClientByClusterDetails(
|
||||
clusterDetails,
|
||||
);
|
||||
const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails(
|
||||
clusterDetails,
|
||||
);
|
||||
|
||||
this.logger.debug(`calling cluster=${clusterDetails.name}`);
|
||||
return fn({ core, apps }).then(result => {
|
||||
return result.body.items;
|
||||
});
|
||||
}
|
||||
|
||||
private fetchServicesByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1Service>> {
|
||||
return this.singleClusterFetch<V1Service>(clusterDetails, ({ core }) =>
|
||||
core.listServiceForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchPodsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1Pod>> {
|
||||
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
|
||||
core.listPodForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchConfigMapsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1ConfigMap>> {
|
||||
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
|
||||
core.listConfigMapForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchSecretsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1Secret>> {
|
||||
return this.singleClusterFetch<V1Secret>(clusterDetails, ({ core }) =>
|
||||
core.listSecretForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchDeploymentsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1Deployment>> {
|
||||
return this.singleClusterFetch<V1Deployment>(clusterDetails, ({ apps }) =>
|
||||
apps.listDeploymentForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private fetchReplicaSetsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
): Promise<Array<V1ReplicaSet>> {
|
||||
return this.singleClusterFetch<V1ReplicaSet>(clusterDetails, ({ apps }) =>
|
||||
apps.listReplicaSetForAllNamespaces(
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
`backstage.io/kubernetes-id=${serviceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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 { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ClusterDetails } from '..';
|
||||
|
||||
const TEST_SERVICE_ID = 'my-service';
|
||||
|
||||
const fetchObjectsByServiceId = jest.fn();
|
||||
|
||||
const getClusterByServiceId = jest.fn();
|
||||
|
||||
const mockFetch = (mock: jest.Mock) => {
|
||||
mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) =>
|
||||
Promise.resolve([
|
||||
{
|
||||
type: 'pods',
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: `my-pods-${serviceId}-${clusterDetails.name}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'configmaps',
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: `my-configmaps-${serviceId}-${clusterDetails.name}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'services',
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: `my-services-${serviceId}-${clusterDetails.name}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
describe('handleGetKubernetesObjectsByServiceId', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('retrieve objects for one cluster', async () => {
|
||||
getClusterByServiceId.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'test-cluster',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
mockFetch(fetchObjectsByServiceId);
|
||||
|
||||
const result = await handleGetKubernetesObjectsByServiceId(
|
||||
TEST_SERVICE_ID,
|
||||
{
|
||||
fetchObjectsByServiceId,
|
||||
},
|
||||
{
|
||||
getClusterByServiceId,
|
||||
},
|
||||
getVoidLogger(),
|
||||
);
|
||||
|
||||
expect(getClusterByServiceId.mock.calls.length).toBe(1);
|
||||
expect(fetchObjectsByServiceId.mock.calls.length).toBe(1);
|
||||
expect(result).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
cluster: {
|
||||
name: 'test-cluster',
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-pods-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'pods',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-configmaps-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'configmaps',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-services-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'services',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('retrieve objects for two clusters', async () => {
|
||||
getClusterByServiceId.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'test-cluster',
|
||||
},
|
||||
{
|
||||
name: 'other-cluster',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
mockFetch(fetchObjectsByServiceId);
|
||||
|
||||
const result = await handleGetKubernetesObjectsByServiceId(
|
||||
TEST_SERVICE_ID,
|
||||
{
|
||||
fetchObjectsByServiceId,
|
||||
},
|
||||
{
|
||||
getClusterByServiceId,
|
||||
},
|
||||
getVoidLogger(),
|
||||
);
|
||||
|
||||
expect(getClusterByServiceId.mock.calls.length).toBe(1);
|
||||
expect(fetchObjectsByServiceId.mock.calls.length).toBe(2);
|
||||
expect(result).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
cluster: {
|
||||
name: 'test-cluster',
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-pods-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'pods',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-configmaps-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'configmaps',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-services-my-service-test-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'services',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: {
|
||||
name: 'other-cluster',
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-pods-my-service-other-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'pods',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-configmaps-my-service-other-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'configmaps',
|
||||
},
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'my-services-my-service-other-cluster',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'services',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { Logger } from 'winston';
|
||||
import {
|
||||
KubernetesClusterLocator,
|
||||
KubernetesFetcher,
|
||||
KubernetesObjectTypes,
|
||||
ObjectsByServiceIdResponse,
|
||||
} from '..';
|
||||
|
||||
export type GetKubernetesObjectsByServiceIdHandler = (
|
||||
serviceId: string,
|
||||
fetcher: KubernetesFetcher,
|
||||
clusterLocator: KubernetesClusterLocator,
|
||||
logger: Logger,
|
||||
objectsToFetch?: Set<KubernetesObjectTypes>,
|
||||
) => Promise<ObjectsByServiceIdResponse>;
|
||||
|
||||
const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
|
||||
'pods',
|
||||
'services',
|
||||
'configmaps',
|
||||
'secrets',
|
||||
'deployments',
|
||||
'replicasets',
|
||||
]);
|
||||
|
||||
export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async (
|
||||
serviceId,
|
||||
fetcher,
|
||||
clusterLocator,
|
||||
logger,
|
||||
objectsToFetch = DEFAULT_OBJECTS,
|
||||
) => {
|
||||
const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
|
||||
|
||||
logger.info(
|
||||
`serviceId=${serviceId} clusterDetails=${clusterDetails.map(c => c.name)}`,
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
clusterDetails.map(cd => {
|
||||
return fetcher
|
||||
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
|
||||
.then(result => {
|
||||
return {
|
||||
cluster: {
|
||||
name: cd.name,
|
||||
},
|
||||
resources: result,
|
||||
};
|
||||
});
|
||||
}),
|
||||
).then(r => ({ items: r }));
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { makeRouter } from './router';
|
||||
import {
|
||||
KubernetesClusterLocator,
|
||||
KubernetesFetcher,
|
||||
ObjectsByServiceIdResponse,
|
||||
} from '..';
|
||||
|
||||
describe('router', () => {
|
||||
let app: express.Express;
|
||||
let kubernetesFetcher: jest.Mocked<KubernetesFetcher>;
|
||||
let kubernetesClusterLocator: jest.Mocked<KubernetesClusterLocator>;
|
||||
let handleGetByServiceId: jest.Mock<Promise<ObjectsByServiceIdResponse>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
kubernetesFetcher = {
|
||||
fetchObjectsByServiceId: jest.fn(),
|
||||
};
|
||||
|
||||
kubernetesClusterLocator = {
|
||||
getClusterByServiceId: jest.fn(),
|
||||
};
|
||||
|
||||
handleGetByServiceId = jest.fn();
|
||||
|
||||
const router = makeRouter(
|
||||
getVoidLogger(),
|
||||
kubernetesFetcher,
|
||||
kubernetesClusterLocator,
|
||||
handleGetByServiceId as any,
|
||||
);
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /services/:serviceId', () => {
|
||||
it('happy path: lists kubernetes objects', async () => {
|
||||
const result = {
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
|
||||
|
||||
const response = await request(app).get('/services/test-service');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(result);
|
||||
});
|
||||
|
||||
it('internal error: lists kubernetes objects', async () => {
|
||||
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
|
||||
|
||||
const response = await request(app).get('/services/test-service');
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual({ error: 'some internal error' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,19 +17,65 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ClusterLocatorMethod } from '../cluster-locator/types';
|
||||
import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator';
|
||||
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
import { KubernetesClientProvider } from './KubernetesClientProvider';
|
||||
import {
|
||||
GetKubernetesObjectsByServiceIdHandler,
|
||||
handleGetKubernetesObjectsByServiceId,
|
||||
} from './getKubernetesObjectsByServiceIdHandler';
|
||||
import { KubernetesClusterLocator, KubernetesFetcher } from '..';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const makeRouter = (logger: Logger): express.Router => {
|
||||
const getClusterLocator = (config: Config): KubernetesClusterLocator => {
|
||||
const clusterLocatorMethod = config.getString(
|
||||
'kubernetes.clusterLocatorMethod',
|
||||
) as ClusterLocatorMethod;
|
||||
|
||||
switch (clusterLocatorMethod) {
|
||||
case 'configMultiTenant':
|
||||
return MultiTenantConfigClusterLocator.fromConfig(
|
||||
config.getConfigArray('kubernetes.clusters'),
|
||||
);
|
||||
case 'http':
|
||||
throw new Error('not implemented');
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const makeRouter = (
|
||||
logger: Logger,
|
||||
fetcher: KubernetesFetcher,
|
||||
clusterLocator: KubernetesClusterLocator,
|
||||
handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler,
|
||||
): express.Router => {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
// TODO error handling
|
||||
router.get('/services/:serviceId', async (req, res) => {
|
||||
const serviceId = req.params.serviceId;
|
||||
logger.info(`HERE ${serviceId}`);
|
||||
res.send({ serviceId });
|
||||
|
||||
try {
|
||||
const response = await handleGetByServiceId(
|
||||
serviceId,
|
||||
fetcher,
|
||||
clusterLocator,
|
||||
logger,
|
||||
);
|
||||
res.send(response);
|
||||
} catch (e) {
|
||||
res.status(500).send({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
@@ -41,5 +87,18 @@ export async function createRouter(
|
||||
const logger = options.logger;
|
||||
|
||||
logger.info('Initializing Kubernetes backend');
|
||||
return makeRouter(logger);
|
||||
|
||||
const clusterLocator = getClusterLocator(options.config);
|
||||
|
||||
const fetcher = new KubernetesClientBasedFetcher({
|
||||
kubernetesClientProvider: new KubernetesClientProvider(),
|
||||
logger,
|
||||
});
|
||||
|
||||
return makeRouter(
|
||||
logger,
|
||||
fetcher,
|
||||
clusterLocator,
|
||||
handleGetKubernetesObjectsByServiceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
@@ -35,6 +36,7 @@ export async function createStandaloneApplication(
|
||||
options: ApplicationOptions,
|
||||
): Promise<express.Application> {
|
||||
const { enableCors, logger } = options;
|
||||
const config = ConfigReader.fromConfigs([]);
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
@@ -44,7 +46,7 @@ export async function createStandaloneApplication(
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/', await createRouter({ logger }));
|
||||
app.use('/', await createRouter({ logger, config }));
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
|
||||
@@ -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 {
|
||||
V1ConfigMap,
|
||||
V1Deployment,
|
||||
V1Pod,
|
||||
V1ReplicaSet,
|
||||
V1Secret,
|
||||
V1Service,
|
||||
} from '@kubernetes/client-node';
|
||||
|
||||
export interface ClusterDetails {
|
||||
name: string;
|
||||
url: string;
|
||||
// TODO this will eventually be configured by the auth translation work
|
||||
serviceAccountToken: string | undefined;
|
||||
}
|
||||
|
||||
export interface ClusterObjects {
|
||||
cluster: { name: string };
|
||||
resources: FetchResponse[];
|
||||
}
|
||||
|
||||
export interface ObjectsByServiceIdResponse {
|
||||
items: ClusterObjects[];
|
||||
}
|
||||
|
||||
export type FetchResponse =
|
||||
| PodFetchResponse
|
||||
| ServiceFetchResponse
|
||||
| ConfigMapFetchResponse
|
||||
| SecretFetchResponse
|
||||
| DeploymentFetchResponse
|
||||
| ReplicaSetsFetchResponse;
|
||||
|
||||
// TODO fairly sure there's a easier way to do this
|
||||
|
||||
export type KubernetesObjectTypes =
|
||||
| 'pods'
|
||||
| 'services'
|
||||
| 'configmaps'
|
||||
| 'secrets'
|
||||
| 'deployments'
|
||||
| 'replicasets';
|
||||
|
||||
export interface PodFetchResponse {
|
||||
type: 'pods';
|
||||
resources: Array<V1Pod>;
|
||||
}
|
||||
|
||||
export interface ServiceFetchResponse {
|
||||
type: 'services';
|
||||
resources: Array<V1Service>;
|
||||
}
|
||||
|
||||
export interface ConfigMapFetchResponse {
|
||||
type: 'configmaps';
|
||||
resources: Array<V1ConfigMap>;
|
||||
}
|
||||
|
||||
export interface SecretFetchResponse {
|
||||
type: 'secrets';
|
||||
resources: Array<V1Secret>;
|
||||
}
|
||||
|
||||
export interface DeploymentFetchResponse {
|
||||
type: 'deployments';
|
||||
resources: Array<V1Deployment>;
|
||||
}
|
||||
|
||||
export interface ReplicaSetsFetchResponse {
|
||||
type: 'replicasets';
|
||||
resources: Array<V1ReplicaSet>;
|
||||
}
|
||||
|
||||
// Fetches information from a kubernetes cluster using the cluster details object
|
||||
// to target a specific cluster
|
||||
export interface KubernetesFetcher {
|
||||
fetchObjectsByServiceId(
|
||||
serviceId: string,
|
||||
clusterDetails: ClusterDetails,
|
||||
objectTypesToFetch: Set<KubernetesObjectTypes>,
|
||||
): Promise<FetchResponse[]>;
|
||||
}
|
||||
|
||||
// Used to locate which cluster(s) a service is running on
|
||||
export interface KubernetesClusterLocator {
|
||||
getClusterByServiceId(serviceId: string): Promise<ClusterDetails[]>;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.23",
|
||||
"@backstage/core": "^0.1.1-alpha.23",
|
||||
"@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.23",
|
||||
"@backstage/theme": "^0.1.1-alpha.23",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -39,7 +40,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -22,7 +22,7 @@ import { rootCatalogKubernetesRouteRef } from './plugin';
|
||||
import { KubernetesContent } from './components/KubernetesContent';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
|
||||
const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes';
|
||||
const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id';
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) => {
|
||||
const kubernetesAnnotationValue =
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { DiscoveryApi } from '@backstage/core';
|
||||
import { KubernetesApi } from './types';
|
||||
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
|
||||
|
||||
export class KubernetesBackendClient implements KubernetesApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
@@ -37,7 +38,9 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getObjectsByServiceId(serviceId: String): Promise<{}> {
|
||||
async getObjectsByServiceId(
|
||||
serviceId: String,
|
||||
): Promise<ObjectsByServiceIdResponse> {
|
||||
return await this.getRequired(`/services/${serviceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
|
||||
|
||||
export const kubernetesApiRef = createApiRef<KubernetesApi>({
|
||||
id: 'plugin.kubernetes.service',
|
||||
@@ -23,5 +24,5 @@ export const kubernetesApiRef = createApiRef<KubernetesApi>({
|
||||
});
|
||||
|
||||
export interface KubernetesApi {
|
||||
getObjectsByServiceId(serviceId: String): Promise<{}>;
|
||||
getObjectsByServiceId(serviceId: String): Promise<ObjectsByServiceIdResponse>;
|
||||
}
|
||||
|
||||
@@ -15,25 +15,19 @@
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Typography, Grid } from '@material-ui/core';
|
||||
import {
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { InfoCard, Page, pageTheme, Content, useApi } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { kubernetesApiRef } from '../../api/types';
|
||||
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
|
||||
|
||||
// TODO this is a temporary component used to construct the Kubernetes plugin boilerplate
|
||||
|
||||
export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
const [kubernetesObjects, setKubernetesObjects] = useState<{} | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [kubernetesObjects, setKubernetesObjects] = useState<
|
||||
ObjectsByServiceIdResponse | undefined
|
||||
>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,21 +44,28 @@ export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => {
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Content>
|
||||
<ContentHeader title="This is where you would see your kubernetes objects" />
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title="This is where you would see your kubernetes objects">
|
||||
<Typography variant="body1">
|
||||
{kubernetesObjects === undefined && <div>loading....</div>}
|
||||
{error !== undefined && <div>{error}</div>}
|
||||
{kubernetesObjects !== undefined && (
|
||||
<div>
|
||||
backend response: {JSON.stringify(kubernetesObjects)}
|
||||
</div>
|
||||
)}
|
||||
</Typography>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
{kubernetesObjects === undefined && <div>loading....</div>}
|
||||
{error !== undefined && <div>{error}</div>}
|
||||
{kubernetesObjects !== undefined && (
|
||||
<div>
|
||||
{kubernetesObjects.items.map((item, i) => (
|
||||
<Grid item key={i}>
|
||||
<InfoCard key={item.cluster.name} title={item.cluster.name}>
|
||||
{item.resources.map((fr, j) => (
|
||||
<div key={j}>
|
||||
<br />
|
||||
{fr.type}:{' '}
|
||||
{(fr.resources as any)
|
||||
.map((v: any) => v.metadata.name)
|
||||
.join(' ')}
|
||||
</div>
|
||||
))}
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
import { createRouter } from './router';
|
||||
import * as winston from 'winston';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { loadBackendConfig } from '@backstage/backend-common';
|
||||
import {
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
describe('createRouter', () => {
|
||||
it('works', async () => {
|
||||
const logger = winston.createLogger();
|
||||
const config = ConfigReader.fromConfigs(await loadBackendConfig());
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const router = await createRouter({
|
||||
config,
|
||||
logger,
|
||||
pathPrefix: '/proxy',
|
||||
discovery,
|
||||
});
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -23,12 +23,12 @@ import createProxyMiddleware, {
|
||||
} from 'http-proxy-middleware';
|
||||
import { Logger } from 'winston';
|
||||
import http from 'http';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
// The URL path prefix that the router itself is mounted as, commonly "/proxy"
|
||||
pathPrefix: string;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
}
|
||||
|
||||
export interface ProxyConfig extends ProxyMiddlewareConfig {
|
||||
@@ -76,16 +76,14 @@ export async function createRouter(
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
const externalUrl = await options.discovery.getExternalBaseUrl('proxy');
|
||||
const { pathname: pathPrefix } = new URL(externalUrl);
|
||||
|
||||
const proxyConfig = options.config.getOptional('proxy') ?? {};
|
||||
Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => {
|
||||
router.use(
|
||||
route,
|
||||
buildMiddleware(
|
||||
options.pathPrefix,
|
||||
options.logger,
|
||||
route,
|
||||
proxyRouteConfig,
|
||||
),
|
||||
buildMiddleware(pathPrefix, options.logger, route, proxyRouteConfig),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
createServiceBuilder,
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
@@ -37,10 +38,11 @@ export async function startStandaloneServer(
|
||||
logger.debug('Creating application...');
|
||||
|
||||
const config = ConfigReader.fromConfigs(await loadBackendConfig());
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const router = await createRouter({
|
||||
config,
|
||||
logger,
|
||||
pathPrefix: '/proxy',
|
||||
discovery,
|
||||
});
|
||||
const service = createServiceBuilder(module)
|
||||
.enableCors({ origin: 'http://localhost:3000' })
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -18,11 +18,11 @@ yarn start
|
||||
|
||||
## What techdocs-backend does
|
||||
|
||||
This plugin is the backend part of the techdocs plugin. It provides building and serving of your docs without having to use another service and hosting provider. To use it set your techdocs storageUrl in your `app-config.yml` to `http://localhost:7000/techdocs/static/docs`.
|
||||
This plugin is the backend part of the techdocs plugin. It provides building and serving of your docs without having to use another service and hosting provider. To use it set your techdocs storageUrl in your `app-config.yml` to `http://localhost:7000/api/techdocs/static/docs`.
|
||||
|
||||
```yaml
|
||||
techdocs:
|
||||
storageUrl: http://localhost:7000/techdocs/static/docs
|
||||
storageUrl: http://localhost:7000/api/techdocs/static/docs
|
||||
```
|
||||
|
||||
## Extending techdocs-backend
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
PublisherBase,
|
||||
LocalPublish,
|
||||
} from '../techdocs';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import {
|
||||
PluginEndpointDiscovery,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { DocsBuilder } from './helpers';
|
||||
|
||||
@@ -35,6 +38,7 @@ type RouterOptions = {
|
||||
generators: GeneratorBuilder;
|
||||
publisher: PublisherBase;
|
||||
logger: Logger;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
database?: Knex; // TODO: Make database required when we're implementing database stuff.
|
||||
config: Config;
|
||||
dockerClient: Docker;
|
||||
@@ -52,20 +56,25 @@ export async function createRouter({
|
||||
config,
|
||||
dockerClient,
|
||||
logger,
|
||||
discovery,
|
||||
}: RouterOptions): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
router.get('/docs/:kind/:namespace/:name/*', async (req, res) => {
|
||||
const baseUrl = config.getString('backend.baseUrl');
|
||||
const storageUrl = config.getString('techdocs.storageUrl');
|
||||
|
||||
const { kind, namespace, name } = req.params;
|
||||
|
||||
const entity = (await (
|
||||
await fetch(
|
||||
`${baseUrl}/catalog/entities/by-name/${kind}/${namespace}/${name}`,
|
||||
)
|
||||
).json()) as Entity;
|
||||
const catalogUrl = await discovery.getBaseUrl('catalog');
|
||||
const triple = [kind, namespace, name].map(encodeURIComponent).join('/');
|
||||
|
||||
const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`);
|
||||
if (!catalogRes.ok) {
|
||||
catalogRes.body.pipe(res.status(catalogRes.status));
|
||||
return;
|
||||
}
|
||||
|
||||
const entity: Entity = await catalogRes.json();
|
||||
|
||||
const docsBuilder = new DocsBuilder({
|
||||
preparers,
|
||||
@@ -80,7 +89,7 @@ export async function createRouter({
|
||||
await docsBuilder.build();
|
||||
}
|
||||
|
||||
return res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`);
|
||||
res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`);
|
||||
});
|
||||
|
||||
if (publisher instanceof LocalPublish) {
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createServiceBuilder } from '@backstage/backend-common';
|
||||
import {
|
||||
createServiceBuilder,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
@@ -39,6 +42,7 @@ export async function startStandaloneServer(
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'techdocs-backend' });
|
||||
const config = ConfigReader.fromConfigs([]);
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
logger.debug('Creating application...');
|
||||
const preparers = new Preparers();
|
||||
@@ -61,6 +65,7 @@ export async function startStandaloneServer(
|
||||
publisher,
|
||||
dockerClient,
|
||||
config,
|
||||
discovery,
|
||||
});
|
||||
const service = createServiceBuilder(module)
|
||||
.enableCors({ origin: 'http://localhost:3000' })
|
||||
|
||||
@@ -64,7 +64,7 @@ export class LocalPublish implements PublisherBase {
|
||||
}
|
||||
|
||||
resolve({
|
||||
remoteUrl: `http://localhost:7000/techdocs/static/docs/${entity.metadata.name}`,
|
||||
remoteUrl: `http://localhost:7000/api/techdocs/static/docs/${entity.metadata.name}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"canvas": "^2.6.1",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
Reference in New Issue
Block a user