Skip to content

chore: add prototype/example code for adding dynamic entity tags #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 1, 2024
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
3 changes: 3 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
coder:
- ".github/workflows/build.yaml"
- "plugins/backstage-plugin-coder/**"
devcontainers-backend:
- ".github/workflows/build.yaml"
- "plugins/backstage-plugin-devcontainers-backend/**"
plugin:
needs: changes
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@backstage/plugin-search-backend-module-techdocs": "^0.1.13",
"@backstage/plugin-search-backend-node": "^1.2.13",
"@backstage/plugin-techdocs-backend": "^1.9.2",
"@coder/backstage-plugin-devcontainers-backend": "^0.1.0",
"app": "link:../app",
"better-sqlite3": "^9.0.0",
"dockerode": "^3.3.1",
Expand Down
12 changes: 10 additions & 2 deletions packages/backend/src/plugins/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type Router } from 'express';
import { type PluginEnvironment } from '../types';
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { ScaffolderEntitiesProcessor } from '@backstage/plugin-catalog-backend-module-scaffolder-entity-model';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import { GithubOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-github';
import { DevcontainersProcessor } from '@coder/backstage-plugin-devcontainers-backend';

export default async function createPlugin(
env: PluginEnvironment,
Expand All @@ -21,6 +22,13 @@ export default async function createPlugin(
);

builder.addProcessor(new ScaffolderEntitiesProcessor());
builder.addProcessor(
DevcontainersProcessor.fromConfig(env.config, {
logger: env.logger,
eraseTags: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just double check with Ben about the default here. I'll add it to our check-in notes.

}),
);
Comment on lines +25 to +30
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the plugin gets wired up to the deployment


const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
14 changes: 14 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# backstage-plugin-devcontainers

Welcome to the backstage-plugin-devcontainers backend plugin!

_This plugin was created through the Backstage CLI_

## Getting started

Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn
start` in the root directory, and then navigating to [/backstage-plugin-devcontainers](http://localhost:3000/backstage-plugin-devcontainers).

You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
47 changes: 47 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@coder/backstage-plugin-devcontainers-backend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "^0.20.1",
"@backstage/catalog-client": "^1.6.0",
"@backstage/catalog-model": "^1.4.4",
"@backstage/config": "^1.1.1",
"@backstage/plugin-catalog-node": "^1.7.2",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.25.1",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist"
]
}
128 changes: 128 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
Copy link
Member Author

@Parkreiner Parkreiner Feb 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main file. I'm sure the todo's aren't exhaustive, but I tried to cover as much ground as I could (though credit where it's due, a lot of them were added by Asher while we were doing the pair programming session)

* @todo Add nice backend logging for files (probably using Winston)
* @todo Figure out if monorepos affect the parsing logic at all
* @todo Verify that all URLs are correct and will always succeed
* @todo Test out globs, particularly for root/recursive searches
* @todo Determine how exactly we'll be detecting devcontainer.json files. Three
* likely options:
* - .devcontainer/devcontainer.json
* - .devcontainer.json
* - .devcontainer/<folder>/devcontainer.json
* (where <folder> is a sub-folder, one level deep)
*/
import { type CatalogProcessor } from '@backstage/plugin-catalog-node';
import { type Entity } from '@backstage/catalog-model';
import { type Config } from '@backstage/config';
import { type Logger } from 'winston';
import { type UrlReader, UrlReaders } from '@backstage/backend-common';
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';

const DEFAULT_TAG_NAME = 'devcontainers';

type ProcessorOptions = Readonly<{
tagName: string;
eraseTags: boolean;
}>;

type ProcessorSetupOptions = Readonly<
Partial<ProcessorOptions> & {
logger: Logger;
}
>;

export class DevcontainersProcessor implements CatalogProcessor {
Copy link
Member Author

@Parkreiner Parkreiner Feb 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CatalogProcessor interface has five methods on it:

  • getProcessorName (required)
  • readLocation (optional)
  • preProcessEntity(optional)
  • postProcessEntity(optional)
  • validateEntityKind(optional)

preProcessEntity is mainly for adding additional data right before the entity actually gets processed for real

We're also allowed to add any number of other methods/properties, but for safety, I'm keeping them all private

private readonly urlReader: UrlReader;
private readonly options: ProcessorOptions;

constructor(urlReader: UrlReader, options: ProcessorOptions) {
this.urlReader = urlReader;
this.options = options;
}

static fromConfig(readerConfig: Config, options: ProcessorSetupOptions) {
Comment on lines +37 to +42
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pattern that a lot of the plugins use, where they basically give you access to the raw constructor if you really need it, but you can also call the static fromConfig method to set things up with more sensible defaults

const processorOptions: ProcessorOptions = {
tagName: options.tagName || DEFAULT_TAG_NAME,
eraseTags: options.eraseTags ?? false,
};

const reader = UrlReaders.default({
config: readerConfig,
logger: options.logger,
});

return new DevcontainersProcessor(reader, processorOptions);
}

getProcessorName(): string {
// Very specific name to avoid name conflicts
return 'backstage-plugin-devcontainers-backend/devcontainers-processor';
}

async preProcessEntity(entity: Entity): Promise<Entity> {
if (entity.kind !== 'Component') {
return entity;
}

const cleanUrl = (
entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION] ?? ''
).replace(/^url:/, '');

const isGithubComponent = cleanUrl.includes('github.com');
if (!isGithubComponent) {
return this.eraseTag(entity, DEFAULT_TAG_NAME);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this part for?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, do we need to worry about other providers?

Copy link
Member Author

@Parkreiner Parkreiner Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's mainly a quick check to see if the component even looks vaguely right, before we start kicking off more computationally-intense async requests with the search API

We will need to worry about other providers eventually, but I think the details are going to depend on what Asher turns up with the URLReader, so I didn't want to add extra code for Gitlab/Bitbucket just yet

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Parkreiner why do we erase the tag if it's not a Github component?

Copy link
Member Author

@Parkreiner Parkreiner Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it's just a preemptive move. I guess I was erring on the side of kicking logic off to the eraseTags method whenever there isn't a match, and having it decide whether it should erase anything

It wouldn't be too bad to decentralize the logic, but doing it this way makes sure that the checks for whether an erase should happen all happen in one central method. The pre-process method doesn't need to know whether the tag actually gets erased – as long as it delegates to the other method, that method can choose what happens next

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooo, I like the centralization! I was more curious why we were erasing at all. I would assume that if it's not a GitHub component (or Bitbucket component, or GitLab component) we return the entity as-is.

}

const fullSearchPath = `${cleanUrl}.devcontainer/devcontainer.json`;
const tagDetected = await this.searchFiles(fullSearchPath);
if (tagDetected) {
return this.addTag(entity, DEFAULT_TAG_NAME);
}

return this.eraseTag(entity, DEFAULT_TAG_NAME);
}

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

return {
...entity,
metadata: {
...entity.metadata,
tags: [...(entity.metadata?.tags ?? []), newTag],
},
};
}

private eraseTag(entity: Entity, targetTag: string): Entity {
const skipTagErasure =
!this.options.eraseTags ||
!Array.isArray(entity.metadata.tags) ||
entity.metadata.tags.length === 0;

if (skipTagErasure) {
return entity;
}

return {
...entity,
metadata: {
...entity.metadata,
tags: entity.metadata.tags?.filter(tag => tag !== targetTag),
},
};
}

private async searchFiles(glob: string): Promise<boolean> {
const response = await this.urlReader.search(glob);

// Placeholder stub until we look into the URLReader more. File traversal
// via the API calls could be expensive; probably want to make sure that we
// do a few checks and early returns to ensure that we're only calling this
// method when necessary
return response.files.some(f => f.url.includes(glob));
}
}

export * from './service/router';
17 changes: 17 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/src/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';

const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();

startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});

process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';

import { createRouter } from './router';

describe('createRouter', () => {
let app: express.Express;

beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
});
app = express().use(router);
});

beforeEach(() => {
jest.resetAllMocks();
});

describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');

expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';

export interface RouterOptions {
logger: Logger;
}

export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger } = options;

const router = Router();
router.use(express.json());

router.get('/health', (_, response) => {
logger.info('PONG!');
response.json({ status: 'ok' });
});
router.use(errorHandler());
return router;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';

export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}

export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'backstage-plugin-devcontainers-backend' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
});

let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/backstage-plugin-devcontainers', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}

return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}

module.hot?.accept();
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
Loading