Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,5 @@ config-check:
secrets:
bash scripts/dev-secrets

## ci — the gate: config-check, lint, envelope tests
ci: config-check lint test
## ci — the gate: config-check, lint, envelope tests, app/backend unit tests
ci: config-check lint test test-app
6 changes: 6 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ catalog:
rules:
- allow: [User, Group]

# MTL community-domain seed (Phase 3): Group tree, facilities, real software, one Cycle
- type: file
target: ../../examples/mtl.yaml
rules:
- allow: [Group, Domain, System, Resource, Component, Cycle]

## Uncomment these lines to add more example data
# - type: url
# target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all.yaml
Expand Down
35 changes: 35 additions & 0 deletions docs/adrs/0007-cycle-custom-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Cycle: a custom catalog kind for bounded groupings

- Status: accepted
- Date: 2026-07-06
- Deciders: Cervator, Claude (Opus 4.8)

## Context and Problem Statement

Backstage's catalog has no built-in concept for a **bounded, dated grouping of occurrences** — a soccer season, a software release, a multi-day event. We need to model the community domain (and, generically, software release/deployment structure) without overloading `Component`.

## Considered Options

- Reuse built-in kinds only (Domain/System/Group/Resource + `spec.type` conventions).
- Introduce a custom kind per domain concept (`CommunityGroup`, `Program`, `Activity`, `ResourceType`, `Facility`, …).
- A single custom **`Cycle`** kind for the whole bounded-grouping family; everything else built-in.

## Decision Outcome

Chosen: a single custom **`Cycle`** kind (`apiVersion: siliconsaga.org/v1alpha1`), discriminated by an **open `spec.type` vocabulary** (`season`, `release`, `series`, `production`, `drive`, `tournament`, …). It is validated and wired by a new-backend-system catalog module (`CycleProcessor` registered via `catalogProcessingExtensionPoint`). The processor **reuses built-in relation types** rather than inventing new ones:

- `spec.of` → `partOf` / `hasPart` (the Cycle is part of its league/app),
- `spec.owner` → `ownedBy` / `ownerOf`,
- `spec.happensAt` → `dependsOn` / `dependencyOf` (the field/resource it uses),

so the catalog graph and relation cards integrate for free. Everything else in the domain uses built-in kinds: a typed `Group` tree (people-org), facilities/fields/environments as `System` + typed `Resource`, and real software as `System` + `Component`.

### Consequences

- Good: one small custom kind covers the bounded-grouping family across community and software domains; built-in relations mean no bespoke frontend wiring to see the graph.
- Occurrences (matches, deployments, single events) are **queried from source, not minted** as catalog entities — minting each would churn the catalog.
- `ResourceType` is dropped (a `Resource` is already typed; revisit as a Phase-4 vocabulary) and `Activity` dissolves into an occurrence or a `Cycle`.
- The narrated **`Saga`** kind and the TeamSnap entity-provider are designed but deferred.
- The custom kind renders on the **default entity page** for now; a curated page is a follow-up.

See the design: [`../plans/2026-07-06-leidangr-phase3-community-domain-design.md`](../plans/2026-07-06-leidangr-phase3-community-domain-design.md).
1 change: 1 addition & 0 deletions docs/adrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ annotation. Until then they live here as plain Markdown.
| [0004](0004-gitea-catalog-source.md) | Gitea as the catalog source (overlay; GitHub deferred) |
| [0005](0005-guest-auth-first.md) | Guest auth first; Keycloak sign-in deferred |
| [0006](0006-bdd-from-day-one.md) | BDD from day one over TDD'd tooling |
| [0007](0007-cycle-custom-kind.md) | Cycle: a custom catalog kind for bounded groupings |
6 changes: 5 additions & 1 deletion docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ Backstage's own runner (`backstage-cli repo test`, exposed as `make test-app`) o

## 2. App / backend (`make test-app`)

The tests generated with the Backstage app (e.g. `packages/app/src/App.test.tsx`), run by `backstage-cli repo test`.
The tests generated with the Backstage app (e.g. `packages/app/src/App.test.tsx`) plus in-repo backend module units — including the custom `Cycle` kind's processor (`packages/backend/src/modules/cycle/CycleProcessor.test.ts`) — run by `backstage-cli repo test`.

## The `ci` gate (`make ci`)

`make ci` runs the full gate: `config-check`, `lint`, `test` (envelope BDD), and `test-app` (app/backend + the `CycleProcessor` units).

## `@live` scenarios

Expand Down
100 changes: 100 additions & 0 deletions examples/mtl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
# People-org — typed Group tree (organization → sport → division → team)
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: mtl
description: Mountaintop League
spec:
type: organization
children: [mtl-soccer, mtl-basketball, mtl-hockey]
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata: { name: mtl-soccer }
spec: { type: sport, parent: mtl, children: [soccer-u8] }
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata: { name: soccer-u8 }
spec: { type: division, parent: mtl-soccer, children: [soccer-u8-red] }
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata: { name: soccer-u8-red }
spec: { type: team, parent: soccer-u8, children: [] }
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: mtl-basketball
description: Small sport — teams hang directly, no division level
spec: { type: sport, parent: mtl, children: [bball-varsity] }
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata: { name: bball-varsity }
spec: { type: team, parent: mtl-basketball, children: [] }
---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata: { name: mtl-hockey }
spec: { type: sport, parent: mtl, children: [] }
---
# Asset graph — Domain / System / Resource / Component
apiVersion: backstage.io/v1alpha1
kind: Domain
metadata: { name: mtl }
spec: { owner: group:default/mtl }
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata: { name: mtl-house, description: The league house (facility) }
spec: { owner: group:default/mtl, domain: mtl }
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: main-room
annotations:
siliconsaga.org/reservable: 'true'
spec: { type: bookable-space, owner: group:default/mtl, system: mtl-house }
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata: { name: mtl-fields, description: Playing fields (facility) }
spec: { owner: group:default/mtl-soccer, domain: mtl }
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: field-1
annotations:
siliconsaga.org/reservable: 'true'
spec: { type: bookable-space, owner: group:default/mtl-soccer, system: mtl-fields }
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata: { name: mtl-registration, description: Registration app (real software) }
spec: { owner: group:default/mtl, domain: mtl }
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata: { name: reg-web }
spec: { type: website, lifecycle: production, owner: group:default/mtl, system: mtl-registration }
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata: { name: reg-api }
spec: { type: service, lifecycle: production, owner: group:default/mtl, system: mtl-registration }
---
# The one Cycle — a soccer season
apiVersion: siliconsaga.org/v1alpha1
kind: Cycle
metadata: { name: soccer-2026-spring, description: 'MTL Soccer, Spring 2026 season' }
spec:
type: season
timeframe: { start: '2026-03-01', end: '2026-06-15' }
of: group:default/mtl-soccer
owner: group:default/mtl-soccer
happensAt: [resource:default/field-1]
3 changes: 3 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
},
"dependencies": {
"@backstage/backend-defaults": "^0.17.3",
"@backstage/backend-plugin-api": "^1.9.2",
"@backstage/catalog-model": "^1.9.0",
"@backstage/config": "^1.3.8",
"@backstage/plugin-app-backend": "^0.5.15",
"@backstage/plugin-auth-backend": "^0.29.1",
Expand All @@ -26,6 +28,7 @@
"@backstage/plugin-catalog-backend": "^3.8.0",
"@backstage/plugin-catalog-backend-module-logs": "^0.1.23",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.21",
"@backstage/plugin-catalog-node": "^2.2.2",
"@backstage/plugin-kubernetes-backend": "^0.21.5",
"@backstage/plugin-mcp-actions-backend": "^0.1.14",
"@backstage/plugin-notifications-backend": "^0.6.6",
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ backend.add(
// See https://backstage.io/docs/features/software-catalog/configuration#subscribing-to-catalog-errors
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

// custom community-domain kinds (Cycle)
backend.add(import('./modules/cycle/catalogModuleCycle'));

// permission plugin
backend.add(import('@backstage/plugin-permission-backend'));
// See https://backstage.io/docs/permissions/getting-started for how to create your own permission policy
Expand Down
114 changes: 114 additions & 0 deletions packages/backend/src/modules/cycle/CycleProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Entity } from '@backstage/catalog-model';
import { CatalogProcessorResult } from '@backstage/plugin-catalog-node';
import { CycleProcessor } from './CycleProcessor';

const cycle = (spec: unknown): Entity => ({
apiVersion: 'siliconsaga.org/v1alpha1',
kind: 'Cycle',
metadata: { name: 'soccer-2026-spring' },
spec: spec as Entity['spec'],
});

const validSpec = {
type: 'season',
of: 'group:default/mtl-soccer',
owner: 'group:default/mtl-soccer',
timeframe: { start: '2026-03-01', end: '2026-06-15' },
};

describe('CycleProcessor.validateEntityKind', () => {
const p = new CycleProcessor();

it('accepts a valid Cycle', async () => {
await expect(p.validateEntityKind(cycle(validSpec))).resolves.toBe(true);
});

it('ignores non-Cycle kinds', async () => {
const c: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'x' },
};
await expect(p.validateEntityKind(c)).resolves.toBe(false);
});

it('rejects a Cycle missing spec.type', async () => {
const { type: _drop, ...noType } = validSpec;
await expect(p.validateEntityKind(cycle(noType))).rejects.toThrow(
/spec\.type is required/,
);
});

it('rejects a Cycle missing timeframe', async () => {
const { timeframe: _drop, ...noTf } = validSpec;
await expect(p.validateEntityKind(cycle(noTf))).rejects.toThrow(
/spec\.timeframe/,
);
});

it('rejects a Cycle with a malformed spec.of ref', async () => {
await expect(
p.validateEntityKind(cycle({ ...validSpec, of: 'group:default/' })),
).rejects.toThrow(/spec\.of is not a valid entity ref/);
});

it('rejects a Cycle with an empty timeframe.start', async () => {
await expect(
p.validateEntityKind(
cycle({ ...validSpec, timeframe: { start: '', end: '2026-06-15' } }),
),
).rejects.toThrow(/spec\.timeframe/);
});
});

describe('CycleProcessor.postProcessEntity', () => {
const p = new CycleProcessor();

it('emits partOf/ownedBy/dependsOn relations for a Cycle', async () => {
const emitted: CatalogProcessorResult[] = [];
await p.postProcessEntity(
cycle({
type: 'season',
of: 'group:default/mtl-soccer',
owner: 'group:default/mtl-soccer',
happensAt: ['resource:default/field-1'],
timeframe: { start: '2026-03-01', end: '2026-06-15' },
}),
{ type: 'file', target: 'examples/mtl.yaml' } as any,
r => emitted.push(r),
);

const relations = emitted
.filter(r => r.type === 'relation')
.map(r => (r as any).relation);

expect(relations).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'partOf',
// getCompoundEntityRef preserves the entity's kind casing ('Cycle');
// parseEntityRef on the lowercase ref strings yields 'group'/'resource'.
source: expect.objectContaining({ kind: 'Cycle', name: 'soccer-2026-spring' }),
target: expect.objectContaining({ kind: 'group', name: 'mtl-soccer' }),
}),
expect.objectContaining({ type: 'hasPart' }),
expect.objectContaining({ type: 'ownedBy' }),
expect.objectContaining({
type: 'dependsOn',
target: expect.objectContaining({ kind: 'resource', name: 'field-1' }),
}),
expect.objectContaining({ type: 'dependencyOf' }),
]),
);
});

it('emits nothing for non-Cycle kinds', async () => {
const emitted: CatalogProcessorResult[] = [];
await p.postProcessEntity(
{ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'x' } },
{ type: 'file', target: 'x' } as any,
r => emitted.push(r),
);
expect(emitted).toHaveLength(0);
});
});
Loading