diff --git a/Makefile b/Makefile index d3a904b..26ef862 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/app-config.yaml b/app-config.yaml index fbcc181..2147d9b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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 diff --git a/docs/adrs/0007-cycle-custom-kind.md b/docs/adrs/0007-cycle-custom-kind.md new file mode 100644 index 0000000..76faa3c --- /dev/null +++ b/docs/adrs/0007-cycle-custom-kind.md @@ -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). diff --git a/docs/adrs/README.md b/docs/adrs/README.md index e0ce62e..345e0e3 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -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 | diff --git a/docs/development/testing.md b/docs/development/testing.md index 5d72f2e..06c2944 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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 diff --git a/examples/mtl.yaml b/examples/mtl.yaml new file mode 100644 index 0000000..cebcf59 --- /dev/null +++ b/examples/mtl.yaml @@ -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] diff --git a/packages/backend/package.json b/packages/backend/package.json index 65ab495..2c53b35 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", @@ -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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7e9be64..88cae6e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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 diff --git a/packages/backend/src/modules/cycle/CycleProcessor.test.ts b/packages/backend/src/modules/cycle/CycleProcessor.test.ts new file mode 100644 index 0000000..48000d0 --- /dev/null +++ b/packages/backend/src/modules/cycle/CycleProcessor.test.ts @@ -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); + }); +}); diff --git a/packages/backend/src/modules/cycle/CycleProcessor.ts b/packages/backend/src/modules/cycle/CycleProcessor.ts new file mode 100644 index 0000000..49ef1c9 --- /dev/null +++ b/packages/backend/src/modules/cycle/CycleProcessor.ts @@ -0,0 +1,145 @@ +import { + Entity, + getCompoundEntityRef, + parseEntityRef, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PART_OF, + RELATION_HAS_PART, + RELATION_DEPENDS_ON, + RELATION_DEPENDENCY_OF, +} from '@backstage/catalog-model'; +import { + CatalogProcessor, + CatalogProcessorEmit, + LocationSpec, + processingResult, +} from '@backstage/plugin-catalog-node'; + +export const CYCLE_KIND = 'Cycle'; + +type CycleSpec = { + type?: unknown; + of?: unknown; + owner?: unknown; + happensAt?: unknown; + timeframe?: { start?: unknown; end?: unknown }; +}; + +/** True if `ref` parses as a valid entity reference (with the given default kind). */ +function isParseableRef(ref: string, defaultKind: string): boolean { + try { + parseEntityRef(ref, { defaultKind, defaultNamespace: 'default' }); + return true; + } catch { + return false; + } +} + +export class CycleProcessor implements CatalogProcessor { + getProcessorName(): string { + return 'CycleProcessor'; + } + + async validateEntityKind(entity: Entity): Promise { + if (entity.kind !== CYCLE_KIND) { + return false; + } + const spec = (entity.spec ?? {}) as CycleSpec; + const errors: string[] = []; + + if (typeof spec.type !== 'string' || spec.type.trim() === '') { + errors.push('spec.type is required'); + } + + // spec.of — required, and must be a parseable entity ref (default kind Group). + if (typeof spec.of !== 'string' || spec.of.trim() === '') { + errors.push('spec.of is required'); + } else if (!isParseableRef(spec.of, 'Group')) { + errors.push(`spec.of is not a valid entity ref: "${spec.of}"`); + } + + // spec.owner — optional, but if present must be a parseable entity ref. + if (spec.owner !== undefined) { + if (typeof spec.owner !== 'string' || spec.owner.trim() === '') { + errors.push('spec.owner, if set, must be a non-empty entity ref'); + } else if (!isParseableRef(spec.owner, 'Group')) { + errors.push(`spec.owner is not a valid entity ref: "${spec.owner}"`); + } + } + + // spec.happensAt — optional array of parseable entity refs (default kind Resource). + if (spec.happensAt !== undefined) { + if (!Array.isArray(spec.happensAt)) { + errors.push('spec.happensAt, if set, must be an array of entity refs'); + } else { + spec.happensAt.forEach((t, i) => { + if (typeof t !== 'string' || t.trim() === '' || !isParseableRef(t, 'Resource')) { + errors.push(`spec.happensAt[${i}] is not a valid entity ref: "${String(t)}"`); + } + }); + } + } + + // spec.timeframe — required, with non-empty start/end strings. + const tf = spec.timeframe; + if ( + !tf || + typeof tf.start !== 'string' || tf.start.trim() === '' || + typeof tf.end !== 'string' || tf.end.trim() === '' + ) { + errors.push('spec.timeframe.start and spec.timeframe.end are required'); + } + + if (errors.length > 0) { + throw new Error( + `Invalid Cycle entity "${entity.metadata.name}": ${errors.join('; ')}`, + ); + } + return true; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + if (entity.kind !== CYCLE_KIND) { + return entity; + } + const self = getCompoundEntityRef(entity); + const spec = (entity.spec ?? {}) as CycleSpec; + + // of → partOf / hasPart (default parent kind: Group) + const ofRef = parseEntityRef(spec.of as string, { + defaultKind: 'Group', + defaultNamespace: 'default', + }); + emit(processingResult.relation({ source: self, type: RELATION_PART_OF, target: ofRef })); + emit(processingResult.relation({ source: ofRef, type: RELATION_HAS_PART, target: self })); + + // owner → ownedBy / ownerOf + if (typeof spec.owner === 'string' && spec.owner.trim() !== '') { + const ownerRef = parseEntityRef(spec.owner, { + defaultKind: 'Group', + defaultNamespace: 'default', + }); + emit(processingResult.relation({ source: self, type: RELATION_OWNED_BY, target: ownerRef })); + emit(processingResult.relation({ source: ownerRef, type: RELATION_OWNER_OF, target: self })); + } + + // happensAt → dependsOn / dependencyOf (default target kind: Resource) + const happensAt = Array.isArray(spec.happensAt) ? spec.happensAt : []; + for (const t of happensAt) { + if (typeof t !== 'string') continue; + const resRef = parseEntityRef(t, { + defaultKind: 'Resource', + defaultNamespace: 'default', + }); + emit(processingResult.relation({ source: self, type: RELATION_DEPENDS_ON, target: resRef })); + emit(processingResult.relation({ source: resRef, type: RELATION_DEPENDENCY_OF, target: self })); + } + + return entity; + } +} diff --git a/packages/backend/src/modules/cycle/catalogModuleCycle.ts b/packages/backend/src/modules/cycle/catalogModuleCycle.ts new file mode 100644 index 0000000..097095f --- /dev/null +++ b/packages/backend/src/modules/cycle/catalogModuleCycle.ts @@ -0,0 +1,18 @@ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { CycleProcessor } from './CycleProcessor'; + +export const catalogModuleCycle = createBackendModule({ + pluginId: 'catalog', + moduleId: 'cycle', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(new CycleProcessor()); + }, + }); + }, +}); + +export default catalogModuleCycle; diff --git a/tests/acceptance/checkpoint-3-community-domain.feature b/tests/acceptance/checkpoint-3-community-domain.feature new file mode 100644 index 0000000..b3449fe --- /dev/null +++ b/tests/acceptance/checkpoint-3-community-domain.feature @@ -0,0 +1,17 @@ +Feature: Phase 3 community domain seed + The MTL seed models the community hierarchy with built-in kinds plus one Cycle. + + Scenario: the seed declares the MTL org Group tree + Given the MTL seed file + Then it declares a Group "mtl" of type "organization" + And it declares a Group "soccer-u8-red" of type "team" + + Scenario: the seed declares a season Cycle wired to its league and field + Given the MTL seed file + Then it declares a Cycle "soccer-2026-spring" of type "season" + And that Cycle is "of" group "mtl-soccer" + And that Cycle "happensAt" resource "field-1" + + Scenario: the catalog allows the Cycle kind + Given the app-config catalog rules + Then the "mtl.yaml" location allows the "Cycle" kind diff --git a/tests/acceptance/checkpoint-3.steps.ts b/tests/acceptance/checkpoint-3.steps.ts new file mode 100644 index 0000000..bebb2ad --- /dev/null +++ b/tests/acceptance/checkpoint-3.steps.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs'; +import { loadFeature, defineFeature } from 'jest-cucumber'; + +const feature = loadFeature( + 'tests/acceptance/checkpoint-3-community-domain.feature', +); + +// Escape a captured value before using it inside a RegExp. +const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Split a multi-document YAML string into its individual documents so a check +// can be scoped to the one entity it means (rather than matching name/type +// independently anywhere in the file). +const yamlDocs = (yaml: string) => yaml.split(/^---\s*$/m); + +const findDoc = (yaml: string, kind: string, name: string) => + yamlDocs(yaml).find( + d => + new RegExp(`kind:\\s*${esc(kind)}\\b`).test(d) && + new RegExp(`name:\\s*${esc(name)}\\b`).test(d), + ); + +defineFeature(feature, test => { + let seed = ''; + let appConfig = ''; + let cycleDoc: string | undefined; + + test('the seed declares the MTL org Group tree', ({ given, then, and }) => { + given('the MTL seed file', () => { + seed = readFileSync('examples/mtl.yaml', 'utf-8'); + }); + then(/^it declares a Group "(.*)" of type "(.*)"$/, (name, type) => { + const doc = findDoc(seed, 'Group', name); + expect(doc).toBeDefined(); + expect(doc!).toMatch(new RegExp(`type:\\s*${esc(type)}\\b`)); + }); + and(/^it declares a Group "(.*)" of type "(.*)"$/, (name, type) => { + const doc = findDoc(seed, 'Group', name); + expect(doc).toBeDefined(); + expect(doc!).toMatch(new RegExp(`type:\\s*${esc(type)}\\b`)); + }); + }); + + test('the seed declares a season Cycle wired to its league and field', ({ given, then, and }) => { + given('the MTL seed file', () => { + seed = readFileSync('examples/mtl.yaml', 'utf-8'); + }); + then(/^it declares a Cycle "(.*)" of type "(.*)"$/, (name, type) => { + cycleDoc = findDoc(seed, 'Cycle', name); + expect(cycleDoc).toBeDefined(); + expect(cycleDoc!).toMatch(new RegExp(`type:\\s*${esc(type)}\\b`)); + }); + and(/^that Cycle is "of" group "(.*)"$/, group => { + expect(cycleDoc!).toMatch(new RegExp(`of:\\s*group:default/${esc(group)}\\b`)); + }); + and(/^that Cycle "happensAt" resource "(.*)"$/, res => { + // Match the resource within the happensAt array, tolerating extra + // entries / reflowed formatting rather than only a single inline item. + expect(cycleDoc!).toMatch( + new RegExp(`happensAt:\\s*\\[[^\\]]*\\bresource:default/${esc(res)}\\b[^\\]]*\\]`), + ); + }); + }); + + test('the catalog allows the Cycle kind', ({ given, then }) => { + given('the app-config catalog rules', () => { + appConfig = readFileSync('app-config.yaml', 'utf-8'); + }); + then(/^the "(.*)" location allows the "(.*)" kind$/, (loc, kind) => { + // Scope the allow-list to the same location stanza that targets `loc`, + // so the kind can't be satisfied by a different location's rules. + expect(appConfig).toMatch( + new RegExp( + `target:\\s*\\S*${esc(loc)}[\\s\\S]{0,200}?allow:\\s*\\[[^\\]]*\\b${esc(kind)}\\b`, + ), + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 569cf35..81cbbe5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13105,6 +13105,8 @@ __metadata: resolution: "backend@workspace:packages/backend" dependencies: "@backstage/backend-defaults": "npm:^0.17.3" + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/catalog-model": "npm:^1.9.0" "@backstage/cli": "npm:^0.36.3" "@backstage/config": "npm:^1.3.8" "@backstage/plugin-app-backend": "npm:^0.5.15" @@ -13115,6 +13117,7 @@ __metadata: "@backstage/plugin-catalog-backend": "npm:^3.8.0" "@backstage/plugin-catalog-backend-module-logs": "npm:^0.1.23" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "npm:^0.2.21" + "@backstage/plugin-catalog-node": "npm:^2.2.2" "@backstage/plugin-kubernetes-backend": "npm:^0.21.5" "@backstage/plugin-mcp-actions-backend": "npm:^0.1.14" "@backstage/plugin-notifications-backend": "npm:^0.6.6"