PlatformEngr® Backstage course

Module 03 · The portal and its catalog · 60 min · You are: Platform and product

The catalog: teams, systems and a real service

In this module

  • You learn: The kinds of entity (Group, User, Domain, System, Component, API, Resource), how refs and relations work, and how a location tells the catalog where to read.
  • You do: As the platform team, add the organisation and systems to the portal. As team-checkout, create the checkout repo with its catalog-info.yaml, OpenAPI file and docs. Then register it and follow the relations.
  • You end with: The catalog holds Acme's teams, you as a User, two systems, and checkout with its API and database. The example data is gone. Your checkout repo exists on GitHub.

The catalog is only useful if it is true and connected. Each entity is small YAML, but the fields that point at other entities (owner, system, providesApis, dependsOn) are what make "who owns this, what breaks if it goes down" answerable. You will write one side of each relation and watch the catalog add the other.

Open this module in the interactive course to track progress.

Concepts

Entity
One YAML document with apiVersion, kind, metadata (name, title, annotations) and spec. Kinds you use today: Group, User, Domain, System, Component, API, Resource.
Entity ref
kind:namespace/name, like group:default/team-checkout. Fields that imply the kind accept the short form: owner: team-checkout.
Relations
You write spec.owner; the catalog stores ownedBy on the component and ownerOf on the group. A ref to an entity that does not exist is stored anyway.
Location
Where the catalog reads YAML from: a file next to the backend, or a URL in Git. Each location can limit which kinds it may add.
Annotations
Keys under metadata.annotations that plugins read: github.com/project-slug links the repo, backstage.io/techdocs-ref points at the docs.

Files to know

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

Steps

1. Add the organisation and systems

The platform team owns who exists. Copy the two files and put your GitHub name in as the User.

mkdir -p ~/work/portal/catalog && cp ~/work/backstage-workshop/portal/catalog/org.yaml ~/work/backstage-workshop/portal/catalog/systems.yaml ~/work/portal/catalog/
perl -pi -e 's/YOUR_GITHUB_[U]SER/YOUR_GITHUB_USER/g' ~/work/portal/catalog/org.yaml

2. Point the catalog at them

Replace the example locations. Each location only allows the kinds it should hold.

Change in ~/work/portal/app-config.yaml
Under catalog.locations.

-     # Local example data, file locations are relative to the backend process, typically `packages/backend`
-     - type: file
-       target: ../../examples/entities.yaml
+     # Platform-owned catalog data (module 03). File paths are relative to packages/backend.
+     - type: file
+       target: ../../catalog/org.yaml
+       rules:
+         - allow: [Group, User]
+     - type: file
+       target: ../../catalog/systems.yaml
+       rules:
+         - allow: [Domain, System]

Delete from ~/work/portal/app-config.yaml

-     # Local example organizational data
-     - type: file
-       target: ../../examples/org.yaml
-       rules:
-         - allow: [User, Group]

Save. The backend reloads its config by itself. The example template location stays for now.

3. Check the teams

In your second terminal (with .env loaded).

Expect acme, platform and team-checkout. The example guests group is gone: its location was removed, so its entities were deleted.

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=group" | jq -r '.[].metadata.name'

The relations the catalog built for team-checkout. Expect childOf group:default/acme, hasMember you, and ownerOf for domain:default/commerce and system:default/shop:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/group/default/team-checkout | jq -r '.relations[] | .type + " " + .targetRef'

4. As team-checkout: create the checkout repo

In a real company this repo holds the code. Here it holds what the portal needs: catalog-info.yaml, an OpenAPI file and docs.

mkdir -p ~/work/checkout && cp -R ~/work/backstage-workshop/checkout/. ~/work/checkout/
perl -pi -e 's/YOUR_GITHUB_[U]SER/YOUR_GITHUB_USER/g' ~/work/checkout/catalog-info.yaml
cd ~/work/checkout && git init -q -b main && git add -A && git commit -qm "checkout: catalog, API and docs"
gh repo create YOUR_GITHUB_USER/checkout --public --source . --push

5. Register it

A URL location: the catalog reads the file from GitHub with the token in .env, and resolves $text: ./openapi.yaml relative to it.

Add to ~/work/portal/app-config.yaml under catalog.locations, after the systems.yaml location

    # team-checkout's repo, registered by URL.
    - type: url
      target: https://github.com/YOUR_GITHUB_USER/checkout/blob/main/catalog-info.yaml

Save, give it half a minute, then list the components and APIs. Expect checkout and checkout-api:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=component,kind=api" | jq -r '.[].metadata.name'

6. Follow the relations

You wrote four fields in spec; here is what the catalog made of them.

Expect ownedBy group:default/team-checkout, partOf system:default/shop, providesApi api:default/checkout-api and dependsOn resource:default/checkout-db:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/component/default/checkout | jq -r '.relations[] | .type + " " + .targetRef'

Now the UI. Open checkout in the catalog: the Overview tab shows the owner, the system and a relations graph. The APIs tab lists checkout-api; click it and open Definition to see the OpenAPI file rendered. Then click Team Checkout (the owner): its page lists what it owns.

7. Ship it

cd ~/work/portal && git status --short

Expect app-config.yaml modified and the new catalog/ folder.

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

git switch -c catalog-acme && git add -A && git commit -qm "Catalog: Acme teams, systems and checkout"
git push -qu origin HEAD

Open the pull request. It prints a link:

gh pr create --fill

Open that link in your browser. 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

Either way, bring your copy up to date:

git switch -q main && git pull -q

Simplified for the course

Teams and people are typed into org.yaml. In production an org provider (GitHub, Microsoft Entra ID, LDAP) imports them, so leavers disappear on their own.

Break it on purpose: A typo in the owner

team-checkout tidies their catalog-info.yaml and mistypes their own team name. Nothing complains: the push works and the portal keeps running.

You practise: finding a wrong relation when no error exists, and reading relations from both ends.

1. Break it

Change in ~/work/checkout/catalog-info.yaml
In the Component, not the API or Resource.

-   owner: team-checkout
-   system: shop
-   providesApis: [checkout-api]
+   owner: team-chekout
+   system: shop
+   providesApis: [checkout-api]
cd ~/work/checkout && git commit -qam "checkout: tidy catalog-info" && git push -q

The catalog checks the file again every few minutes. Ask it to refresh now (with .env loaded):

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/catalog/refresh -d '{"entityRef":"component:default/checkout"}'

2. What you see

Expected: Team Checkout owns checkout. You get: the Team Checkout page no longer lists checkout under what it owns. There is no error in the portal, in the backend log or on the entity.

The Overview of checkout shows an owner link, but it goes to a page that does not exist.

3. Fix it yourself

Find out who the catalog thinks owns checkout, and why it accepted it. Fix it the way team-checkout would, and prove team-checkout owns checkout again.

Hint 1

Read the relations from both sides: ownedBy on the component, ownerOf on the group. They should agree.

Hint 2

The catalog stores a ref to an entity that does not exist without complaint. Compare the ownedBy target with the group name letter by letter.

The fix

Change in ~/work/checkout/catalog-info.yaml

-   owner: team-chekout
+   owner: team-checkout
cd ~/work/checkout && git commit -qam "checkout: fix owner" && git push -q
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" -X POST -H 'Content-Type: application/json' localhost:7007/api/catalog/refresh -d '{"entityRef":"component:default/checkout"}'

Expect ownerOf component:default/checkout among the lines:

curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" localhost:7007/api/catalog/entities/by-name/group/default/team-checkout | jq -r '.relations[] | .type + " " + .targetRef'
After you fixed it: the five questions
What failed?
The component's owner pointed at a group that does not exist, so the real team lost it.
How was it detected?
Only by looking: the team page no longer listed the service. No error anywhere.
Who responds?
team-checkout: they own the file and the fix.
How is it corrected?
Correct the owner in their repo and refresh (or wait for the next processing loop).
How is it prevented?
Create catalog-info.yaml from a template with an owner picker (module 06), and run a CI check that every ref in the file resolves through the catalog API.

Quiz

  1. You write spec.owner: team-checkout on a component. What does the catalog store?
    • ownedBy on the component and ownerOf on the group
    • Only ownedBy on the component; the group must list it under spec.owns
    • A copy of the group inside the component
  2. A component names an owner that does not exist. What happens?
    • The catalog rejects the entity and logs a validation error
    • The ref is stored and the link leads nowhere; nothing reports it
    • The catalog creates an empty group with that name
  3. Why does $text: ./openapi.yaml work for the checkout repo?
    • The file is copied into the portal repo when you register it
    • The frontend fetches it from GitHub when someone opens the API page
    • It is resolved relative to the URL location of catalog-info.yaml and read with the GitHub token
Answers
  1. ownedBy on the component and ownerOf on the group You write one side; the catalog adds both.
  2. The ref is stored and the link leads nowhere; nothing reports it Refs are not checked for existence. Typos survive until someone looks.
  3. It is resolved relative to the URL location of catalog-info.yaml and read with the GitHub token Processors read $text references next to the file they came from.

Field note

The most useful catalog question in any company is "who do I call when this breaks?". If the answer is a person who left two years ago, the portal has failed. That is why I make the owner field a team, never a person.

Report a problem with this module