Merge pull request #24489 from harness/cjlee1/harnessIntegration

feat: Introduce Harness URL reader and Code Repo support
This commit is contained in:
Fredrik Adelöw
2024-05-04 23:35:54 +02:00
committed by GitHub
23 changed files with 1048 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Added `HarnessURLReader` with `readUrl` support.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': minor
---
Added `HarnessIntegration` via the `ScmIntegrations` interface.
@@ -152,6 +152,8 @@ graphviz
Hackathons
haproxy
hardcoded
Harness
harness
Helidon
Henneke
Heroku
+33
View File
@@ -0,0 +1,33 @@
---
id: locations
title: Harness Locations
sidebar_label: Locations
description: Integrating source code stored in Harness Code into the Backstage catalog
---
The Harness Code integration supports loading catalog entities from a hosted repository. Entities can be added to
[static catalog configuration](../../features/software-catalog/configuration.md),
registered with the
[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import)
plugin.
## Configuration
To use this integration, add configuration to your root `app-config.yaml`:
```yaml
integrations:
harness:
- host: app.harness.io
token: ${HARNESS_CODE_BEARER_TOKEN}
apiKey: ${HARNESS_CODE_APIKEY}
```
Directly under the `harness` key is a list of provider configurations, where you
can list the Harness instances you want to be able to fetch
check out https://developer.harness.io/docs/platform/automation/api/add-and-manage-api-keys/ for more information
- `host`: The host of the Harness Code instance that you want to match on.
- `token` (optional): The password or api token to authenticate with.
- `apiKey` (optional): The apiKey to authenticate with.
+5
View File
@@ -238,6 +238,11 @@
"label": "Gitea",
"items": ["integrations/gitea/locations"]
},
{
"type": "category",
"label": "Harness",
"items": ["integrations/harness/locations"]
},
{
"type": "category",
"label": "Google GCS",
+18
View File
@@ -30,6 +30,7 @@ import { GiteaIntegration } from '@backstage/integration';
import { GithubCredentialsProvider } from '@backstage/integration';
import { GithubIntegration } from '@backstage/integration';
import { GitLabIntegration } from '@backstage/integration';
import { HarnessIntegration } from '@backstage/integration';
import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { IdentityService } from '@backstage/backend-plugin-api';
@@ -512,6 +513,23 @@ export class GitlabUrlReader implements UrlReader {
toString(): string;
}
// @public
export class HarnessUrlReader implements UrlReader {
constructor(integration: HarnessIntegration);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export const HostDiscovery: typeof HostDiscovery_2;
@@ -0,0 +1,185 @@
/*
* Copyright 2024 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { HarnessIntegration, readHarnessConfig } from '@backstage/integration';
import { JsonObject } from '@backstage/types';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { getVoidLogger } from '../logging';
import { UrlReaderPredicateTuple } from './types';
import { DefaultReadTreeResponseFactory } from './tree';
import getRawBody from 'raw-body';
import { HarnessUrlReader } from './HarnessUrlReader';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
jest.mock('../scm', () => ({
Git: {
fromAuth: () => ({
clone: jest.fn(() => Promise.resolve({})),
}),
},
}));
const harnessProcessor = new HarnessUrlReader(
new HarnessIntegration(
readHarnessConfig(
new ConfigReader({
host: 'app.harness.io',
token: 'p',
}),
),
),
);
const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => {
return HarnessUrlReader.factory({
config: new ConfigReader(config),
logger: getVoidLogger(),
treeResponseFactory,
});
};
const responseBuffer = Buffer.from('Apache License');
const harnessApiResponse = (content: any) => {
return content;
};
const handlers = [
rest.get(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/:path+/raw/all-apis.yaml',
(_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Error!!!' }));
},
),
rest.get(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/:path+/raw/404error.yaml',
(_req, res, ctx) => {
return res(ctx.status(404), ctx.json({ message: 'File not found.' }));
},
),
rest.get(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/:path+/raw/stream.TXT',
(_req, res, ctx) => {
return res(
ctx.status(200),
ctx.body(harnessApiResponse(responseBuffer.toString())),
);
},
),
rest.get(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/:path+/raw/buffer.TXT',
(_req, res, ctx) => {
return res(
ctx.status(200),
ctx.body(harnessApiResponse(responseBuffer.toString())),
);
},
),
];
describe('HarnessUrlReader', () => {
const worker = setupServer(...handlers);
setupRequestMockHandlers(worker);
beforeAll(() => worker.listen({ onUnhandledRequest: 'bypass' }));
afterAll(() => {
jest.clearAllMocks();
});
describe('reader factory', () => {
it('creates a reader.', () => {
const readers = createReader({
integrations: {
harness: [{ host: 'app.harness.io' }],
},
});
expect(readers).toHaveLength(1);
});
it('should not create a default entry.', () => {
const readers = createReader({
integrations: {},
});
expect(readers).toHaveLength(0);
});
});
describe('predicates', () => {
it('returns true for the configured host', () => {
const readers = createReader({
integrations: {
harness: [{ host: 'app.harness.io' }],
},
});
const predicate = readers[0].predicate;
expect(predicate(new URL('https://app.harness.io/path'))).toBe(true);
});
it('returns false for a different host.', () => {
const readers = createReader({
integrations: {
harness: [{ host: 'app.harness.io' }],
},
});
const predicate = readers[0].predicate;
expect(predicate(new URL('https://github.com/path'))).toBe(false);
});
});
describe('readUrl part 1', () => {
it('should be able to read file contents as buffer', async () => {
const result = await harnessProcessor.readUrl(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/buffer.TXT',
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe(responseBuffer.toString());
});
it('should be able to read file contents as stream', async () => {
const result = await harnessProcessor.readUrl(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/stream.TXT',
);
const fromStream = await getRawBody(result.stream!());
expect(fromStream.toString()).toBe(responseBuffer.toString());
});
it('should raise NotFoundError on 404.', async () => {
await expect(
harnessProcessor.readUrl(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/404error.yaml',
),
).rejects.toThrow(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/404error.yaml x https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/raw/404error.yaml?routingId=accountId&git_ref=refMain, 404 Not Found',
);
});
it('should throw an error on non 404 errors.', async () => {
await expect(
harnessProcessor.readUrl(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/all-apis.yaml',
),
).rejects.toThrow(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/all-apis.yaml x https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/raw/all-apis.yaml?routingId=accountId&git_ref=refMain, 500 Internal Server Error',
);
});
});
});
@@ -0,0 +1,125 @@
/*
* Copyright 2025 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 {
getHarnessRequestOptions,
getHarnessFileContentsUrl,
HarnessIntegration,
ScmIntegrations,
} from '@backstage/integration';
import { ReadUrlOptions, ReadUrlResponse } from './types';
import {
ReaderFactory,
ReadTreeResponse,
SearchResponse,
UrlReader,
} from './types';
import fetch, { Response } from 'node-fetch';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
import {
AuthenticationError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import { Readable } from 'stream';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for the Harness code v1 api.
*
*
* @public
*/
export class HarnessUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config }) => {
return ScmIntegrations.fromConfig(config)
.harness.list()
.map(integration => {
const reader = new HarnessUrlReader(integration);
const predicate = (url: URL) => {
return url.host === integration.config.host;
};
return { reader, predicate };
});
};
constructor(private readonly integration: HarnessIntegration) {}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
let response: Response;
const blobUrl = getHarnessFileContentsUrl(this.integration.config, url);
try {
response = await fetch(blobUrl, {
method: 'GET',
...getHarnessRequestOptions(this.integration.config),
signal: options?.signal as any,
});
} catch (e) {
throw new Error(`Unable to read ${blobUrl}, ${e}`);
}
if (response.ok) {
// Harness Code returns the raw content object
const jsonResponse = { data: response.body };
if (jsonResponse) {
return ReadUrlResponseFactory.fromReadable(
Readable.from(jsonResponse.data),
{
etag: response.headers.get('ETag') ?? undefined,
},
);
}
throw new Error(`Unknown json: ${jsonResponse}`);
}
const message = `${url} x ${blobUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.status === 403) {
throw new AuthenticationError();
}
throw new Error(message);
}
readTree(): Promise<ReadTreeResponse> {
throw new Error('HarnessUrlReader readTree not implemented.');
}
search(): Promise<SearchResponse> {
throw new Error('HarnessUrlReader search not implemented.');
}
toString() {
const { host } = this.integration.config;
return `harness{host=${host},authed=${Boolean(
this.integration.config.token || this.integration.config.apiKey,
)}}`;
}
}
@@ -31,6 +31,7 @@ import { GoogleGcsUrlReader } from './GoogleGcsUrlReader';
import { AwsS3UrlReader } from './AwsS3UrlReader';
import { GiteaUrlReader } from './GiteaUrlReader';
import { AwsCodeCommitUrlReader } from './AwsCodeCommitUrlReader';
import { HarnessUrlReader } from './HarnessUrlReader';
/**
* Creation options for {@link @backstage/backend-plugin-api#UrlReaderService}.
@@ -61,7 +62,6 @@ export class UrlReaders {
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config,
});
for (const factory of factories ?? []) {
const tuples = factory({ config, logger: logger, treeResponseFactory });
@@ -94,6 +94,7 @@ export class UrlReaders {
GiteaUrlReader.factory,
GitlabUrlReader.factory,
GoogleGcsUrlReader.factory,
HarnessUrlReader.factory,
AwsS3UrlReader.factory,
AwsCodeCommitUrlReader.factory,
FetchUrlReader.factory,
@@ -22,6 +22,7 @@ export { GerritUrlReader } from './GerritUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
export { GitlabUrlReader } from './GitlabUrlReader';
export { GiteaUrlReader } from './GiteaUrlReader';
export { HarnessUrlReader } from './HarnessUrlReader';
export { AwsS3UrlReader } from './AwsS3UrlReader';
export { FetchUrlReader } from './FetchUrlReader';
export { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
+48
View File
@@ -513,6 +513,17 @@ export function getGitLabRequestOptions(config: GitLabIntegrationConfig): {
headers: Record<string, string>;
};
// @public
export function getHarnessFileContentsUrl(
config: HarnessIntegrationConfig,
url: string,
): string;
// @public
export function getHarnessRequestOptions(config: HarnessIntegrationConfig): {
headers?: Record<string, string>;
};
// @public
export class GiteaIntegration implements ScmIntegration {
constructor(config: GiteaIntegrationConfig);
@@ -674,6 +685,34 @@ export type GoogleGcsIntegrationConfig = {
privateKey?: string;
};
// @public
export class HarnessIntegration implements ScmIntegration {
constructor(config: HarnessIntegrationConfig);
// (undocumented)
readonly config: HarnessIntegrationConfig;
// (undocumented)
static factory: ScmIntegrationsFactory<HarnessIntegration>;
// (undocumented)
resolveEditUrl(url: string): string;
// (undocumented)
resolveUrl(options: {
url: string;
base: string;
lineNumber?: number | undefined;
}): string;
// (undocumented)
get title(): string;
// (undocumented)
get type(): string;
}
// @public
export type HarnessIntegrationConfig = {
host: string;
token?: string;
apiKey?: string;
};
// @public
export interface IntegrationsByType {
// (undocumented)
@@ -696,6 +735,8 @@ export interface IntegrationsByType {
github: ScmIntegrationsGroup<GithubIntegration>;
// (undocumented)
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
// (undocumented)
harness: ScmIntegrationsGroup<HarnessIntegration>;
}
// @public
@@ -839,6 +880,9 @@ export function readGoogleGcsIntegrationConfig(
config: Config,
): GoogleGcsIntegrationConfig;
// @public
export function readHarnessConfig(config: Config): HarnessIntegrationConfig;
// @public @deprecated (undocumented)
export const replaceGitHubUrlType: typeof replaceGithubUrlType;
@@ -889,6 +933,8 @@ export interface ScmIntegrationRegistry
github: ScmIntegrationsGroup<GithubIntegration>;
// (undocumented)
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
// (undocumented)
harness: ScmIntegrationsGroup<HarnessIntegration>;
resolveEditUrl(url: string): string;
resolveUrl(options: {
url: string;
@@ -927,6 +973,8 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
// (undocumented)
get gitlab(): ScmIntegrationsGroup<GitLabIntegration>;
// (undocumented)
get harness(): ScmIntegrationsGroup<HarnessIntegration>;
// (undocumented)
list(): ScmIntegration[];
// (undocumented)
resolveEditUrl(url: string): string;
+18
View File
@@ -345,5 +345,23 @@ export interface Config {
*/
password?: string;
}>;
/** Integration configuration for Harness Code */
harness?: Array<{
/**
* The hostname of the given Harness Code instance
* @visibility frontend
*/
host: string;
/**
* The apikey to use for authenticated requests.
* @visibility secret
*/
apiKey?: string;
/**
* Harness Code token used to authenticate requests. This can be either a generated access token.
* @visibility secret
*/
token?: string;
}>;
};
}
@@ -38,6 +38,7 @@ import { ScmIntegrations } from './ScmIntegrations';
import { GiteaIntegration, GiteaIntegrationConfig } from './gitea';
import { AwsCodeCommitIntegration } from './awsCodeCommit/AwsCodeCommitIntegration';
import { AwsCodeCommitIntegrationConfig } from './awsCodeCommit';
import { HarnessIntegration, HarnessIntegrationConfig } from './harness';
describe('ScmIntegrations', () => {
const awsS3 = new AwsS3Integration({
@@ -80,6 +81,10 @@ describe('ScmIntegrations', () => {
host: 'gitea.local',
} as GiteaIntegrationConfig);
const harness = new HarnessIntegration({
host: 'harness.local',
} as HarnessIntegrationConfig);
const i = new ScmIntegrations({
awsS3: basicIntegrations([awsS3], item => item.config.host),
awsCodeCommit: basicIntegrations([awsCodeCommit], item => item.config.host),
@@ -94,6 +99,7 @@ describe('ScmIntegrations', () => {
github: basicIntegrations([github], item => item.config.host),
gitlab: basicIntegrations([gitlab], item => item.config.host),
gitea: basicIntegrations([gitea], item => item.config.host),
harness: basicIntegrations([harness], item => item.config.host),
});
it('can get the specifics', () => {
@@ -113,6 +119,7 @@ describe('ScmIntegrations', () => {
expect(i.github.byUrl('https://github.local')).toBe(github);
expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab);
expect(i.gitea.byUrl('https://gitea.local')).toBe(gitea);
expect(i.harness.byUrl('https://harness.local')).toBe(harness);
});
it('can list', () => {
@@ -128,6 +135,7 @@ describe('ScmIntegrations', () => {
github,
gitlab,
gitea,
harness,
]),
);
});
@@ -143,6 +151,7 @@ describe('ScmIntegrations', () => {
expect(i.byUrl('https://github.local')).toBe(github);
expect(i.byUrl('https://gitlab.local')).toBe(gitlab);
expect(i.byUrl('https://gitea.local')).toBe(gitea);
expect(i.byUrl('https://harness.local')).toBe(harness);
expect(i.byHost('awss3.local')).toBe(awsS3);
expect(i.byHost('awscodecommit.local')).toBe(awsCodeCommit);
@@ -28,6 +28,7 @@ import { defaultScmResolveUrl } from './helpers';
import { ScmIntegration, ScmIntegrationsGroup } from './types';
import { ScmIntegrationRegistry } from './registry';
import { GiteaIntegration } from './gitea';
import { HarnessIntegration } from './harness/HarnessIntegration';
/**
* The set of supported integrations.
@@ -48,6 +49,7 @@ export interface IntegrationsByType {
github: ScmIntegrationsGroup<GithubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
gitea: ScmIntegrationsGroup<GiteaIntegration>;
harness: ScmIntegrationsGroup<HarnessIntegration>;
}
/**
@@ -70,6 +72,7 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
github: GithubIntegration.factory({ config }),
gitlab: GitLabIntegration.factory({ config }),
gitea: GiteaIntegration.factory({ config }),
harness: HarnessIntegration.factory({ config }),
});
}
@@ -120,6 +123,10 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
return this.byType.gitea;
}
get harness(): ScmIntegrationsGroup<HarnessIntegration> {
return this.byType.harness;
}
list(): ScmIntegration[] {
return Object.values(this.byType).flatMap(
i => i.list() as ScmIntegration[],
@@ -0,0 +1,123 @@
/*
* Copyright 2024 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 { ConfigReader } from '@backstage/config';
import { HarnessIntegration } from './HarnessIntegration';
describe('HarnessIntegration', () => {
it('has a working factory', () => {
const integrations = HarnessIntegration.factory({
config: new ConfigReader({
integrations: {
harness: [
{
host: 'app.harness.io',
token: '1234',
},
],
},
}),
});
expect(integrations.list().length).toBe(1);
expect(integrations.list()[0].config.host).toBe('app.harness.io');
});
it('returns the basics', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(integration.type).toBe('harness');
expect(integration.title).toBe('app.harness.io');
});
describe('resolveUrl', () => {
it('works for valid urls, ignoring line number', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(
integration.resolveUrl({
url: 'https://app.harness.io/catalog-info.yaml',
base: 'https://app.harness.io/catalog-info.yaml',
lineNumber: 9,
}),
).toBe('https://app.harness.io/catalog-info.yaml');
});
it('handles line numbers', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(
integration.resolveUrl({
url: '',
base: 'https://app.harness.io/catalog-info.yaml#4',
lineNumber: 9,
}),
).toBe('https://app.harness.io/catalog-info.yaml#L9');
});
});
describe('resolves with a relative url', () => {
it('works for valid urls', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(
integration.resolveUrl({
url: './skeleton',
base: 'https://app.harness.io/git/plugins/repo/+/refs/heads/master/template.yaml',
}),
).toBe(
'https://app.harness.io/git/plugins/repo/+/refs/heads/master/skeleton',
);
});
});
describe('resolves with an absolute url', () => {
it('works for valid urls', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(
integration.resolveUrl({
url: '/catalog-info.yaml',
base: 'https://app.harness.io/git/repo/+/refs/heads/master/',
}),
).toBe(
'https://app.harness.io/git/repo/+/refs/heads/master/catalog-info.yaml',
);
});
});
it('resolve edit URL', () => {
const integration = new HarnessIntegration({
host: 'app.harness.io',
});
expect(
integration.resolveEditUrl(
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/edit/refMain/~/all-apis.yaml',
),
).toBe(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/edit/all-apis.yaml',
);
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2024 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 { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import { HarnessIntegrationConfig, readHarnessConfig } from './config';
import { getHarnessEditContentsUrl } from './core';
/**
* A Harness Code based integration.
*
* @public
*/
export class HarnessIntegration implements ScmIntegration {
static factory: ScmIntegrationsFactory<HarnessIntegration> = ({ config }) => {
const configs = config.getOptionalConfigArray('integrations.harness') ?? [];
const harnessConfigs = configs.map(c => readHarnessConfig(c));
return basicIntegrations(
harnessConfigs.map(c => new HarnessIntegration(c)),
(harness: HarnessIntegration) => harness.config.host,
);
};
constructor(readonly config: HarnessIntegrationConfig) {}
get type(): string {
return 'harness';
}
get title(): string {
return this.config.host;
}
resolveUrl(options: {
url: string;
base: string;
lineNumber?: number | undefined;
}): string {
return defaultScmResolveUrl(options);
}
resolveEditUrl(url: string): string {
return getHarnessEditContentsUrl(this.config, url);
}
}
@@ -0,0 +1,98 @@
/*
* Copyright 2024 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 { Config, ConfigReader } from '@backstage/config';
import { loadConfigSchema } from '@backstage/config-loader';
import { HarnessIntegrationConfig, readHarnessConfig } from './config';
describe('readHarnessConfig', () => {
function buildConfig(data: Partial<HarnessIntegrationConfig>): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<HarnessIntegrationConfig>,
): Promise<Config> {
const fullSchema = await loadConfigSchema({
dependencies: ['@backstage/integration'],
});
const serializedSchema = fullSchema.serialize() as {
schemas: { value: { properties?: { integrations?: object } } }[];
};
const schema = await loadConfigSchema({
serialized: {
...serializedSchema, // only include schemas that apply to integrations
schemas: serializedSchema.schemas.filter(
s => s.value?.properties?.integrations,
),
},
});
const processed = schema.process(
[{ data: { integrations: { harness: [data] } }, context: 'app' }],
{ visibility: ['frontend'] },
);
return new ConfigReader((processed[0].data as any).integrations.harness[0]);
}
it('reads all values', () => {
const output = readHarnessConfig(
buildConfig({
host: 'a.com',
token: 'p',
apiKey: 'a',
}),
);
expect(output).toEqual({
host: 'a.com',
token: 'p',
apiKey: 'a',
});
});
it('can create a default value if the API base URL is missing', () => {
const output = readHarnessConfig(
buildConfig({
host: 'a.com',
}),
);
expect(output).toEqual({
host: 'a.com',
token: undefined,
});
});
it('rejects funky configs', () => {
const valid: any = {
host: 'a.com',
};
expect(() => readHarnessConfig(buildConfig({ ...valid, host: 2 }))).toThrow(
/host/,
);
});
it('works on the frontend', async () => {
expect(
readHarnessConfig(
await buildFrontendConfig({
host: 'a.com',
token: 'p',
}),
),
).toEqual({
host: 'a.com',
});
});
});
@@ -0,0 +1,61 @@
/*
* Copyright 2024 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 { Config } from '@backstage/config';
import { isValidHost } from '../helpers';
/**
* The configuration for a single Harness integration.
*
* @public
*/
export type HarnessIntegrationConfig = {
/**
* The host of the target that this matches on, e.g. "app.harness.io"
*/
host: string;
/**
* The password or http token to use for authentication.
*/
token?: string;
/**
* The API key to use for authentication.
*/
apiKey?: string;
};
/**
* Parses a location config block for use in HarnessIntegration
*
* @public
*/
export function readHarnessConfig(config: Config): HarnessIntegrationConfig {
const host = config.getString('host');
const token = config.getOptionalString('token');
const apiKey = config.getOptionalString('apiKey');
if (!isValidHost(host)) {
throw new Error(
`Invalid Harness Code integration config, '${host}' is not a valid host`,
);
}
return {
host,
apiKey,
token,
};
}
@@ -0,0 +1,91 @@
/*
* Copyright 2020 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 { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '../helpers';
import { HarnessIntegrationConfig } from './config';
import {
getHarnessEditContentsUrl,
getHarnessFileContentsUrl,
getHarnessRequestOptions,
} from './core';
describe('Harness code core', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
describe('getHarnessFileContentsUrl', () => {
it('can create an url from arguments', () => {
const config: HarnessIntegrationConfig = {
host: 'app.harness.io',
};
expect(
getHarnessFileContentsUrl(
config,
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/all-apis.yaml',
),
).toEqual(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/raw/all-apis.yaml?routingId=accountId&git_ref=refMain',
);
});
});
describe('getHarnessEditContentsUrl', () => {
it('can create an url from arguments', () => {
const config: HarnessIntegrationConfig = {
host: 'app.harness.io',
};
expect(
getHarnessEditContentsUrl(
config,
'https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/edit/refMain/~/all-apis.yaml',
),
).toEqual(
'https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/edit/all-apis.yaml',
);
});
});
describe('getHarnessRequestOptions', () => {
it('adds token header when only a token is specified', () => {
const authRequest: HarnessIntegrationConfig = {
host: 'app.harness.io',
token: 'P',
};
const anonymousRequest: HarnessIntegrationConfig = {
host: 'app.harness.io',
};
expect(
(getHarnessRequestOptions(authRequest).headers as any).Authorization,
).toEqual('Bearer P');
expect(getHarnessRequestOptions(anonymousRequest).headers).toStrictEqual(
{},
);
});
it('adds basic auth when apikey and token are specified', () => {
const authRequest: HarnessIntegrationConfig = {
host: 'app.harness.io',
token: 'P',
apiKey: 'a',
};
expect(
(getHarnessRequestOptions(authRequest).headers as any)['x-api-key'],
).toEqual('a');
});
});
});
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright 2024 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 { HarnessIntegrationConfig } from './config';
/**
* Given a URL pointing to a file, returns a URL
* for editing the contents of the data.
*
* @remarks
*
* Converts
* from: https://app.harness.io/a/b/src/branchname/path/to/c.yaml
* or: https://app.harness.io/a/b/_edit/branchname/path/to/c.yaml
*
* @param url - A URL pointing to a file
* @param config - The relevant provider config
* @public
*/
export function getHarnessEditContentsUrl(
config: HarnessIntegrationConfig,
url: string,
) {
try {
const baseUrl = `https://${config.host}`;
const [
_blank,
_ng,
_account,
accountId,
_module,
_moduleName,
_org,
orgName,
_projects,
projectName,
_repos,
repoName,
_files,
_ref,
_branch,
...path
] = url.replace(baseUrl, '').split('/');
const pathWithoutSlash = path.join('/').replace(/^\//, '');
return `${baseUrl}/gateway/code/api/v1/repos/${accountId}/${orgName}/${projectName}/${repoName}/+/edit/${pathWithoutSlash}`;
} catch (e) {
throw new Error(`Incorrect URL: ${url}, ${e}`);
}
}
/**
* Given a file path URL,
* it returns an API URL which returns the contents of the file.
* @remarks
*
* Converts
* from: https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/all-apis.yaml
* to: https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/content/all-apis.yaml?routingId=accountId&include_commit=false&ref=refMain
*
* @param url - A URL pointing to a file
* @param config - The relevant provider config
* @public
*/
export function getHarnessFileContentsUrl(
config: HarnessIntegrationConfig,
url: string,
) {
try {
const baseUrl = `https://${config.host}`;
const [
_blank,
_ng,
_account,
accountId,
_module,
_moduleName,
_org,
orgName,
_projects,
projectName,
_repos,
repoName,
_files,
_ref,
_branch,
...path
] = url.replace(baseUrl, '').split('/');
const urlParts = url.replace(baseUrl, '').split('/');
const refAndPath = urlParts.slice(13);
const refIndex = refAndPath.findIndex(item => item === '~');
const refString = refAndPath.slice(0, refIndex);
const pathWithoutSlash = path.join('/').replace(/^\//, '');
return `${baseUrl}/gateway/code/api/v1/repos/${accountId}/${orgName}/${projectName}/${repoName}/+/raw/${pathWithoutSlash}?routingId=${accountId}&git_ref=${refString}`;
} catch (e) {
throw new Error(`Incorrect URL: ${url}, ${e}`);
}
}
/**
* Return request headers for a Harness Code provider.
*
* @param config - A Harness Code provider config
* @public
*/
export function getHarnessRequestOptions(config: HarnessIntegrationConfig): {
headers?: Record<string, string>;
} {
const headers: Record<string, string> = {};
const { token, apiKey } = config;
if (apiKey) {
headers['x-api-key'] = apiKey;
} else if (token) {
headers.Authorization = `Bearer ${token}`;
}
return {
headers,
};
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { HarnessIntegration } from './HarnessIntegration';
export { getHarnessRequestOptions, getHarnessFileContentsUrl } from './core';
export { readHarnessConfig } from './config';
export type { HarnessIntegrationConfig } from './config';
+1
View File
@@ -31,6 +31,7 @@ export * from './gitea';
export * from './github';
export * from './gitlab';
export * from './googleGcs';
export * from './harness';
export { defaultScmResolveUrl } from './helpers';
export { ScmIntegrations } from './ScmIntegrations';
export type { IntegrationsByType } from './ScmIntegrations';
+2
View File
@@ -25,6 +25,7 @@ import { GerritIntegration } from './gerrit/GerritIntegration';
import { GithubIntegration } from './github/GithubIntegration';
import { GitLabIntegration } from './gitlab/GitLabIntegration';
import { GiteaIntegration } from './gitea/GiteaIntegration';
import { HarnessIntegration } from './harness/HarnessIntegration';
/**
* Holds all registered SCM integrations, of all types.
@@ -46,6 +47,7 @@ export interface ScmIntegrationRegistry
github: ScmIntegrationsGroup<GithubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
gitea: ScmIntegrationsGroup<GiteaIntegration>;
harness: ScmIntegrationsGroup<HarnessIntegration>;
/**
* Resolves an absolute or relative URL in relation to a base URL.
*