PlatformEngr® Backstage plugins course

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

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-dbPostgres from the first course
set -a && . ./.env && set +a && yarn startStart 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

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: pluginId and register(env) → env.registerInit({ deps, init }). init runs 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: catalogServiceRef is the catalog client as a service. Pass { credentials } and the catalog applies its own permissions.
  • Errors: throw NotFoundError or InputError from @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-plugin creates the package and adds the backend.add line; plugin-common-library makes -common.

Pitfalls

  • Losing the backend.add line in a merge conflict: the backend starts without errors and your API answers 404 with 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=readinessThe shared types package
yarn new --select backend-plugin --option pluginId=readinessThe backend plugin, wired into the backend
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checksCall your API as a script
yarn tscType-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

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 list migrations in files.
  • Config schema: a config.d.ts named in package.json under configSchema documents your keys; config:check and 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 with readSchedulerServiceTaskScheduleDefinitionFromConfig.

Pitfalls

  • A string where a list belongs (disabledChecks: has-runbook): the plugin fails at startup with got string, wanted string-array and the API answers 503. config:check --strict in CI catches it, but only for keys you described in config.d.ts.
  • No config.d.ts: typos in config are silently ignored and nobody knows which keys exist.

Commands

yarn backstage-cli config:check --strictValidate config against every schema
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/scorecardsStored 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

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 gets 200 instead of 404. 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=falseRun 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

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 -node package (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) } }), before registerInit.
  • createBackendModule({ pluginId, moduleId }): extends one plugin and may only use that plugin's extension points.
  • Start order: all modules of a plugin finish init before the plugin's init runs, 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=readinessThe -node package for the extension point
yarn new --select backend-plugin-module --option pluginId=readiness --option moduleId=api --option pluginPackage=@internal/backstage-plugin-readiness-backendA 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

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 a title it also gets a sidebar item.
  • createFrontendPlugin({ pluginId, extensions, routes }): groups extensions. The app installs every plugin package in packages/app/package.json (app.packages: all).
  • Config by id: app.extensions can 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 says INVALID_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=readinessGenerate the plugin; it is added to packages/app/package.json
grep -n readiness packages/app/package.jsonInstalled 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

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 with useApi(readinessApiRef).
  • ApiBlueprint: an extension that provides an implementation from other APIs (deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }). It must be in the plugin's extensions.
  • discoveryApi.getBaseUrl('readiness'): the plugin's base URL from backend.baseUrl, so the same code works locally and in production.
  • fetchApi.fetch: fetch with 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 ReadinessApi for the ref; components depend on the interface.

Pitfalls

  • Dropping the API extension from extensions in a refactor: TypeScript is happy, the page shows NotImplementedError: No implementation available for apiRef{plugin.readiness}. Check api:readiness is 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

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: EntityCardBlueprint adds a card to the overview; EntityContentBlueprint adds 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 id entity-card:readiness/scorecard, configurable under app.extensions.
  • Loading states: useAsync from react-use with Progress, ResponseErrorPanel, and "Not evaluated yet." for undefined.

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=falseRender 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

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(...), then permissions.authorize([{ permission }], { credentials }) returns ALLOW or DENY; throw NotAllowedError (403) on DENY.
  • Policy: one class with handle(request, user) decides for every plugin, set by a module on the permission plugin through policyExtensionPoint.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=acmeA module that sets your policy
yarn --cwd packages/backend remove @backstage/plugin-permission-backend-module-allow-all-policyDrop allow-all
yarn --cwd plugins/readiness-backend test --watchAll=falseIncluding 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

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 with yarn new, adds owner-is-team through 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.links in checkout's catalog-info.yaml, refresh the entity, evaluate.
  • Prove it: yarn tsc and every plugin's tests; seven checks, checkout at 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; doneRun 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 .scoreScore 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.