Module 04 · The backend plugin · 45 min · You are: Platform and product
An extension point and a module
In this module
- You learn: How
createExtensionPointandcreateBackendModulework, why modules start before their plugin, and why the extension point lives in its own package. - You do: As the platform team, add the extension point. As team-checkout, write a module that adds a
provides-apicheck, test it, and see the score change. - You end with: Six checks, one owned by team-checkout in its own package.
checkoutscores 83. Merged.
A central plugin with every team's rules becomes a queue at the platform team's door. An extension point turns "please add our check" into "write a module". This is how the catalog, the scaffolder and auth are extended, so you will read this pattern in every Backstage repo.
Open this module in the interactive course to track progress.
Concepts
- Extension point
- An interface the plugin offers during
register, beforeinit. Extension points. - Module
createBackendModule({ pluginId, moduleId })extends one plugin. It may only use that plugin's extension points. Modules.- Start order
- All modules of a plugin finish
initbefore the plugin'sinitruns, so every check is added before the plugin reads the list. - The -node package
- Modules import the extension point from
readiness-node, never fromreadiness-backend, so they depend on a small, stable package.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/plugins/readiness-node/src/index.ts(reference, your repo): The extension point.- An interface and an id L12–19
portal/plugins/readiness-backend/src/plugin.ts(reference, your repo): Where the plugin offers it.- Registered in
register, beforeinitL25–31
- Registered in
portal/plugins/readiness-backend-module-api/src/module.ts(reference, your repo): team-checkout's module.
Steps
1. Platform: the extension point
A node library holds what backend modules import.
cd ~/work/portal && yarn new --select plugin-node-library --option pluginId=readiness
cd ~/work/portal && yarn --cwd plugins/readiness-node add @backstage/backend-plugin-api@^1.11.0 @internal/backstage-plugin-readiness-common@workspace:^
Replace the whole file ~/work/portal/plugins/readiness-node/src/index.ts
/**
* What backend modules use to extend the readiness plugin.
*
* @packageDocumentation
*/
import { createExtensionPoint } from '@backstage/backend-plugin-api';
import type { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
export type { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
/** Lets a backend module add checks to the readiness plugin. @public */
export interface ReadinessChecksExtensionPoint {
addCheck(...checks: ReadinessCheck[]): void;
}
/** @public */
export const readinessChecksExtensionPoint =
createExtensionPoint<ReadinessChecksExtensionPoint>({
id: 'readiness.checks',
});
2. Platform: offer it
Three changes: the imports, a list modules add to, and using that list.
cd ~/work/portal && yarn --cwd plugins/readiness-backend add @internal/backstage-plugin-readiness-node@workspace:^
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- import { catalogServiceRef } from '@backstage/plugin-catalog-node';
- import { defaultChecks }
+ import { catalogServiceRef } from '@backstage/plugin-catalog-node';
+ import { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
+ import { readinessChecksExtensionPoint } from '@internal/backstage-plugin-readiness-node';
+ import { defaultChecks }
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- register(env) {
- env.registerInit({
+ register(env) {
+ // Modules add checks here before init runs.
+ const checks: ReadinessCheck[] = [...defaultChecks];
+ env.registerExtensionPoint(readinessChecksExtensionPoint, {
+ addCheck(...added) {
+ checks.push(...added);
+ },
+ });
+
+ env.registerInit({
Change in ~/work/portal/plugins/readiness-backend/src/plugin.ts
- const active = defaultChecks.filter(
+ const active = checks.filter(
cd ~/work/portal && yarn tsc && yarn --cwd plugins/readiness-backend test --watchAll=false
3. team-checkout: write a module
team-checkout wants every service to say which APIs it provides. yarn new adds the module to the backend too.
cd ~/work/portal && yarn new --select backend-plugin-module --option pluginId=readiness --option moduleId=api --option pluginPackage=@internal/backstage-plugin-readiness-backend
cd ~/work/portal && yarn --cwd plugins/readiness-backend-module-api add @internal/backstage-plugin-readiness-node@workspace:^ @internal/backstage-plugin-readiness-common@workspace:^
yarn --cwd plugins/readiness-backend-module-api add -D @internal/backstage-plugin-readiness-backend@workspace:^ supertest@^7.3.1 @types/supertest@^7.2.1
Replace the whole file ~/work/portal/plugins/readiness-backend-module-api/src/module.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import {
ReadinessCheck,
readinessChecksExtensionPoint,
} from '@internal/backstage-plugin-readiness-node';
/** Owned by team-checkout: services must say which APIs they serve. */
export const providesApiCheck: ReadinessCheck = {
id: 'provides-api',
title: 'Services provide an API',
evaluate: entity => {
if (entity.spec?.type !== 'service') {
return { passed: true, message: 'Not a service.' };
}
const apis = entity.spec?.providesApis as string[] | undefined;
return apis?.length
? { passed: true }
: {
passed: false,
message: 'List the APIs it serves in spec.providesApis.',
};
},
};
export const readinessModuleApi = createBackendModule({
pluginId: 'readiness',
moduleId: 'api',
register(reg) {
reg.registerInit({
deps: { checks: readinessChecksExtensionPoint },
async init({ checks }) {
checks.addCheck(providesApiCheck);
},
});
},
});
The test starts the real plugin with the module and checks the new check is listed:
cp ~/work/backstage-workshop/portal/plugins/readiness-backend-module-api/src/module.test.ts ~/work/portal/plugins/readiness-backend-module-api/src/module.test.ts
yarn --cwd plugins/readiness-backend-module-api test --watchAll=false
4. See it work
Restart the portal (Ctrl+C, then start it again): there is a new package.
cd ~/work/portal && set -a && . ./.env && set +a
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'
Six checks, the last is provides-api. Score checkout again. Expect 83: five of six pass.
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
5. Merge it
cd ~/work/portal
Propose it as a pull request. Put it on its own branch and push it:
git switch -c readiness-extension-point && git add -A && git commit -qm "Readiness: checks extension point and the api module"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
Every package lives in one repo. In a big company, team-checkout's module could live in their own repo and be published to a private registry.
Break it on purpose: A typo in the plugin id
team-checkout copies their module to start a second one and slips on the plugin id.
You practise: why a module that extends nothing is not an error, and how to find it.
1. Break it
Change in ~/work/portal/plugins/readiness-backend-module-api/src/module.ts
One letter.
- pluginId: 'readiness',
+ pluginId: 'readyness',
Save, wait for the restart, and list the checks:
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'
2. What you see
Expected: six checks. You get: five; provides-api is gone. The backend starts, nothing is red, and checkout is back to 80.
3. Fix it yourself
Find out where the module went. You are done when /checks lists provides-api again.
Hint 1
Read the startup log line Plugin initialization started. Is there an id you do not recognise?
Hint 2
A module for a plugin that does not exist waits for it forever: look for still initializing.
The fix
Change in ~/work/portal/plugins/readiness-backend-module-api/src/module.ts
- pluginId: 'readyness',
+ pluginId: 'readiness',
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'
After you fixed it: the five questions
- What failed?
- The module attached to a plugin called
readyness, which does not exist, so its check was never added. - How was it detected?
- A missing check. The log lists
readynessas a plugin that is still initializing. - Who responds?
- team-checkout, who own the module.
- How is it corrected?
- Correct the plugin id.
- How is it prevented?
- The module's own test starts the real plugin with the module and expects the check in
/checks. Run it in CI.
Quiz
- Why is the extension point in
readiness-nodeand not inreadiness-backend?- So modules depend on a small package and never on the plugin's internals
- Because extension points only work in packages with the node-library role
- Because
readiness-backendcannot export anything but its plugin
- When does the module's
initrun?- After the plugin's
init, so it can read the plugin's checks - Before the plugin's
init, so its check is in the list in time - Only when the first request for
/api/readinessarrives
- After the plugin's
- Who reviews a change to
provides-api?- The platform team, because the check runs in their plugin
- Anyone, because checks are only data
- team-checkout, who own the module
Answers
- So modules depend on a small package and never on the plugin's internals A small, stable contract package keeps modules from breaking when the plugin changes inside.
- Before the plugin's
init, so its check is in the list in time Modules of a plugin start first; that is what makes extension points safe. - team-checkout, who own the module The module is the team's code, in the team's folder.
Field note
The first extension point I add to any internal plugin is the one I would otherwise get the most tickets about. It turns a backlog into a list of modules owned by the teams who care.