Merge branch 'master' into fact-retriever-async
This commit is contained in:
@@ -140,6 +140,7 @@ export async function createRouter(
|
||||
}
|
||||
if (provider.refresh) {
|
||||
r.get('/refresh', provider.refresh.bind(provider));
|
||||
r.post('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
|
||||
router.use(`/${providerId}`, r);
|
||||
|
||||
@@ -10,27 +10,10 @@ import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "CodeCoverageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface CodeCoverageApi {
|
||||
// (undocumented)
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "makeRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const makeRouter: (options: RouterOptions) => Promise<express.Router>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
|
||||
@@ -20,4 +20,5 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './service/router';
|
||||
export { createRouter } from './service/router';
|
||||
export type { RouterOptions } from './service/router';
|
||||
|
||||
@@ -35,6 +35,11 @@ import { Jacoco } from './converter/jacoco';
|
||||
import { Converter } from './converter';
|
||||
import { getEntitySourceLocation } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* Options for {@link createRouter}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
@@ -211,6 +216,11 @@ export const makeRouter = async (
|
||||
return router;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a code-coverage plugin backend router.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
|
||||
@@ -9,8 +9,6 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "codeCoveragePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const codeCoveragePlugin: BackstagePlugin<
|
||||
{
|
||||
@@ -19,20 +17,12 @@ export const codeCoveragePlugin: BackstagePlugin<
|
||||
{}
|
||||
>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityCodeCoverageContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export const EntityCodeCoverageContent: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "isCodeCoverageAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
const isCodeCoverageAvailable: (entity: Entity) => boolean;
|
||||
export { isCodeCoverageAvailable };
|
||||
export { isCodeCoverageAvailable as isPluginApplicableToEntity };
|
||||
// @public
|
||||
export function isCodeCoverageAvailable(entity: Entity): boolean;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const Router: () => JSX.Element;
|
||||
// @public @deprecated (undocumented)
|
||||
export const isPluginApplicableToEntity: typeof isCodeCoverageAvailable;
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'highlight.js/styles/atom-one-dark.css';
|
||||
import highlight from 'highlight.js';
|
||||
|
||||
|
||||
@@ -13,16 +13,26 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { CodeCoveragePage } from './CodeCoveragePage';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core-components';
|
||||
|
||||
export const isCodeCoverageAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']);
|
||||
/**
|
||||
* Returns true if the given entity has code coverage enabled.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function isCodeCoverageAvailable(entity: Entity) {
|
||||
return Boolean(entity.metadata.annotations?.['backstage.io/code-coverage']);
|
||||
}
|
||||
|
||||
export const Router = () => {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const Router = (): JSX.Element => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isCodeCoverageAvailable(entity)) {
|
||||
@@ -30,5 +40,6 @@ export const Router = () => {
|
||||
<MissingAnnotationEmptyState annotation="backstage.io/code-coverage" />
|
||||
);
|
||||
}
|
||||
|
||||
return <CodeCoveragePage />;
|
||||
};
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { isCodeCoverageAvailable } from './components/Router';
|
||||
|
||||
export { codeCoveragePlugin, EntityCodeCoverageContent } from './plugin';
|
||||
export {
|
||||
Router,
|
||||
isCodeCoverageAvailable,
|
||||
isCodeCoverageAvailable as isPluginApplicableToEntity,
|
||||
} from './components/Router';
|
||||
export { isCodeCoverageAvailable };
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `isPluginApplicableToEntity` instead.
|
||||
*/
|
||||
export const isPluginApplicableToEntity = isCodeCoverageAvailable;
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
discoveryApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const codeCoveragePlugin = createPlugin({
|
||||
id: 'code-coverage',
|
||||
routes: {
|
||||
@@ -37,6 +40,11 @@ export const codeCoveragePlugin = createPlugin({
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* An entity code coverage page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EntityCodeCoverageContent = codeCoveragePlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityCodeCoverageContent',
|
||||
|
||||
@@ -43,9 +43,12 @@ proxy:
|
||||
headers:
|
||||
Authorization: token ${FOSSA_API_TOKEN}
|
||||
|
||||
# if you have a fossa organization, configure your id here
|
||||
fossa:
|
||||
# if you have a fossa organization, configure your id here
|
||||
organizationId: <your-fossa-organization-id>
|
||||
# if you have a self-managed fossa instance,
|
||||
# configure the baseUrl to use for links to the fossa page here
|
||||
externalLinkBaseUrl: <your fossa url>
|
||||
```
|
||||
|
||||
4. Get an api-token and provide `FOSSA_AUTH_HEADER` as env variable (https://app.fossa.com/account/settings/integrations/api_tokens)
|
||||
|
||||
Vendored
+7
-1
@@ -20,6 +20,12 @@ export interface Config {
|
||||
* The organization id in fossa.
|
||||
* @visibility frontend
|
||||
*/
|
||||
organizationId: string;
|
||||
organizationId?: string;
|
||||
|
||||
/**
|
||||
* The base url to use for external links (from Backstage to Fossa).
|
||||
* @visibility frontend
|
||||
*/
|
||||
externalLinkBaseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +175,58 @@ describe('FossaClient', () => {
|
||||
expect(summary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow custom external link base url', async () => {
|
||||
client = new FossaClient({
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
organizationId: '8736',
|
||||
externalLinkBaseUrl: 'https://custom.fossa.com', // overrides the default app.fossa.com
|
||||
});
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
|
||||
const expectedQuery =
|
||||
'count=1000&page=0&sort=title%2B&organizationId=8736&title=our-service';
|
||||
if (req.url.searchParams.toString() !== expectedQuery) {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.body(
|
||||
`${req.url.searchParams.toString()} !== ${expectedQuery}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json([
|
||||
{
|
||||
locator: 'custom+8736/our-service',
|
||||
title: 'our-service',
|
||||
default_branch: 'develop',
|
||||
revisions: [
|
||||
{
|
||||
updatedAt: '2020-01-01T00:00:00Z',
|
||||
dependency_count: 160,
|
||||
unresolved_licensing_issue_count: 5,
|
||||
unresolved_issue_count: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = await client.getFindingSummary('our-service');
|
||||
|
||||
expect(summary).toEqual({
|
||||
timestamp: '2020-01-01T00:00:00Z',
|
||||
issueCount: 5,
|
||||
dependencyCount: 160,
|
||||
projectDefaultBranch: 'develop',
|
||||
projectUrl:
|
||||
'https://custom.fossa.com/projects/custom%2B8736%2Four-service',
|
||||
} as FindingSummary);
|
||||
});
|
||||
|
||||
it('should handle 404 status', async () => {
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => {
|
||||
|
||||
@@ -36,20 +36,24 @@ export class FossaClient implements FossaApi {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
organizationId?: string;
|
||||
externalLinkBaseUrl: string;
|
||||
private readonly limit = pLimit(5);
|
||||
|
||||
constructor({
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
organizationId,
|
||||
externalLinkBaseUrl = 'https://app.fossa.com',
|
||||
}: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
organizationId?: string;
|
||||
externalLinkBaseUrl?: string;
|
||||
}) {
|
||||
this.discoveryApi = discoveryApi;
|
||||
this.identityApi = identityApi;
|
||||
this.organizationId = organizationId;
|
||||
this.externalLinkBaseUrl = externalLinkBaseUrl;
|
||||
}
|
||||
|
||||
private async callApi<T>(
|
||||
@@ -104,9 +108,9 @@ export class FossaClient implements FossaApi {
|
||||
revision.unresolved_issue_count,
|
||||
dependencyCount: revision.dependency_count,
|
||||
projectDefaultBranch: project.default_branch,
|
||||
projectUrl: `https://app.fossa.com/projects/${encodeURIComponent(
|
||||
project.locator,
|
||||
)}`,
|
||||
projectUrl: `${
|
||||
this.externalLinkBaseUrl
|
||||
}/projects/${encodeURIComponent(project.locator)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ export const fossaPlugin = createPlugin({
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
organizationId: configApi.getOptionalString('fossa.organizationId'),
|
||||
externalLinkBaseUrl: configApi.getOptionalString(
|
||||
'fossa.externalLinkBaseUrl',
|
||||
),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@react-hookz/web": "^14.0.0",
|
||||
"@react-hookz/web": "^15.0.0",
|
||||
"react-router-dom": "6.0.0-beta.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Duration } from 'luxon';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
@@ -108,6 +109,7 @@ export class KubernetesBuilder {
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
): express.Router;
|
||||
// (undocumented)
|
||||
protected buildServiceLocator(
|
||||
@@ -155,6 +157,8 @@ export interface KubernetesClustersSupplier {
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface KubernetesEnvironment {
|
||||
// (undocumented)
|
||||
catalogApi: CatalogApi;
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
@@ -268,6 +272,8 @@ export interface ObjectToFetch {
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
catalogApi: CatalogApi;
|
||||
// (undocumented)
|
||||
clusterSupplier?: KubernetesClustersSupplier;
|
||||
// (undocumented)
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
"dependencies": {
|
||||
"@azure/identity": "^2.0.4",
|
||||
"@backstage/backend-common": "^0.14.1-next.2",
|
||||
"@backstage/catalog-client": "^1.0.4-next.1",
|
||||
"@backstage/catalog-model": "^1.1.0-next.2",
|
||||
"@backstage/config": "^1.0.1",
|
||||
"@backstage/errors": "^1.1.0-next.0",
|
||||
"@backstage/plugin-kubernetes-common": "^0.4.0-next.1",
|
||||
"@backstage/plugin-auth-node": "^0.2.3-next.1",
|
||||
"@backstage/plugin-kubernetes-common": "^0.4.0-next.0",
|
||||
"@google-cloud/container": "^4.0.0",
|
||||
"@kubernetes/client-node": "^0.16.0",
|
||||
"@kubernetes/client-node": "^0.17.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/luxon": "^2.0.4",
|
||||
"aws-sdk": "^2.840.0",
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import Router from 'express-promise-router';
|
||||
import { addResourceRoutesToRouter } from './resourcesRoutes';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('resourcesRoutes', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
const router = Router();
|
||||
addResourceRoutesToRouter(
|
||||
router,
|
||||
{
|
||||
getEntityByRef: jest.fn().mockImplementation(entityRef => {
|
||||
if (entityRef.name === 'noentity') {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve({
|
||||
kind: entityRef.kind,
|
||||
metadata: {
|
||||
name: entityRef.name,
|
||||
namespace: entityRef.namespace,
|
||||
},
|
||||
} as Entity);
|
||||
}),
|
||||
} as any,
|
||||
{
|
||||
getKubernetesObjectsByEntity: jest.fn().mockImplementation(args => {
|
||||
if (args.entity.metadata.name === 'inject500') {
|
||||
return Promise.reject(new Error('some internal error'));
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
getCustomResourcesByEntity: jest.fn().mockImplementation(args => {
|
||||
if (args.entity.metadata.name === 'inject500') {
|
||||
return Promise.reject(new Error('some internal error'));
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
} as any,
|
||||
);
|
||||
app.use('/', router);
|
||||
app.use(errorHandler());
|
||||
});
|
||||
|
||||
describe('POST /resources/workloads/query', () => {
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('200 happy path', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'kind:namespacec/someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(200, {
|
||||
items: [
|
||||
{
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when missing entity ref', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: { name: 'InputError', message: 'entity is a required field' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/workloads/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when bad entity ref', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'ffff',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message:
|
||||
'Invalid entity ref, Error: Entity reference "ffff" had missing or empty kind (e.g. did not start with "component:" or similar)',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/workloads/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when no entity in catalog', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'noentity:noentity',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'Entity ref missing, noentity:default/noentity',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/workloads/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('401 when no Auth header', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(401, {
|
||||
error: { name: 'AuthenticationError', message: 'No Backstage token' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/workloads/query',
|
||||
},
|
||||
response: { statusCode: 401 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('401 when invalid Auth header', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'ffffff')
|
||||
.expect(401, {
|
||||
error: { name: 'AuthenticationError', message: 'No Backstage token' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/workloads/query',
|
||||
},
|
||||
response: { statusCode: 401 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('500 handle gracefully', async () => {
|
||||
await request(app)
|
||||
.post('/resources/workloads/query')
|
||||
.send({
|
||||
entityRef: 'inject500:inject500/inject500',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(500, {
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: 'some internal error',
|
||||
level: 'error',
|
||||
service: 'backstage',
|
||||
},
|
||||
request: { method: 'POST', url: '/resources/workloads/query' },
|
||||
response: { statusCode: 500 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('POST /resources/custom/query', () => {
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('200 happy path', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(200, {
|
||||
items: [
|
||||
{
|
||||
clusterOne: {
|
||||
pods: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'pod1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when missing custom resources', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'customResources is a required field',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when custom resources not array', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: 'somestring',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'customResources must be an array',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when custom resources empty', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'at least 1 customResource is required',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when missing entity ref', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: { name: 'InputError', message: 'entity is a required field' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when bad entity ref', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'ffff',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message:
|
||||
'Invalid entity ref, Error: Entity reference "ffff" had missing or empty kind (e.g. did not start with "component:" or similar)',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('400 when no entity in catalog', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'noentity:noentity',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(400, {
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'Entity ref missing, noentity:default/noentity',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 400 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('401 when no Auth header', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(401, {
|
||||
error: { name: 'AuthenticationError', message: 'No Backstage token' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 401 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('401 when invalid Auth header', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'component:someComponent',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'ffffff')
|
||||
.expect(401, {
|
||||
error: { name: 'AuthenticationError', message: 'No Backstage token' },
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/resources/custom/query',
|
||||
},
|
||||
response: { statusCode: 401 },
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('500 handle gracefully', async () => {
|
||||
await request(app)
|
||||
.post('/resources/custom/query')
|
||||
.send({
|
||||
entityRef: 'inject500:inject500/inject500',
|
||||
auth: {
|
||||
google: 'something',
|
||||
},
|
||||
customResources: [
|
||||
{
|
||||
group: 'someGroup',
|
||||
apiVersion: 'someApiVersion',
|
||||
plural: 'somePlural',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Authorization', 'Bearer Zm9vYmFy')
|
||||
.expect(500, {
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: 'some internal error',
|
||||
level: 'error',
|
||||
service: 'backstage',
|
||||
},
|
||||
request: { method: 'POST', url: '/resources/custom/query' },
|
||||
response: { statusCode: 500 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { InputError, AuthenticationError } from '@backstage/errors';
|
||||
import express, { Request } from 'express';
|
||||
import { KubernetesObjectsProvider } from '../types/types';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
|
||||
export const addResourceRoutesToRouter = (
|
||||
router: express.Router,
|
||||
catalogApi: CatalogApi,
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
) => {
|
||||
const getEntityByReq = async (req: Request<any>) => {
|
||||
const rawEntityRef = req.body.entityRef;
|
||||
if (rawEntityRef && typeof rawEntityRef !== 'string') {
|
||||
throw new InputError(`entity query must be a string`);
|
||||
} else if (!rawEntityRef) {
|
||||
throw new InputError('entity is a required field');
|
||||
}
|
||||
let entityRef: CompoundEntityRef | undefined = undefined;
|
||||
|
||||
try {
|
||||
entityRef = parseEntityRef(rawEntityRef);
|
||||
} catch (error) {
|
||||
throw new InputError(`Invalid entity ref, ${error}`);
|
||||
}
|
||||
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.headers.authorization,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError('No Backstage token');
|
||||
}
|
||||
|
||||
const entity = await catalogApi.getEntityByRef(entityRef, {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new InputError(
|
||||
`Entity ref missing, ${stringifyEntityRef(entityRef)}`,
|
||||
);
|
||||
}
|
||||
return entity;
|
||||
};
|
||||
|
||||
router.post('/resources/workloads/query', async (req, res) => {
|
||||
const entity = await getEntityByReq(req);
|
||||
const response = await objectsProvider.getKubernetesObjectsByEntity({
|
||||
entity,
|
||||
auth: req.body.auth,
|
||||
});
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
router.post('/resources/custom/query', async (req, res) => {
|
||||
const entity = await getEntityByReq(req);
|
||||
|
||||
if (!req.body.customResources) {
|
||||
throw new InputError('customResources is a required field');
|
||||
} else if (!Array.isArray(req.body.customResources)) {
|
||||
throw new InputError('customResources must be an array');
|
||||
} else if (req.body.customResources.length === 0) {
|
||||
throw new InputError('at least 1 customResource is required');
|
||||
}
|
||||
|
||||
const response = await objectsProvider.getCustomResourcesByEntity({
|
||||
entity,
|
||||
customResources: req.body.customResources,
|
||||
auth: req.body.auth,
|
||||
});
|
||||
res.json(response);
|
||||
});
|
||||
};
|
||||
@@ -31,11 +31,13 @@ import {
|
||||
import { KubernetesBuilder } from './KubernetesBuilder';
|
||||
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
|
||||
import { PodStatus } from '@kubernetes/client-node';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
describe('KubernetesBuilder', () => {
|
||||
let app: express.Express;
|
||||
let kubernetesFanOutHandler: jest.Mocked<KubernetesFanOutHandler>;
|
||||
let config: Config;
|
||||
let catalogApi: CatalogApi;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = getVoidLogger();
|
||||
@@ -69,7 +71,13 @@ describe('KubernetesBuilder', () => {
|
||||
getKubernetesObjectsByEntity: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const { router } = await KubernetesBuilder.createBuilder({ config, logger })
|
||||
catalogApi = {} as CatalogApi;
|
||||
|
||||
const { router } = await KubernetesBuilder.createBuilder({
|
||||
config,
|
||||
logger,
|
||||
catalogApi,
|
||||
})
|
||||
.setObjectsProvider(kubernetesFanOutHandler)
|
||||
.setClusterSupplier(clusterSupplier)
|
||||
.build();
|
||||
@@ -240,6 +248,7 @@ describe('KubernetesBuilder', () => {
|
||||
const { router } = await KubernetesBuilder.createBuilder({
|
||||
logger,
|
||||
config,
|
||||
catalogApi,
|
||||
})
|
||||
.setClusterSupplier(clusterSupplier)
|
||||
.setServiceLocator(serviceLocator)
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
KubernetesFanOutHandler,
|
||||
} from './KubernetesFanOutHandler';
|
||||
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -45,6 +47,7 @@ import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
export interface KubernetesEnvironment {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
catalogApi: CatalogApi;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +122,11 @@ export class KubernetesBuilder {
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
|
||||
const router = this.buildRouter(objectsProvider, clusterSupplier);
|
||||
const router = this.buildRouter(
|
||||
objectsProvider,
|
||||
clusterSupplier,
|
||||
this.env.catalogApi,
|
||||
);
|
||||
|
||||
return {
|
||||
clusterSupplier,
|
||||
@@ -226,11 +233,13 @@ export class KubernetesBuilder {
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
): express.Router {
|
||||
const logger = this.env.logger;
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
// @deprecated
|
||||
router.post('/services/:serviceId', async (req, res) => {
|
||||
const serviceId = req.params.serviceId;
|
||||
const requestBody: ObjectsByEntityRequest = req.body;
|
||||
@@ -259,6 +268,9 @@ export class KubernetesBuilder {
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Logger } from 'winston';
|
||||
import { KubernetesClustersSupplier } from '../types/types';
|
||||
import express from 'express';
|
||||
import { KubernetesBuilder } from './KubernetesBuilder';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -27,6 +28,7 @@ import { KubernetesBuilder } from './KubernetesBuilder';
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
catalogApi: CatalogApi;
|
||||
clusterSupplier?: KubernetesClustersSupplier;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
@@ -26,6 +27,7 @@ import helmet from 'helmet';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
@@ -39,6 +41,10 @@ export async function createStandaloneApplication(
|
||||
const config = new ConfigReader({});
|
||||
const app = express();
|
||||
|
||||
const catalogApi = new CatalogClient({
|
||||
discoveryApi: SingleHostDiscovery.fromConfig(config),
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
if (enableCors) {
|
||||
app.use(cors());
|
||||
@@ -46,7 +52,7 @@ export async function createStandaloneApplication(
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/', await createRouter({ logger, config }));
|
||||
app.use('/', await createRouter({ logger, config, catalogApi }));
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@
|
||||
"url": "https://github.com/backstage/backstage/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^1.1.0-next.1",
|
||||
"@backstage/catalog-model": "^1.1.0-next.2",
|
||||
"@kubernetes/client-node": "^0.16.0"
|
||||
"@kubernetes/client-node": "^0.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.18.0-next.2"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@backstage/plugin-catalog-react": "^1.1.2-next.2",
|
||||
"@backstage/plugin-kubernetes-common": "^0.4.0-next.1",
|
||||
"@backstage/theme": "^0.2.16-next.1",
|
||||
"@kubernetes/client-node": "^0.16.0",
|
||||
"@kubernetes/client-node": "^0.17.0",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
|
||||
@@ -14,3 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { TextDecoder, TextEncoder } from 'util';
|
||||
|
||||
// These are missing from jest-node, so not available on global.
|
||||
Object.defineProperty(global, 'TextEncoder', {
|
||||
value: TextEncoder,
|
||||
});
|
||||
|
||||
Object.defineProperty(global, 'TextDecoder', {
|
||||
value: TextDecoder,
|
||||
});
|
||||
|
||||
@@ -388,6 +388,7 @@ export function createPublishGitlabAction(options: {
|
||||
gitCommitMessage?: string | undefined;
|
||||
gitAuthorName?: string | undefined;
|
||||
gitAuthorEmail?: string | undefined;
|
||||
setUserAsOwner?: boolean | undefined;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -32,6 +32,9 @@ const mockGitlabClient = {
|
||||
Users: {
|
||||
current: jest.fn(),
|
||||
},
|
||||
ProjectMembers: {
|
||||
add: jest.fn(),
|
||||
},
|
||||
};
|
||||
jest.mock('@gitbeaker/node', () => ({
|
||||
Gitlab: class {
|
||||
@@ -113,6 +116,7 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
|
||||
it('should work when there is a token provided through ctx.input', async () => {
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -135,6 +139,7 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
|
||||
it('should call the correct Gitlab APIs when the owner is an organization', async () => {
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -168,6 +173,7 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
|
||||
it('should call initRepoAndPush with the correct values', async () => {
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -187,6 +193,7 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
|
||||
it('should call initRepoAndPush with the correct default branch', async () => {
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -241,6 +248,7 @@ describe('publish:gitlab', () => {
|
||||
config: customAuthorConfig,
|
||||
});
|
||||
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -286,6 +294,7 @@ describe('publish:gitlab', () => {
|
||||
config: customAuthorConfig,
|
||||
});
|
||||
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -305,6 +314,7 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
|
||||
it('should call output with the remoteUrl and repoContentsUrl', async () => {
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -321,4 +331,54 @@ describe('publish:gitlab', () => {
|
||||
'http://mockurl/-/blob/master',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the correct Gitlab APIs when setUserAsOwner option is true and integration config has a token', async () => {
|
||||
const customAuthorConfig = new ConfigReader({
|
||||
integrations: {
|
||||
gitlab: [
|
||||
{
|
||||
host: 'gitlab.com',
|
||||
token: 'tokenlols',
|
||||
apiBaseUrl: 'https://api.gitlab.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customAuthorIntegrations =
|
||||
ScmIntegrations.fromConfig(customAuthorConfig);
|
||||
const customAuthorAction = createPublishGitlabAction({
|
||||
integrations: customAuthorIntegrations,
|
||||
config: customAuthorConfig,
|
||||
});
|
||||
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Users.current.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
id: 123456,
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
|
||||
await customAuthorAction.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
repoUrl: 'gitlab.com?repo=repo&owner=owner',
|
||||
token: 'token',
|
||||
setUserAsOwner: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner');
|
||||
expect(mockGitlabClient.Users.current).toHaveBeenCalled();
|
||||
expect(mockGitlabClient.ProjectMembers.add).toHaveBeenCalledWith(
|
||||
123456,
|
||||
12345,
|
||||
50,
|
||||
);
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespace_id: 1234,
|
||||
name: 'repo',
|
||||
visibility: 'private',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createPublishGitlabAction(options: {
|
||||
gitCommitMessage?: string;
|
||||
gitAuthorName?: string;
|
||||
gitAuthorEmail?: string;
|
||||
setUserAsOwner?: boolean;
|
||||
}>({
|
||||
id: 'publish:gitlab',
|
||||
description:
|
||||
@@ -92,6 +93,12 @@ export function createPublishGitlabAction(options: {
|
||||
type: 'string',
|
||||
description: 'The token to use for authorization to GitLab',
|
||||
},
|
||||
setUserAsOwner: {
|
||||
title: 'Set User As Owner',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Set the token user as owner of the newly created repository. Requires a token authorized to do the edit in the integration configuration for the matching host',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -116,6 +123,7 @@ export function createPublishGitlabAction(options: {
|
||||
gitCommitMessage = 'initial commit',
|
||||
gitAuthorName,
|
||||
gitAuthorEmail,
|
||||
setUserAsOwner = false,
|
||||
} = ctx.input;
|
||||
const { owner, repo, host } = parseRepoUrl(repoUrl, integrations);
|
||||
|
||||
@@ -149,19 +157,35 @@ export function createPublishGitlabAction(options: {
|
||||
id: number;
|
||||
};
|
||||
|
||||
const { id: userId } = (await client.Users.current()) as {
|
||||
id: number;
|
||||
};
|
||||
|
||||
if (!targetNamespace) {
|
||||
const { id } = (await client.Users.current()) as {
|
||||
id: number;
|
||||
};
|
||||
targetNamespace = id;
|
||||
targetNamespace = userId;
|
||||
}
|
||||
|
||||
const { http_url_to_repo } = await client.Projects.create({
|
||||
const { id: projectId, http_url_to_repo } = await client.Projects.create({
|
||||
namespace_id: targetNamespace,
|
||||
name: repo,
|
||||
visibility: repoVisibility,
|
||||
});
|
||||
|
||||
// When setUserAsOwner is true the input token is expected to come from an unprivileged user GitLab
|
||||
// OAuth flow. In this case GitLab works in a way that allows the unprivileged user to
|
||||
// create the repository, but not to push the default protected branch (e.g. master).
|
||||
// In order to set the user as owner of the newly created repository we need to check that the
|
||||
// GitLab integration configuration for the matching host contains a token and use
|
||||
// such token to bootstrap a new privileged client.
|
||||
if (setUserAsOwner && integrationConfig.config.token) {
|
||||
const adminClient = new Gitlab({
|
||||
host: integrationConfig.config.baseUrl,
|
||||
token: integrationConfig.config.token,
|
||||
});
|
||||
|
||||
await adminClient.ProjectMembers.add(projectId, userId, 50);
|
||||
}
|
||||
|
||||
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
|
||||
const repoContentsUrl = `${remoteUrl}/-/blob/${defaultBranch}`;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@react-hookz/web": "^13.0.0",
|
||||
"@react-hookz/web": "^15.0.0",
|
||||
"@rjsf/core": "^3.2.1",
|
||||
"@rjsf/material-ui": "^3.2.1",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
|
||||
@@ -90,9 +90,17 @@ export class MyOwnClient implements TechRadarApi {
|
||||
|
||||
const data = await fetch('https://mydata.json').then(res => res.json());
|
||||
|
||||
// maybe you'll need to do some data transformation here to make it look like TechRadarLoaderResponse
|
||||
|
||||
return data;
|
||||
// For example, this converts the timeline dates into date objects
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map(entry => ({
|
||||
...entry,
|
||||
timeline: entry.timeline.map(timeline => ({
|
||||
...timeline,
|
||||
date: new Date(timeline.date),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@react-hookz/web": "^14.0.0",
|
||||
"@react-hookz/web": "^15.0.0",
|
||||
"git-url-parse": "^12.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user