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 underreadiness:inapp-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.tsin the package, named inpackage.jsonunderconfigSchema, describes your keys.config:checkand the backend validate against it. Defining config. - Scheduler
scheduler.scheduleTaskruns 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.
portal/plugins/readiness-backend/src/ReadinessStore.ts(reference, your repo): Reads and writes scorecards with Knex.portal/plugins/readiness-backend/src/ReadinessEvaluator.ts(reference, your repo): Scores entities and stores the results.- The scheduled job: every component, as the plugin itself L43–53
portal/plugins/readiness-backend/src/plugin.ts(reference, your repo): Where config and the schedule come together.
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 --strictwould 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 --stricton every pull request. It only checks keys you described inconfig.d.ts, so write the schema.
Quiz
- 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
- 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
- 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
- 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.
- Once per interval: a lock in the database picks one replica The scheduler coordinates through the database.
- 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.