PlatformEngr® Backstage plugins course

Module 02 · The backend plugin · 60 min · You are: Platform team

Database, config and a schedule

In this module

  • You learn: How the database service and Knex migrations work, how a config schema documents and checks your settings, and how the scheduler runs a job once across replicas.
  • You do: Write a migration, copy the store and the evaluator, add a config schema and config, then watch the schedule fill the database.
  • You end with: 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.

Scoring on every page view is slow and gives different answers as the catalog changes. A stored score with a time on it is something teams can trust and compare. Every plugin that stores data or runs jobs uses the same three services, so you learn them once.

Open this module in the interactive course to track progress.

Concepts

Database service
Each plugin gets its own database (backstage_plugin_<id>) and a Knex client. You ship migrations in the package and run them at startup. Database service.
Config schema
A config.d.ts in the package, named in package.json under configSchema, describes your keys. config:check and the backend validate against it. Defining config.
Scheduler
scheduler.scheduleTask runs a function on a frequency. With several backend replicas, a lock in the database makes only one run it. Scheduler service.
Acting as the plugin
A scheduled job has no user. auth.getOwnServiceCredentials() gives the plugin its own credentials to call the catalog.

Files to know

Open them in your editor or on GitHub; the bullets say what to look at.

Steps

1. Write the migration

Knex runs every file in migrations/ once, in name order, and records it in its own table.

cd ~/work/portal && yarn --cwd plugins/readiness-backend add knex@^3.3.0

Create ~/work/portal/plugins/readiness-backend/migrations/20261105120000_init.js

// @ts-check

/** @param {import('knex').Knex} knex */
exports.up = async function up(knex) {
  await knex.schema.createTable('readiness_scorecards', table => {
    table
      .string('entity_ref')
      .primary()
      .comment('For example component:default/checkout');
    table
      .integer('score')
      .notNullable()
      .comment('Share of checks passed, 0 to 100');
    table.text('checks').notNullable().comment('The check results, as JSON');
    table.timestamp('evaluated_at', { useTz: true }).notNullable();
  });
};

/** @param {import('knex').Knex} knex */
exports.down = async function down(knex) {
  await knex.schema.dropTable('readiness_scorecards');
};

2. Copy the store and the evaluator

Plain classes: read them in Quick look above. The store saves one row per entity; the evaluator reads the catalog and calls the store.

cp ~/work/backstage-workshop/portal/plugins/readiness-backend/src/ReadinessStore.ts ~/work/portal/plugins/readiness-backend/src/ReadinessStore.ts && cp ~/work/backstage-workshop/portal/plugins/readiness-backend/src/ReadinessEvaluator.ts ~/work/portal/plugins/readiness-backend/src/

3. Describe the config

The schema documents your keys and lets config:check catch mistakes before the backend starts.

Create ~/work/portal/plugins/readiness-backend/config.d.ts

import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api';

export interface Config {
  /** Settings for the readiness backend plugin. Owned by the platform team. */
  readiness?: {
    /** How often every component is evaluated. Default: every 30 minutes. */
    schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
    /** Check ids to switch off, for example has-runbook. */
    disabledChecks?: string[];
  };
}

Change in ~/work/portal/plugins/readiness-backend/package.json
Ship the migrations and the schema with the package.

-   "files": [
-     "dist"
-   ]
+   "files": [
+     "dist",
+     "migrations",
+     "config.d.ts"
+   ],
+   "configSchema": "config.d.ts"

Add to ~/work/portal/app-config.yaml before the comment # see https://backstage.io/docs/permissions/getting-started ...

# Production-readiness scorecards (plugin course, module 02). Owned by the platform team.
readiness:
  schedule:
    frequency: { minutes: 30 }
    timeout: { minutes: 5 }
    initialDelay: { seconds: 15 }
  disabledChecks: []

4. Use the store and the schedule

The router now reads stored scorecards; POST /evaluate scores one entity now and stores it.

Replace the whole file ~/work/portal/plugins/readiness-backend/src/router.ts

import { HttpAuthService } from '@backstage/backend-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { InputError, NotFoundError } from '@backstage/errors';
import { ReadinessCheck } from '@internal/backstage-plugin-readiness-common';
import express from 'express';
import Router from 'express-promise-router';
import { z } from 'zod/v3';
import { ReadinessEvaluator } from './ReadinessEvaluator';
import { ReadinessStore } from './ReadinessStore';

export async function createRouter(options: {
  httpAuth: HttpAuthService;
  store: ReadinessStore;
  evaluator: ReadinessEvaluator;
  checks: ReadinessCheck[];
}): Promise<express.Router> {
  const { httpAuth, store, evaluator, checks } = options;
  const router = Router();
  router.use(express.json());

  router.get('/checks', async (_req, res) => {
    res.json({ items: checks.map(({ id, title }) => ({ id, title })) });
  });

  router.get('/scorecards', async (_req, res) => {
    res.json({ items: await store.list() });
  });

  router.get('/scorecards/:kind/:namespace/:name', async (req, res) => {
    const entityRef = stringifyEntityRef(req.params);
    const card = await store.get(entityRef);
    if (!card) {
      throw new NotFoundError(`No scorecard for ${entityRef} yet`);
    }
    res.json(card);
  });

  const evaluateSchema = z.object({ entityRef: z.string() });

  router.post('/evaluate', async (req, res) => {
    const credentials = await httpAuth.credentials(req);
    const parsed = evaluateSchema.safeParse(req.body);
    if (!parsed.success) {
      throw new InputError(parsed.error.toString());
    }
    res.json(
      await evaluator.evaluateEntity(parsed.data.entityRef, credentials),
    );
  });

  return router;
}

Replace the whole file ~/work/portal/plugins/readiness-backend/src/plugin.ts

import {
  coreServices,
  createBackendPlugin,
  readSchedulerServiceTaskScheduleDefinitionFromConfig,
} from '@backstage/backend-plugin-api';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { defaultChecks } from './checks';
import { ReadinessEvaluator } from './ReadinessEvaluator';
import { ReadinessStore } from './ReadinessStore';
import { createRouter } from './router';

/**
 * Production-readiness scorecards for catalog components.
 *
 * @public
 */
export const readinessPlugin = createBackendPlugin({
  pluginId: 'readiness',
  register(env) {
    env.registerInit({
      deps: {
        auth: coreServices.auth,
        config: coreServices.rootConfig,
        database: coreServices.database,
        httpAuth: coreServices.httpAuth,
        httpRouter: coreServices.httpRouter,
        logger: coreServices.logger,
        scheduler: coreServices.scheduler,
        catalog: catalogServiceRef,
      },
      async init(deps) {
        const { auth, config, database, httpAuth, httpRouter, logger } = deps;
        const { scheduler, catalog } = deps;

        const disabled =
          config.getOptionalStringArray('readiness.disabledChecks') ?? [];
        const active = defaultChecks.filter(c => !disabled.includes(c.id));

        const store = await ReadinessStore.create(database);
        const evaluator = new ReadinessEvaluator({
          checks: active,
          catalog,
          store,
          auth,
          logger,
        });

        const schedule = config.has('readiness.schedule')
          ? readSchedulerServiceTaskScheduleDefinitionFromConfig(
              config.getConfig('readiness.schedule'),
            )
          : {
              frequency: { minutes: 30 },
              timeout: { minutes: 5 },
              initialDelay: { seconds: 15 },
            };
        await scheduler.scheduleTask({
          id: 'readiness-evaluate-all',
          ...schedule,
          fn: () => evaluator.evaluateAll(),
        });

        httpRouter.use(
          await createRouter({
            httpAuth,
            store,
            evaluator,
            checks: active,
          }),
        );
      },
    });
  },
});
cd ~/work/portal && yarn tsc && yarn backstage-cli config:check --strict 2>&1 | grep readiness

The last command prints nothing: no config error mentions readiness.

5. Watch the schedule

Save, and watch the portal terminal. You see Registered scheduled task: readiness-evaluate-all, then about 15 seconds later Evaluated N components with 5 checks.

Every scorecard, lowest score first:

cd ~/work/portal && set -a && . ./.env && set +a
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/scorecards | jq -r '.items[] | "\(.score) \(.entityRef)"'

One scorecard. Expect 80:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/scorecards/component/default/checkout | jq .score

The rows in the plugin's own database:

docker exec portal-db psql -U postgres -d backstage_plugin_readiness -c 'select entity_ref, score, evaluated_at from readiness_scorecards'

6. Merge it

cd ~/work/portal

Propose it as a pull request. Put it on its own branch and push it:

git switch -c readiness-store && git add -A && git commit -qm "Readiness: store scorecards and evaluate on a schedule"
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

Checks are stored as one JSON text column. A plugin that queries single checks would give them their own table.

Break it on purpose: A string where a list belongs

Nobody has a runbook yet, so the platform team switches that check off. In a hurry they write the id as a plain string.

You practise: reading a startup failure, and catching config mistakes before deploy.

1. Break it

Change in ~/work/portal/app-config.yaml
The quick fix.

-   disabledChecks: []
+   disabledChecks: has-runbook

Save. The backend restarts. Call the API while it starts, and again after:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks

2. What you see

Expected: four checks. You get: first 503 with Service has not started up yet, then the log shows Plugin 'readiness' startup failed; caused by TypeError: Invalid type in config for key 'readiness.disabledChecks' in 'app-config.yaml', got string, wanted string-array.

3. Fix it yourself

Find a command that catches this before the backend starts, then fix the config. You are done when /checks lists four checks without has-runbook.

Hint 1

yarn backstage-cli config:check validates config against every schema. Try it with and without --strict.

Hint 2

YAML lists are [a, b] or one - item per line.

The fix
cd ~/work/portal && yarn backstage-cli config:check --strict 2>&1 | grep readiness

It prints Config must be array { type=array } at /readiness/disabledChecks. Without --strict it says nothing.

Change in ~/work/portal/app-config.yaml

-   disabledChecks: has-runbook
+   disabledChecks: [has-runbook]
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/readiness/checks | jq -r '.items[].id'

Then set it back to disabledChecks: []: team-checkout adds a runbook in the capstone.

After you fixed it: the five questions
What failed?
The config had a string where the schema wants a list, so the plugin failed at startup and the whole backend answered 503.
How was it detected?
The startup log, and every API call failing. config:check --strict would have caught it first.
Who responds?
The platform team, who own app-config.yaml.
How is it corrected?
Write the value as a list.
How is it prevented?
CI runs yarn backstage-cli config:check --strict on every pull request. It only checks keys you described in config.d.ts, so write the schema.

Quiz

  1. Why does the readiness backend get its own database?
    • Each plugin owns its tables and migrations, so plugins cannot break each other's data
    • Postgres needs one database per table to keep writes fast
    • Backstage needs it to tell plugins apart in the logs
  2. Two backend replicas run the same scheduled task. How often does it run?
    • Twice per interval, once on each replica
    • Once per interval: a lock in the database picks one replica
    • Never, until you mark one replica as the leader in config
  3. Whose credentials does the scheduled job use to read the catalog?
    • The last user who opened the readiness page
    • The static token from .env
    • The plugin's own service credentials
Answers
  1. Each plugin owns its tables and migrations, so plugins cannot break each other's data One database per plugin keeps data and migrations owned by one team.
  2. Once per interval: a lock in the database picks one replica The scheduler coordinates through the database.
  3. The plugin's own service credentials Background work has no user; the plugin acts as itself.

Field note

Every plugin I have seen in production regret a missing config.d.ts. Without it, typos in config are silently ignored and nobody knows which keys exist. It is ten lines; write it before the first setting ships.

Report a problem with this module