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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"git-url-parse": "^14.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.25.1",
"@types/git-url-parse": "^9.0.3",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
Expand Down
5 changes: 4 additions & 1 deletion plugins/backstage-plugin-devcontainers-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export * from './service/router';
export { DevcontainersProcessor } from './processors/DevcontainersProcessor';
export {
DevcontainersProcessor,
type VsCodeUrlKey,
} from './processors/DevcontainersProcessor';
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ describe(`${DevcontainersProcessor.name}`, () => {
expect(inputEntity).toEqual(inputSnapshot);

const metadataCompare = structuredClone(inputSnapshot.metadata);
metadataCompare.annotations = {
...(metadataCompare.annotations ?? {}),
vsCodeUrl:
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
};
delete metadataCompare.tags;

expect(outputEntity).toEqual(
Expand Down Expand Up @@ -226,6 +231,11 @@ describe(`${DevcontainersProcessor.name}`, () => {
expect(inputEntity).toEqual(inputSnapshot);

const metadataCompare = structuredClone(inputSnapshot.metadata);
metadataCompare.annotations = {
...(metadataCompare.annotations ?? {}),
vsCodeUrl:
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
};
delete metadataCompare.tags;

expect(outputEntity).toEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ import { type Config } from '@backstage/config';
import { isError, NotFoundError } from '@backstage/errors';
import { type UrlReader, UrlReaders } from '@backstage/backend-common';
import { type Logger } from 'winston';
import { parseGitUrl } from '../utils/git';

export const DEFAULT_TAG_NAME = 'devcontainers';
export const PROCESSOR_NAME_PREFIX = 'backstage-plugin-devcontainers-backend';

const vsCodeUrlKey = 'vsCodeUrl';

// We export this type instead of the actual constant so we can validate the
// constant on the frontend at compile-time instead of making the backend plugin
// a run-time dependency, so it can continue to run standalone.
export type VsCodeUrlKey = typeof vsCodeUrlKey;

type ProcessorOptions = Readonly<{
tagName: string;
logger: Logger;
Expand Down Expand Up @@ -89,7 +97,12 @@ export class DevcontainersProcessor implements CatalogProcessor {
try {
const jsonUrl = await this.findDevcontainerJson(rootUrl, entityLogger);
entityLogger.info('Found devcontainer config', { url: jsonUrl });
return this.addTag(entity, this.options.tagName, entityLogger);
return this.addMetadata(
entity,
this.options.tagName,
location,
entityLogger,
);
} catch (error) {
if (!isError(error) || error.name !== 'NotFoundError') {
emit(
Expand All @@ -115,16 +128,25 @@ export class DevcontainersProcessor implements CatalogProcessor {
return entity;
}

private addTag(entity: Entity, newTag: string, logger: Logger): Entity {
private addMetadata(
entity: Entity,
newTag: string,
location: LocationSpec,
logger: Logger,
): Entity {
if (entity.metadata.tags?.includes(newTag)) {
return entity;
}

logger.info(`Adding "${newTag}" tag to component`);
logger.info(`Adding VS Code URL and "${newTag}" tag to component`);
return {
...entity,
metadata: {
...entity.metadata,
annotations: {
...(entity.metadata.annotations ?? {}),
[vsCodeUrlKey]: serializeVsCodeUrl(location.target),
},
tags: [...(entity.metadata?.tags ?? []), newTag],
},
};
Expand Down Expand Up @@ -185,3 +207,15 @@ export class DevcontainersProcessor implements CatalogProcessor {
return url;
}
}

/**
* Current implementation for generating the URL will likely need to change as
* we flesh out the backend plugin. For example, it would be nice if there was
* a way to specify the branch instead of always checking out the default.
*/
function serializeVsCodeUrl(repoUrl: string): string {
const cleaners: readonly RegExp[] = [/^url: */];
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
const rootUrl = parseGitUrl(cleanedUrl);
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${rootUrl}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { parseGitUrl } from './git';

describe('git', () => {
it('parses urls', () => {
// List of forges and the various ways URLs can be formed.
const forges = {
github: {
saas: 'github.com',
paths: [
'/tree/foo',
'/blob/foo',
'/tree/foo/dir',
'/blob/foo/dir/file.ts',
],
},
gitlab: {
saas: 'gitlab.com',
paths: [
'/-/tree/foo',
'/-/blob/foo',
'/-/tree/foo/dir?ref_type=heads',
'/-/blob/foo/dir/file.ts?ref_type=heads',
],
},
bitbucket: {
saas: 'bitbucket.org',
paths: [
'/src/hashOrTag',
'/src/hashOrTag?at=foo',
'/src/hashOrTag/dir',
'/src/hashOrTag/dir?at=foo',
'/src/hashOrTag/dir/file.ts',
'/src/hashOrTag/dir/file.ts?at=foo',
],
},
};

for (const [forge, test] of Object.entries(forges)) {
// These are URLs that point to the root of the repository. To these we
// append the above paths to test that the original root URL is extracted.
const baseUrls = [
// Most common format.
`https://${test.saas}/coder/backstage-plugins`,
// GitLab lets you have a sub-group.
`https://${test.saas}/coder/group/backstage-plugins`,
// Self-hosted.
`https://${forge}.coder.com/coder/backstage-plugins`,
// Self-hosted at a port.
`https://${forge}.coder.com:9999/coder/backstage-plugins`,
// Self-hosted at base path.
`https://${forge}.coder.com/base/path/coder/backstage-plugins`,
// Self-hosted without the forge anywhere in the domain.
'https://coder.com/coder/backstage-plugins',
];
for (const baseUrl of baseUrls) {
expect(parseGitUrl(baseUrl)).toEqual(baseUrl);
for (const path of test.paths) {
const url = `${baseUrl}${path}`;
expect(parseGitUrl(url)).toEqual(baseUrl);
}
}
}
});
});
12 changes: 12 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import parse from 'git-url-parse';

/**
* Given a repository URL, figure out the base repository.
*/
export function parseGitUrl(url: string): String {
const parsed = parse(url);
// Although it seems to have a `host` property, it is not on the types, so we
// will have to reconstruct it.
const host = parsed.resource + (parsed.port ? `:${parsed.port}` : '');
return `${parsed.protocol}://${host}/${parsed.full_name}`;
}
4 changes: 2 additions & 2 deletions plugins/backstage-plugin-devcontainers-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ _Note: While this plugin can be used standalone, it has been designed to be a fr

### Standalone features

- Custom hooks for reading your special Dev Container metadata tag inside your repo entities, and providing ready-made links to opening that repo in VS Code
- Custom hooks for reading your special Dev Container metadata tag and VS Code launch URI inside your repo entities, and exposing that URI for opening the repo in VS Code

### When combined with the backend plugin

- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation, while letting you read them from custom hooks and components
- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation (including tags and the VS Code launch URI), while letting you read them from custom hooks and components

## Setup

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ExampleDevcontainersComponent = () => {
return (
<InfoCard title="Devcontainers plugin">
<p>
Searched component entity for tag:{' '}
Searched component entity for VS Code URL and tag:{' '}
<span className={styles.tagName}>{state.tagName}</span>
</p>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useDevcontainers } from './useDevcontainers';
import { type DevcontainersConfig, DevcontainersProvider } from '../plugin';
import { wrapInTestApp } from '@backstage/test-utils';
import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react';
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';

const mockTagName = 'devcontainers-test';
const mockUrlRoot = 'https://www.github.com/example-company/example-repo';
Expand All @@ -17,7 +16,7 @@ const baseEntity: BackstageEntity = {
name: 'metadata',
tags: [mockTagName, 'other', 'random', 'values'],
annotations: {
[ANNOTATION_SOURCE_LOCATION]: `${mockUrlRoot}/tree/main`,
vsCodeUrl: `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,
},
},
};
Expand Down Expand Up @@ -61,7 +60,7 @@ describe(`${useDevcontainers.name}`, () => {
expect(result2.current.vsCodeUrl).toBe(undefined);
});

it('Does not expose a link when the entity lacks a repo URL', async () => {
it('Does not expose a link when the entity lacks one', async () => {
const { result } = await render(mockTagName, {
...baseEntity,
metadata: {
Expand All @@ -73,7 +72,7 @@ describe(`${useDevcontainers.name}`, () => {
expect(result.current.vsCodeUrl).toBe(undefined);
});

it('Provides a VS Code-formatted link when the current entity has a designated devcontainers tag', async () => {
it('Exposes the link when the entity has both the tag and link', async () => {
const { result } = await render(mockTagName, baseEntity);
expect(result.current.vsCodeUrl).toEqual(
`vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useDevcontainersConfig } from '../components/DevcontainersProvider';
import { useEntity } from '@backstage/plugin-catalog-react';
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
import type { VsCodeUrlKey } from '@coder/backstage-plugin-devcontainers-backend';

// We avoid importing the actual constant to prevent making the backend plugin a
// run-time dependency, but we can use the type at compile-time to validate the
// string is the same.
const vsCodeUrlKey: VsCodeUrlKey = 'vsCodeUrl';

export type UseDevcontainersResult = Readonly<
{
Expand Down Expand Up @@ -38,8 +43,8 @@ export function useDevcontainers(): UseDevcontainersResult {
};
}

const repoUrl = entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION];
if (!repoUrl) {
const vsCodeUrl = entity.metadata.annotations?.[vsCodeUrlKey];
if (!vsCodeUrl) {
return {
tagName,
hasUrl: false,
Expand All @@ -50,20 +55,6 @@ export function useDevcontainers(): UseDevcontainersResult {
return {
tagName,
hasUrl: true,
vsCodeUrl: serializeVsCodeUrl(repoUrl),
vsCodeUrl,
};
}

/**
* Current implementation for generating the URL will likely need to change as
* we flesh out the backend plugin.
*
* It might make more sense to add the direct VSCode link to the entity data
* from the backend plugin via an annotation field, and remove the need for data
* cleaning here in this function
*/
function serializeVsCodeUrl(repoUrl: string): string {
const cleaners: readonly RegExp[] = [/^url: */, /\/tree\/main\/?$/];
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${cleanedUrl}`;
}
36 changes: 33 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8419,6 +8419,11 @@
"@types/qs" "*"
"@types/serve-static" "*"

"@types/git-url-parse@^9.0.3":
version "9.0.3"
resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.3.tgz#7ee022f8fa06ea74148aa28521cbff85915ac09d"
integrity sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==

"@types/graceful-fs@^4.1.3":
version "4.1.9"
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
Expand Down Expand Up @@ -21913,7 +21918,16 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"

"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -21987,7 +22001,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand All @@ -22001,6 +22015,13 @@ strip-ansi@5.2.0:
dependencies:
ansi-regex "^4.1.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -23809,7 +23830,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -23827,6 +23848,15 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down