Cheat notes · Backstage plugins
Backstage plugins: cheat notes
Every module on one page, cut to what you need to remember: the idea, the commands, the pitfalls and a few questions to test yourself. Made for review after you have done the course, not instead of it. Back to the course.
00 Orientation: how a Backstage plugin is built
This module only · Full module
The idea
Plugin code gets a small set of building blocks with strict rules. In the backend: plugins, modules, services and extension points. In the frontend: plugins made of extensions, built from blueprints. Learn the blocks and every plugin, official ones included, looks the same.
Remember
- Backend plugin: one feature with its own API under
/api/<pluginId>and its own database. It declares the services it needs (logger, database, scheduler) and gets them injected. - Module and extension point: a module adds to one plugin without changing its code, through an extension point the plugin offers. The platform owns the plugin; other teams own modules.
- Frontend plugin: a list of extensions (a page, an API client, an entity card). Each is made from a blueprint and attaches to a place in the app.
- Package roles:
readiness-backend(the server),readiness(the UI),readiness-common(types and permissions both sides use),readiness-node(what backend modules import). - The readiness plugin: a production-readiness scorecard per component (has an owner, has docs, links a runbook, ...), on the entity page, extensible by teams.
Pitfalls
- Importing a backend package from the frontend. Shared types and permissions go in
-common, which both sides may import. - Putting every team's rule in the central plugin. Offer an extension point and let teams own modules.
Commands
docker start portal-db | Postgres from the first course |
set -a && . ./.env && set +a && yarn start | Start the portal with its environment |
Test yourself
Answer in your head first, then open the answer.
team-checkout needs one extra readiness check. Where does their code go?
A backend module that adds the check through the plugin's extension point.
Which package holds the types both frontend and backend use?
readiness-common.
How does a backend plugin get a database connection?
It declares the database service in its deps and gets one injected.
Where module 00 leaves you: Your portal runs on your laptop with Postgres, the catalog knows checkout, and the reference repo is up to date.
01 A backend plugin with an API
This module only · Full module
The idea
A backend plugin is an id plus registerInit: declare the services you need, then mount a router. Every route is authenticated by default, and calling the catalog with the caller's credentials means you never show what the caller could not see.
Remember
createBackendPlugin:pluginIdandregister(env)→env.registerInit({ deps, init }).initruns once at startup.httpRouter: mounts your Express router at/api/<pluginId>. The id alone decides the path.- Auth by default: every route needs credentials (a user, another plugin, a configured token).
httpAuth.credentials(req)says who called. - Calling the catalog:
catalogServiceRefis the catalog client as a service. Pass{ credentials }and the catalog applies its own permissions. - Errors: throw
NotFoundErrororInputErrorfrom@backstage/errors; the backend answers 404 or 400 with a JSON body. Validate bodies with Zod. - Generate, don't hand-roll:
yarn new --select backend-plugincreates the package and adds thebackend.addline;plugin-common-librarymakes-common.
Pitfalls
- Losing the
backend.addline in a merge conflict: the backend starts without errors and your API answers404with an empty body. Smoke-test one route per plugin after deploy: 200 or 401, never 404. - Reading the catalog with the plugin's own credentials in a user request: you could leak entities the user may not see.
Commands
yarn new --select plugin-common-library --option pluginId=readiness | The shared types package |
yarn new --select backend-plugin --option pluginId=readiness | The backend plugin, wired into the backend |
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | Call your API as a script |
yarn tsc | Type-check everything |
A backend plugin
export const readinessPlugin = createBackendPlugin({
pluginId: 'readiness', // -> /api/readiness
register(env) {
env.registerInit({
deps: {
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
catalog: catalogServiceRef,
},
async init({ httpAuth, httpRouter, catalog }) {
httpRouter.use(await createRouter({ httpAuth, catalog, checks: defaultChecks }));
},
});
},
});
Test yourself
Answer in your head first, then open the answer.
Why does /evaluate pass the caller's credentials to the catalog?
So the catalog applies its own permissions to what the caller may see.
Where does httpRouter mount the readiness routes?
Under /api/readiness, from the plugin id.
A route throws new NotFoundError(...). What does the caller get?
A 404 with a JSON body that carries the message.
Where module 01 leaves you: The backend serves /api/readiness. It lists five checks and scores checkout at 80 (it has no runbook link). Merged in your portal repo.
02 Database, config and a schedule
This module only · Full module
The idea
A stored score with a time on it is something teams can trust. Every plugin that stores data or runs jobs uses the same three services: its own database with Knex migrations, a config schema, and a scheduler that runs a job once across replicas.
Remember
- Database service: each plugin gets its own database (
backstage_plugin_<id>) and a Knex client. Ship migrations in the package (migrations/, run once each in name order) and listmigrationsinfiles. - Config schema: a
config.d.tsnamed inpackage.jsonunderconfigSchemadocuments your keys;config:checkand the backend validate against it. - Scheduler:
scheduler.scheduleTask({ id, frequency, timeout, initialDelay, fn }). With several replicas, a database lock makes only one run it. - Acting as the plugin: a scheduled job has no user.
auth.getOwnServiceCredentials()gives the plugin its own credentials to call the catalog. - Read config with
config.getOptionalStringArray(...), and schedules withreadSchedulerServiceTaskScheduleDefinitionFromConfig.
Pitfalls
- A string where a list belongs (
disabledChecks: has-runbook): the plugin fails at startup withgot string, wanted string-arrayand the API answers503.config:check --strictin CI catches it, but only for keys you described inconfig.d.ts. - No
config.d.ts: typos in config are silently ignored and nobody knows which keys exist.
Commands
yarn backstage-cli config:check --strict | Validate config against every schema |
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/scorecards | Stored scorecards |
docker exec portal-db psql -U postgres -d backstage_plugin_readiness -c 'select entity_ref, score from readiness_scorecards' | The rows in the plugin's own database |
Config and its schema
# app-config.yaml
readiness:
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 5 }
initialDelay: { seconds: 15 }
disabledChecks: []
// config.d.ts
export interface Config {
readiness?: {
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
disabledChecks?: string[];
};
}
Test yourself
Answer in your head first, then open the answer.
Why does the readiness backend get its own database?
Each plugin owns its tables and migrations, so plugins cannot break each other's data.
Two replicas run the same scheduled task. How often does it run?
Once per interval: a lock in the database picks one replica.
Whose credentials does the scheduled job use to read the catalog?
The plugin's own service credentials.
Where module 02 leaves you: Every 30 minutes the backend scores every component and stores the result in backstage_plugin_readiness. Settings live under readiness: in app-config.yaml. Merged.
03 Test the backend plugin
This module only · Full module
The idea
A plugin is wiring, so test it the way callers use it: start the real plugin with startTestBackend, fake only what you must, and send HTTP requests with supertest.
Remember
startTestBackend({ features }): starts a backend with only the features you list. Core services you do not give are mocks: an in-memory database, a logger, auth that lets requests through.mockServices: replace one service with your behaviour, e.g.mockServices.permissions.factory({ result: AuthorizeResult.DENY }).catalogServiceMock.factory({ entities }): a catalog with only the entities you give it (from@backstage/plugin-catalog-node/testUtils).- supertest:
request(server).post('/api/readiness/evaluate').send({...}).expect(404). - Isolation: each test starts its own backend, so tests never share data.
Pitfalls
- Removing the scheduler's
initialDelay: the job runs during startup, and the test that expects no scorecard yet gets200instead of404. Startup is busy; background work can wait. - Editing a failing test to pass before asking whether the behaviour was meant to change.
Commands
cd plugins/readiness-backend && yarn test --watchAll=false | Run the plugin's tests once |
A test over HTTP
const { server } = await startTestBackend({
features: [
readinessPlugin,
catalogServiceMock.factory({ entities: [checkout] }),
],
});
const res = await request(server)
.post('/api/readiness/evaluate')
.send({ entityRef: 'component:default/checkout' });
expect(res.status).toBe(200);
Test yourself
Answer in your head first, then open the answer.
What does startTestBackend give you that a unit test on scoreEntity does not?
The real plugin wiring: services, routes, database and schedule.
How do you test the plugin with an empty catalog?
Add catalogServiceMock.factory({ entities: [] }) to the features.
A test fails after a change. What do you check first?
Whether the behaviour the test describes was meant to change.
Where module 03 leaves you: Two passing tests in readiness-backend that start the plugin with an in-memory database and a fake catalog. Merged.
04 An extension point and a module
This module only · Full module
The idea
An extension point turns "please add our check" into "write a module". The plugin offers it during register; modules use it in their init, which always runs before the plugin's init.
Remember
createExtensionPoint<T>({ id }): defined in the-nodepackage (readiness-node), so modules depend on a small, stable contract, never on the plugin's internals.- Offering it: in the plugin's
register,env.registerExtensionPoint(ref, { addCheck(...checks) { list.push(...checks) } }), beforeregisterInit. createBackendModule({ pluginId, moduleId }): extends one plugin and may only use that plugin's extension points.- Start order: all modules of a plugin finish
initbefore the plugin'sinitruns, so every check is in the list in time. - Ownership: the module is team-checkout's code in its own package and folder; they review it.
- Generate it:
yarn new --select backend-plugin-module --option pluginId=readiness --option moduleId=api.
Pitfalls
- A typo in
pluginId(readyness): the backend starts, nothing is red, and the check silently disappears. The module's own test starts the real plugin with the module and expects the check in/checks. - Importing the extension point from
readiness-backend: modules then break whenever the plugin changes inside.
Commands
yarn new --select plugin-node-library --option pluginId=readiness | The -node package for the extension point |
yarn new --select backend-plugin-module --option pluginId=readiness --option moduleId=api --option pluginPackage=@internal/backstage-plugin-readiness-backend | A module for the plugin, wired into the backend |
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id' | Is the module's check listed? |
A module using the extension point
export const readinessModuleApi = createBackendModule({
pluginId: 'readiness', // must match exactly
moduleId: 'api',
register(reg) {
reg.registerInit({
deps: { checks: readinessChecksExtensionPoint },
async init({ checks }) {
checks.addCheck(providesApiCheck);
},
});
},
});
Test yourself
Answer in your head first, then open the answer.
Why is the extension point in readiness-node and not readiness-backend?
So modules depend on a small package and never on the plugin's internals.
When does the module's init run?
Before the plugin's init, so its check is in the list in time.
Who reviews a change to provides-api?
team-checkout, who own the module.
Where module 04 leaves you: Six checks, one owned by team-checkout in its own package. checkout scores 83. Merged.
05 A frontend plugin in the new frontend system
This module only · Full module
The idea
The new frontend system builds the whole app from extensions with ids. You add a page by installing a package, not by editing App.tsx, and you can configure, move or disable any extension by its id in app-config.yaml.
Remember
- Extension: one piece of the app with an id (
page:readiness), a place it attaches to, and outputs such as a React element. - Blueprint: a factory for one kind of extension.
PageBlueprint.make({ params: { path, routeRef, title, loader } })makes a page; with atitleit also gets a sidebar item. createFrontendPlugin({ pluginId, extensions, routes }): groups extensions. The app installs every plugin package inpackages/app/package.json(app.packages: all).- Config by id:
app.extensionscan set config (- page:readiness: { config: { path: /scorecards } }) or disable an extension, without code. - The Visualizer (bottom of the sidebar, tab Text) shows every extension and where it attaches.
Pitfalls
- Configuring an id that does not exist (
page:readines): no error on the page, nothing moves. The browser console saysINVALID_EXTENSION_CONFIG_KEY. Copy ids from the Visualizer. - Forgetting to restart after adding a package: the app only picks up new packages on start.
Commands
yarn new --select frontend-plugin --option pluginId=readiness | Generate the plugin; it is added to packages/app/package.json |
grep -n readiness packages/app/package.json | Installed means listed here |
A page, and moving it by config
export const readinessPage = PageBlueprint.make({
params: {
path: '/readiness',
routeRef: rootRouteRef,
title: 'Readiness', // also a sidebar item
loader: () => import('./components/ReadinessPage').then(m => <m.ReadinessPage />),
},
});
# app-config.yaml
app:
extensions:
- page:readiness:
config:
path: /scorecards
Test yourself
Answer in your head first, then open the answer.
How did the app learn about your plugin?
yarn new added the package to packages/app/package.json, and the app installs every plugin package listed there.
What makes Readiness appear in the sidebar?
The title on the page made with PageBlueprint.
How do you change the path of someone else's page without forking it?
Set config.path for its extension id under app.extensions.
Where module 05 leaves you: A Readiness item in the sidebar opens /readiness, served by your own frontend plugin. Merged.
06 Talk to the backend: a utility API
This module only · Full module
The idea
Components should not build URLs or attach tokens. A utility API hides that behind an interface: createApiRef names it, ApiBlueprint provides it, and discoveryApi plus fetchApi call your backend as the signed-in user.
Remember
- API ref:
createApiRef<ReadinessApi>({ id: 'plugin.readiness' }). Components ask withuseApi(readinessApiRef). ApiBlueprint: an extension that provides an implementation from other APIs (deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }). It must be in the plugin'sextensions.discoveryApi.getBaseUrl('readiness'): the plugin's base URL frombackend.baseUrl, so the same code works locally and in production.fetchApi.fetch:fetchwith the user's Backstage token added, so the backend sees the user and permissions apply.- Errors:
throw await ResponseError.fromResponse(response); treat a 404 as a normal "not evaluated yet" state (undefined). - Testing: provide a fake
ReadinessApifor the ref; components depend on the interface.
Pitfalls
- Dropping the API extension from
extensionsin a refactor: TypeScript is happy, the page showsNotImplementedError: No implementation available for apiRef{plugin.readiness}. Checkapi:readinessis in the Visualizer. - Using the browser's
fetch: no token, so every call is a 401.
Provide the client
export const readinessApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
api: readinessApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new ReadinessClient(discoveryApi, fetchApi),
}),
});
extensions: [readinessApi, readinessPage],
Test yourself
Answer in your head first, then open the answer.
Why does the client use fetchApi instead of fetch?
It adds the signed-in user's token, so the backend knows who calls.
How does the client know where the readiness backend is?
discoveryApi.getBaseUrl('readiness'), from the app's config.
How do you test the page without a backend?
Provide a fake ReadinessApi for readinessApiRef in the test.
Where module 06 leaves you: /readiness lists every scorecard, lowest score first, fetched with your sign-in token. Merged.
07 On the entity page: a card and a tab
This module only · Full module
The idea
Put the score where teams already look: their component page. The catalog plugin offers places for cards and tabs; EntityCardBlueprint and EntityContentBlueprint fill them, useEntity gives you the entity, and a config schema makes the card configurable by id.
Remember
- Blueprints from
@backstage/plugin-catalog-react/alpha:EntityCardBlueprintadds a card to the overview;EntityContentBlueprintadds a tab (path,title). filter: { kind: 'component' }shows them only where the backend has scorecards.useEntity()returns the entity the page shows.- Extension config:
makeWithOverrides({ name: 'scorecard', configSchema: { passScore: z.number().default(75) }, factory })gives the card the identity-card:readiness/scorecard, configurable underapp.extensions. - Loading states:
useAsyncfrom react-use withProgress,ResponseErrorPanel, and "Not evaluated yet." forundefined.
Pitfalls
- Deleting the filter to show the card on systems too: it appears on groups and systems saying
Not evaluated yet.forever. Widen the filter and make the backend evaluate those kinds as well. - Filling the overview with cards. Give every card a filter and a reason; the rest goes to a tab.
Commands
yarn --cwd plugins/readiness test --watchAll=false | Render the card with a fake API: no backend needed |
Configure the card by its id
app:
extensions:
- entity-card:readiness/scorecard:
config:
passScore: 80
Test yourself
Answer in your head first, then open the answer.
Where does the card get the entity it shows?
useEntity(), from the entity page it is rendered on.
How does the platform team set the pass score to 80?
Config for entity-card:readiness/scorecard under app.extensions.
The card should show on APIs too. What do you change?
Widen the filter, and make the backend evaluate APIs as well.
Where module 07 leaves you: Component pages show a Production readiness card and a Readiness tab with Evaluate now. The pass score is 80, from config. Merged.
08 Permissions: who may evaluate
This module only · Full module
The idea
Define permissions in -common, check them in the backend before acting, and let one policy decide for the whole portal. The frontend check only hides buttons; the backend check is the guard.
Remember
- Define:
createPermission({ name: 'readiness.evaluate', attributes: { action: 'update' } })in-common, so the backend checks it and the frontend can ask about it. - Register and enforce:
permissionsRegistry.addPermissions(...), thenpermissions.authorize([{ permission }], { credentials })returns ALLOW or DENY; throwNotAllowedError(403) on DENY. - Policy: one class with
handle(request, user)decides for every plugin, set by a module on the permission plugin throughpolicyExtensionPoint.setPolicy(...). It replaces the allow-all policy. - Groups come from sign-in: the policy reads
user.info.ownershipEntityRefs(e.g.group:default/platform). After changing membership, sign out and in. usePermission({ permission })disables the button for people the policy would refuse: a convenience, not a guard.- Service principals (plugins, static tokens) are not users: service calls are allowed, so scripts still work.
Pitfalls
- Dropping the backend check because the button is disabled anyway: the UI looks the same, and anyone with curl can evaluate. Ask in every review: "what happens if someone calls this with curl?"
- Testing the policy only by clicking. Write one test per permission with
mockServices.permissions.factory({ result: AuthorizeResult.DENY }).
Commands
yarn new --select permission-policy-module --option moduleId=acme | A module that sets your policy |
yarn --cwd packages/backend remove @backstage/plugin-permission-backend-module-allow-all-policy | Drop allow-all |
yarn --cwd plugins/readiness-backend test --watchAll=false | Including the test that expects 403 |
A policy
export class AcmePolicy implements PermissionPolicy {
async handle(request: PolicyQuery, user?: PolicyQueryUser): Promise<PolicyDecision> {
if (isPermission(request.permission, readinessEvaluatePermission)) {
const refs = user?.info.ownershipEntityRefs ?? [];
return {
result: refs.includes('group:default/platform')
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
};
}
return { result: AuthorizeResult.ALLOW };
}
}
Test yourself
Answer in your head first, then open the answer.
Why do the permissions live in readiness-common?
The backend checks them and the frontend asks about them, so both import them.
The button is disabled for you. Can you still evaluate?
Only if the backend does not check. The button is a hint; the backend decides.
How many permission policies does a backend have?
One, set by a module on the permission plugin, deciding for every plugin.
Where module 08 leaves you: Only members of platform may evaluate on demand; everyone may read scorecards. A test proves the backend says 403. Merged.
09 Capstone: team-checkout's ownership check
This module only · Full module
The idea
A plugin is done when other teams can extend it without asking you. team-checkout ships an owner-is-team check as its own module, links a runbook, and the platform proves every test is green.
Remember
- team-checkout's module:
readiness-backend-module-ownership, generated withyarn new, addsowner-is-teamthrough the extension point, with its own test. - Background-safe: the check calls the catalog with the plugin's own credentials, because it also runs in the scheduled job, where there is no user.
- Get to 100: add the runbook to
metadata.linksin checkout'scatalog-info.yaml, refresh the entity, evaluate. - Prove it:
yarn tscand every plugin's tests; seven checks,checkoutat 100%.
Pitfalls
- Calling the catalog with the request's credentials inside a check: it fails in the scheduled job, which has no user.
- Changing the plugin to add a team's check. If the team needed you, the extension point is not doing its job.
Commands
for p in readiness readiness-backend readiness-backend-module-api readiness-backend-module-ownership permission-backend-module-acme; do yarn --cwd plugins/$p test --watchAll=false || break; done | Run every plugin's tests |
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/readiness/evaluate -d '{"entityRef":"component:default/checkout"}' | jq .score | Score checkout now |
Test yourself
Answer in your head first, then open the answer.
What did team-checkout need from the platform team to add owner-is-team?
Nothing: the extension point, yarn new and a review of their own folder.
Why does owner-is-team call the catalog with the plugin's own credentials?
It runs in the scheduled job too, where there is no user.
What would you build next for this plugin?
Notifications to the owning team when a score drops below the pass score.
Where module 09 leaves you: Seven checks, checkout at 100%, every test green, all merged.