-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from all commits
cccce6b
74e5313
6d2b237
0a12817
bbc6829
15662f4
d5c78ee
1b49ddb
e3d3a20
3ebc040
e08f612
b561719
8ca73ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
|
@@ -21,6 +22,13 @@ export default async function createPlugin( | |
); | ||
|
||
builder.addProcessor(new ScaffolderEntitiesProcessor()); | ||
builder.addProcessor( | ||
DevcontainersProcessor.fromConfig(env.config, { | ||
logger: env.logger, | ||
eraseTags: false, | ||
}), | ||
); | ||
Comment on lines
+25
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); |
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. |
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" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
/** | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this part for? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, do we need to worry about other providers? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; |
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 {}; |
There was a problem hiding this comment.
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.