PlatformEngr® Backstage plugins course

Module 04 · The backend plugin · 45 min · You are: Platform and product

An extension point and a module

In this module

  • You learn: How createExtensionPoint and createBackendModule work, 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-api check, test it, and see the score change.
  • You end with: Six checks, one owned by team-checkout in its own package. checkout scores 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, before init. 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 init before the plugin's init runs, so every check is added before the plugin reads the list.
The -node package
Modules import the extension point from readiness-node, never from readiness-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.

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 readyness as 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

  1. Why is the extension point in readiness-node and not in readiness-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-backend cannot export anything but its plugin
  2. When does the module's init run?
    • 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/readiness arrives
  3. 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
  1. 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.
  2. 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.
  3. 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.

Report a problem with this module