diff --git a/Makefile b/Makefile index 26ef862..68320d7 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ SHELL := bash COREPACK_ENABLE_DOWNLOAD_PROMPT ?= 0 export COREPACK_ENABLE_DOWNLOAD_PROMPT -.PHONY: doctor deps dev dev-gitea smoke-gitea test test-app lint config-check secrets ci +.PHONY: doctor deps dev dev-gitea smoke-gitea smoke-catalog test test-app tsc lint config-check secrets ci ## doctor — check Node, Corepack, bao, and required dev ports (no secret values printed) doctor: @@ -34,13 +34,23 @@ smoke-gitea: node scripts/preflight-gitea.mjs bash scripts/smoke-gitea.sh +## smoke-catalog — headless real-ingestion check for the custom Cycle/Saga kinds +## (stub mode — no cluster, no secrets; safe anywhere incl. CI) +smoke-catalog: + bash scripts/smoke-catalog.sh + ## test — envelope tooling + BDD acceptance (jest-cucumber) test: corepack yarn jest --config jest.envelope.config.cjs ## test-app — the generated app/backend unit tests (backstage-cli) +## CI=true forces a single run — without it backstage-cli watches in a TTY and never exits. test-app: - corepack yarn backstage-cli repo test + CI=true corepack yarn backstage-cli repo test + +## tsc — typecheck the whole repo (catches wiring/import errors the linter misses) +tsc: + corepack yarn tsc ## lint — repo lint across all workspaces lint: @@ -54,5 +64,5 @@ config-check: secrets: bash scripts/dev-secrets -## ci — the gate: config-check, lint, envelope tests, app/backend unit tests -ci: config-check lint test test-app +## ci — the full local gate: config-check, lint, typecheck, envelope tests, app/backend unit tests +ci: config-check lint tsc test test-app diff --git a/app-config.yaml b/app-config.yaml index 2147d9b..3bd5f7e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -125,7 +125,7 @@ catalog: - type: file target: ../../examples/mtl.yaml rules: - - allow: [Group, Domain, System, Resource, Component, Cycle] + - allow: [Group, Domain, System, Resource, Component, Cycle, Saga] ## Uncomment these lines to add more example data # - type: url diff --git a/docs/adrs/0008-saga-git-backed-kind.md b/docs/adrs/0008-saga-git-backed-kind.md new file mode 100644 index 0000000..94577cb --- /dev/null +++ b/docs/adrs/0008-saga-git-backed-kind.md @@ -0,0 +1,28 @@ +# Saga: a Git-backed catalog kind for narrated effort records + +- Status: accepted +- Date: 2026-07-06 +- Deciders: Cervator, Claude (Opus 4.8) + +## Context and Problem Statement + +The two-family model (ADR 0007) pairs the structured/ingestable `Cycle` with a narrated/authored counterpart: an After-Action-Report a human *writes* about an effort — a season, a talent show, a fundraiser. Backstage has no home for such narrative, and cramming append-only history into a refreshable catalog node fights the catalog. + +## Considered Options + +- No kind — keep narratives as loose docs / TechDocs only. +- A `Saga` kind whose narrative body lives in the Backstage DB. +- A `Saga` kind that is a thin index entity, with the narrative body in Git. + +## Decision Outcome + +Chosen: a custom **`Saga`** kind (`apiVersion: siliconsaga.org/v1alpha1`) that is a **thin, Git-backed index entity**. The narrative body is a markdown file in the org's repo, referenced by the required `siliconsaga.org/saga-doc` annotation; the entity carries `spec.skald` (the authoring User — flair for "author"), `spec.timeframe`, `spec.touches` (entity refs the Saga narrates), and optional `spec.owner`. A `SagaProcessor` (new-backend-system catalog module) validates these and emits **built-in** relations: `spec.skald`/`spec.owner` → `ownedBy`/`ownerOf`, and `spec.touches[]` → `dependsOn`/`dependencyOf`. + +### Consequences + +- Good: nothing unique lives only in Backstage's DB — both the prose and the entity descriptor are Git files; the DB is a rebuildable cache. +- Good: reuses built-in relation types, so the catalog graph shows what a Saga touched with no bespoke wiring. +- A `Saga` is distinct from a `Cycle`: a season always exists as a `Cycle`; it becomes a `Saga` only if a Skald writes one (zero, one, or many per effort). +- **Deferred:** the authoring UX (catalog-backed `EntityPicker`s for `touches`) and blog/TechDocs-style rendering of `saga.md`. This ADR covers the catalog kind, not the reader/authoring surface — the `Saga` renders on the default entity page for now. + +See ADR [0007](0007-cycle-custom-kind.md) (Cycle / two-family model) and the design doc §5. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 345e0e3..de000e1 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -18,3 +18,4 @@ annotation. Until then they live here as plain Markdown. | [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 | +| [0008](0008-saga-git-backed-kind.md) | Saga: a Git-backed catalog kind for narrated effort records | diff --git a/docs/development/testing.md b/docs/development/testing.md index 06c2944..2ffddcc 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -17,17 +17,21 @@ 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`) 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 tests generated with the Backstage app (e.g. `packages/app/src/App.test.tsx`) plus in-repo backend module units — including the custom `Cycle`/`Saga` kind processors (`packages/backend/src/modules/{cycle,saga}/*Processor.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). +`make ci` runs the full gate: `config-check`, `lint`, `tsc` (typecheck), `test` (envelope BDD), and `test-app` (app/backend + the `Cycle`/`Saga` processor units). + +## Real catalog-ingestion smoke (`make smoke-catalog`) + +`make smoke-catalog` is the committed, end-to-end proof for the custom kinds: it boots the backend headlessly in **stub mode** (no cluster, no secrets), waits for the MTL seed to process, and asserts via the catalog API that the `Cycle` and `Saga` entities ingested **with their emitted relations** (partOf/ownedBy/dependsOn). It tears the backend down on exit and is safe to run anywhere, including CI. This is the real-ingestion counterpart to the source-assertion BDD scenarios below — the stub-mode cousin of the `@live` `make smoke-gitea`. ## `@live` scenarios Some acceptance scenarios are tagged `@live` (e.g. the real OpenBao → Gitea ingest). They need a live cluster + an unsealed OpenBao and are **excluded from the default run** (the steps file loads features with `tagFilter: 'not @live'`). Run them by hand against a real environment per [`openbao-setup.md`](openbao-setup.md). -Where a full backend boot would be heavy, a few catalog scenarios assert against the configured source/fixture instead (a pragmatic stand-in); the `@live` variant is the real proof. Replacing those with a real `startTestBackend` boot is queued as hardening. +The BDD catalog scenarios assert against the seed source (a pragmatic stand-in). The real end-to-end ingestion proof is `make smoke-catalog` (above), which boots the backend and checks the catalog API. ## Adding a test diff --git a/examples/mtl.yaml b/examples/mtl.yaml index cebcf59..c54174e 100644 --- a/examples/mtl.yaml +++ b/examples/mtl.yaml @@ -98,3 +98,22 @@ spec: of: group:default/mtl-soccer owner: group:default/mtl-soccer happensAt: [resource:default/field-1] +--- +# A narrated After-Action-Report (Saga) about the season — authored, Git-backed. +# The narrative body lives beside the entity (siliconsaga.org/saga-doc); Backstage +# only indexes it. `touches` links the entities the Saga narrates. +apiVersion: siliconsaga.org/v1alpha1 +kind: Saga +metadata: + name: saga-soccer-2026-spring + description: 'The Saga of MTL Soccer, Spring 2026' + annotations: + siliconsaga.org/saga-doc: ./sagas/soccer-2026-spring.md +spec: + skald: user:default/guest + timeframe: { start: '2026-03-01', end: '2026-06-15' } + touches: + - cycle:default/soccer-2026-spring + - group:default/mtl-soccer + - resource:default/field-1 + owner: group:default/mtl-soccer diff --git a/examples/sagas/soccer-2026-spring.md b/examples/sagas/soccer-2026-spring.md new file mode 100644 index 0000000..632caa4 --- /dev/null +++ b/examples/sagas/soccer-2026-spring.md @@ -0,0 +1,19 @@ +# The Saga of MTL Soccer, Spring 2026 + +> Skald: @guest · Season: 2026-03-01 → 2026-06-15 + +A narrated After-Action-Report for the Spring 2026 MTL soccer season — the kind of +account no scrape produces, only a human writes. This is a placeholder narrative +that demonstrates the Git-backed `Saga` shape: the prose lives here in the repo, +the catalog `Saga` entity (in `../mtl.yaml`) points at it via the +`siliconsaga.org/saga-doc` annotation and links what it touched via `spec.touches`. + +## What happened + +_(Write the story here: how the season went, notable matches, weather woes, the +volunteers who carried it, what to do differently next time.)_ + +## Retrospective + +_(The Saga can simply be the retrospective — what worked, what didn't, lessons for +the next Skald.)_ diff --git a/packages/app/package.json b/packages/app/package.json index fd1b60e..a00051a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,6 +14,7 @@ "lint": "backstage-cli package lint" }, "dependencies": { + "@backstage/catalog-model": "^1.9.0", "@backstage/cli": "^0.36.3", "@backstage/core-components": "^0.18.11", "@backstage/core-plugin-api": "^1.12.7", @@ -27,6 +28,7 @@ "@backstage/plugin-catalog": "^2.0.6", "@backstage/plugin-catalog-graph": "^0.6.5", "@backstage/plugin-catalog-import": "^0.13.14", + "@backstage/plugin-catalog-react": "^3.1.0", "@backstage/plugin-kubernetes": "^0.12.20", "@backstage/plugin-notifications": "^0.5.18", "@backstage/plugin-org": "^0.7.5", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c0206fe..0a472ff 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,7 +1,8 @@ import { createApp } from '@backstage/frontend-defaults'; import catalogPlugin from '@backstage/plugin-catalog/alpha'; import { navModule } from './modules/nav'; +import { cycleModule } from './modules/cycle'; export default createApp({ - features: [catalogPlugin, navModule], + features: [catalogPlugin, navModule, cycleModule], }); diff --git a/packages/app/src/modules/cycle/CycleCard.tsx b/packages/app/src/modules/cycle/CycleCard.tsx new file mode 100644 index 0000000..de9f324 --- /dev/null +++ b/packages/app/src/modules/cycle/CycleCard.tsx @@ -0,0 +1,60 @@ +import { InfoCard, StructuredMetadataTable } from '@backstage/core-components'; +import { + EntityRefLink, + EntityRefLinks, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; + +type CycleSpec = { + type?: string; + timeframe?: { start?: string; end?: string }; + of?: string; + owner?: string; + happensAt?: string[]; +}; + +// parseEntityRef throws on a malformed ref; never let a bad seed value break the +// card render — fall back to the dash placeholder instead. +const safeRef = ( + ref: string, + defaultKind: string, +): CompoundEntityRef | undefined => { + try { + return parseEntityRef(ref, { defaultKind, defaultNamespace: 'default' }); + } catch { + return undefined; + } +}; + +/** + * Curated overview card for the custom `Cycle` kind — surfaces the fields the + * default entity page buries: the type, the timeframe, and what the Cycle is + * of / happens at (rendered as clickable catalog links). Occurrences (matches, + * deployments) are not modelled as entities, so they are not shown here. + */ +export const CycleOverviewCard = () => { + const { entity } = useEntity(); + const spec = (entity.spec ?? {}) as unknown as CycleSpec; + const tf = spec.timeframe ?? {}; + + const ofRef = spec.of ? safeRef(spec.of, 'Group') : undefined; + const happensAtRefs = (Array.isArray(spec.happensAt) ? spec.happensAt : []) + .map(t => safeRef(t, 'Resource')) + .filter((r): r is CompoundEntityRef => r !== undefined); + + const metadata: Record = { + Type: spec.type ?? '—', + Timeframe: tf.start && tf.end ? `${tf.start} → ${tf.end}` : '—', + 'Part of': ofRef ? : '—', + 'Happens at': happensAtRefs.length > 0 + ? + : '—', + }; + + return ( + + + + ); +}; diff --git a/packages/app/src/modules/cycle/index.tsx b/packages/app/src/modules/cycle/index.tsx new file mode 100644 index 0000000..fef0711 --- /dev/null +++ b/packages/app/src/modules/cycle/index.tsx @@ -0,0 +1,18 @@ +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; + +// A curated info-card on the entity Overview, shown only for `kind: Cycle`. +// Attaches to the catalog default overview automatically (EntityCardBlueprint +// attachTo: entity-content:catalog/overview) and is gated by the filter. +const cycleOverviewCard = EntityCardBlueprint.make({ + name: 'cycle', + params: { + filter: { kind: 'cycle' }, + loader: () => import('./CycleCard').then(m => ), + }, +}); + +export const cycleModule = createFrontendModule({ + pluginId: 'app', + extensions: [cycleOverviewCard], +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 88cae6e..bb394b9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -38,8 +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) +// custom community-domain kinds (Cycle, Saga) backend.add(import('./modules/cycle/catalogModuleCycle')); +backend.add(import('./modules/saga/catalogModuleSaga')); // permission plugin backend.add(import('@backstage/plugin-permission-backend')); diff --git a/packages/backend/src/modules/saga/SagaProcessor.test.ts b/packages/backend/src/modules/saga/SagaProcessor.test.ts new file mode 100644 index 0000000..92f4599 --- /dev/null +++ b/packages/backend/src/modules/saga/SagaProcessor.test.ts @@ -0,0 +1,118 @@ +import { Entity } from '@backstage/catalog-model'; +import { CatalogProcessorResult } from '@backstage/plugin-catalog-node'; +import { SagaProcessor } from './SagaProcessor'; + +const saga = ( + opts: { spec?: unknown; annotations?: Record } = {}, +): Entity => ({ + apiVersion: 'siliconsaga.org/v1alpha1', + kind: 'Saga', + metadata: { + name: 'saga-soccer-2026-spring', + annotations: + opts.annotations ?? { + 'siliconsaga.org/saga-doc': './sagas/soccer-2026-spring.md', + }, + }, + spec: opts.spec as Entity['spec'], +}); + +const validSpec = { + skald: 'user:default/guest', + timeframe: { start: '2026-03-01', end: '2026-06-15' }, + touches: [ + 'cycle:default/soccer-2026-spring', + 'group:default/mtl-soccer', + 'resource:default/field-1', + ], + owner: 'group:default/mtl-soccer', +}; + +describe('SagaProcessor.validateEntityKind', () => { + const p = new SagaProcessor(); + + it('accepts a valid Saga', async () => { + await expect(p.validateEntityKind(saga({ spec: validSpec }))).resolves.toBe(true); + }); + + it('ignores non-Saga 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 Saga missing skald', async () => { + const { skald: _drop, ...noSkald } = validSpec; + await expect(p.validateEntityKind(saga({ spec: noSkald }))).rejects.toThrow( + /spec\.skald is required/, + ); + }); + + it('rejects a Saga with empty touches', async () => { + await expect( + p.validateEntityKind(saga({ spec: { ...validSpec, touches: [] } })), + ).rejects.toThrow(/spec\.touches/); + }); + + it('rejects a Saga missing the saga-doc annotation', async () => { + await expect( + p.validateEntityKind(saga({ spec: validSpec, annotations: {} })), + ).rejects.toThrow(/saga-doc/); + }); + + it('rejects a Saga with a malformed skald ref', async () => { + await expect( + p.validateEntityKind(saga({ spec: { ...validSpec, skald: 'user:default/' } })), + ).rejects.toThrow(/spec\.skald is not a valid entity ref/); + }); +}); + +describe('SagaProcessor.postProcessEntity', () => { + const p = new SagaProcessor(); + + it('emits ownedBy (skald + owner) and dependsOn (touches)', async () => { + const emitted: CatalogProcessorResult[] = []; + await p.postProcessEntity( + saga({ spec: validSpec }), + { type: 'file', target: 'examples/mtl.yaml' } as any, + r => emitted.push(r), + ); + const rels = emitted + .filter(r => r.type === 'relation') + .map(r => (r as any).relation); + + expect(rels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ownedBy', + target: expect.objectContaining({ kind: 'user', name: 'guest' }), + }), + expect.objectContaining({ + type: 'ownedBy', + target: expect.objectContaining({ kind: 'group', name: 'mtl-soccer' }), + }), + expect.objectContaining({ + type: 'dependsOn', + target: expect.objectContaining({ kind: 'cycle', name: 'soccer-2026-spring' }), + }), + expect.objectContaining({ + type: 'dependsOn', + target: expect.objectContaining({ kind: 'resource', name: 'field-1' }), + }), + ]), + ); + }); + + it('emits nothing for non-Saga 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/saga/SagaProcessor.ts b/packages/backend/src/modules/saga/SagaProcessor.ts new file mode 100644 index 0000000..dc15b12 --- /dev/null +++ b/packages/backend/src/modules/saga/SagaProcessor.ts @@ -0,0 +1,147 @@ +import { + Entity, + getCompoundEntityRef, + parseEntityRef, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_DEPENDS_ON, + RELATION_DEPENDENCY_OF, +} from '@backstage/catalog-model'; +import { + CatalogProcessor, + CatalogProcessorEmit, + LocationSpec, + processingResult, +} from '@backstage/plugin-catalog-node'; + +export const SAGA_KIND = 'Saga'; +export const SAGA_DOC_ANNOTATION = 'siliconsaga.org/saga-doc'; + +type SagaSpec = { + skald?: unknown; + owner?: unknown; + touches?: 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 SagaProcessor implements CatalogProcessor { + getProcessorName(): string { + return 'SagaProcessor'; + } + + async validateEntityKind(entity: Entity): Promise { + if (entity.kind !== SAGA_KIND) { + return false; + } + const spec = (entity.spec ?? {}) as SagaSpec; + const errors: string[] = []; + + // skald — required author (a User ref). + if (typeof spec.skald !== 'string' || spec.skald.trim() === '') { + errors.push('spec.skald is required'); + } else if (!isParseableRef(spec.skald, 'User')) { + errors.push(`spec.skald is not a valid entity ref: "${spec.skald}"`); + } + + // owner — optional Group 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}"`); + } + } + + // touches — required, non-empty array of entity refs the Saga narrates. + if (!Array.isArray(spec.touches) || spec.touches.length === 0) { + errors.push('spec.touches must be a non-empty array of entity refs'); + } else { + spec.touches.forEach((t, i) => { + if (typeof t !== 'string' || t.trim() === '' || !isParseableRef(t, 'Component')) { + errors.push(`spec.touches[${i}] is not a valid entity ref: "${String(t)}"`); + } + }); + } + + // 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'); + } + + // saga-doc annotation — required pointer to the Git-backed narrative body. + const doc = entity.metadata.annotations?.[SAGA_DOC_ANNOTATION]; + if (typeof doc !== 'string' || doc.trim() === '') { + errors.push( + `the ${SAGA_DOC_ANNOTATION} annotation (path to the narrative) is required`, + ); + } + + if (errors.length > 0) { + throw new Error( + `Invalid Saga entity "${entity.metadata.name}": ${errors.join('; ')}`, + ); + } + return true; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + if (entity.kind !== SAGA_KIND) { + return entity; + } + const self = getCompoundEntityRef(entity); + const spec = (entity.spec ?? {}) as SagaSpec; + + // skald → ownedBy / ownerOf (the authoring User owns the Saga) + if (typeof spec.skald === 'string' && spec.skald.trim() !== '') { + const skaldRef = parseEntityRef(spec.skald, { + defaultKind: 'User', + defaultNamespace: 'default', + }); + emit(processingResult.relation({ source: self, type: RELATION_OWNED_BY, target: skaldRef })); + emit(processingResult.relation({ source: skaldRef, type: RELATION_OWNER_OF, target: self })); + } + + // owner → ownedBy / ownerOf (co-owning Group) + 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 })); + } + + // touches[] → dependsOn / dependencyOf (the entities the Saga narrates) + const touches = Array.isArray(spec.touches) ? spec.touches : []; + for (const t of touches) { + if (typeof t !== 'string') continue; + const ref = parseEntityRef(t, { + defaultKind: 'Component', + defaultNamespace: 'default', + }); + emit(processingResult.relation({ source: self, type: RELATION_DEPENDS_ON, target: ref })); + emit(processingResult.relation({ source: ref, type: RELATION_DEPENDENCY_OF, target: self })); + } + + return entity; + } +} diff --git a/packages/backend/src/modules/saga/catalogModuleSaga.ts b/packages/backend/src/modules/saga/catalogModuleSaga.ts new file mode 100644 index 0000000..b83beb9 --- /dev/null +++ b/packages/backend/src/modules/saga/catalogModuleSaga.ts @@ -0,0 +1,18 @@ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { SagaProcessor } from './SagaProcessor'; + +export const catalogModuleSaga = createBackendModule({ + pluginId: 'catalog', + moduleId: 'saga', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(new SagaProcessor()); + }, + }); + }, +}); + +export default catalogModuleSaga; diff --git a/scripts/smoke-catalog.sh b/scripts/smoke-catalog.sh new file mode 100644 index 0000000..96db81c --- /dev/null +++ b/scripts/smoke-catalog.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# smoke-catalog — real catalog-ingestion check for the custom kinds, headless and +# self-terminating. The committed counterpart to the source-assertion BDD smokes: +# it boots ONLY the backend in stub mode (app-config.yaml — no cluster, no secrets), +# waits for the MTL seed to process, and asserts via the catalog API that the custom +# `Cycle` and `Saga` entities ingested WITH their emitted relations. Logs to +# .dev/backend.log; tears the backend down on exit. +# +# Run: `make smoke-catalog`. Unlike smoke-gitea (@live, needs OpenBao+Gitea), this +# needs nothing external, so it is safe to run anywhere — including CI. +set -euo pipefail +cd "$(dirname "$0")/.." || exit 1 + +# Fail fast if a prerequisite is missing rather than half-running. +for _cmd in corepack curl jq; do + command -v "$_cmd" >/dev/null 2>&1 || { echo "smoke-catalog: missing prerequisite '$_cmd'" >&2; exit 1; } +done + +mkdir -p .dev +ROOT="$(cygpath -m "$PWD" 2>/dev/null || pwd)" +LOG="$ROOT/.dev/backend.log" +TOKEN="leidangr-smoke-$$-local-only" + +# Generated, never committed (.dev is gitignored): a static token so the smoke can +# read the catalog API. +cat > .dev/app-config.smoke.yaml < "$LOG" +corepack yarn workspace backend start \ + --config "$ROOT/app-config.yaml" \ + --config "$ROOT/.dev/app-config.smoke.yaml" >"$LOG" 2>&1 & +PID=$! +# Always reap the backend, even on interrupt, so a stray process can't keep port +# 7007 occupied and poison later make smoke-catalog / make dev runs. +cleanup() { kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true; } +trap cleanup EXIT INT TERM + +up="" +for _ in $(seq 1 150); do + if grep -q "Listening on" "$LOG" 2>/dev/null; then up=1; break; fi + if ! kill -0 "$PID" 2>/dev/null; then break; fi + sleep 1 +done +if [[ -z "$up" ]]; then + echo "smoke-catalog FAIL: backend never logged 'Listening on'. Recent log:" >&2 + tail -n 40 "$LOG" >&2 || true + exit 1 +fi + +hdr=(-H "Authorization: Bearer ${TOKEN}") +byname() { curl -fsS --connect-timeout 3 --max-time 5 "${hdr[@]}" "http://localhost:7007/api/catalog/entities/by-name/$1" 2>/dev/null || echo '{}'; } + +# Backend readiness != catalog-ingestion readiness. Poll until the custom entities +# appear (or the timeout expires) rather than sleeping once and querying once. +CYCLE='{}'; SAGA='{}'; GROUP='{}' +for _ in $(seq 1 120); do + CYCLE="$(byname cycle/default/soccer-2026-spring)" + SAGA="$(byname saga/default/saga-soccer-2026-spring)" + GROUP="$(byname group/default/mtl)" + if printf '%s' "$CYCLE" | grep -q 'soccer-2026-spring' \ + && printf '%s' "$SAGA" | grep -q 'saga-soccer-2026-spring' \ + && printf '%s' "$GROUP" | grep -q '"name":"mtl"'; then break; fi + sleep 1 +done + +# Field presence — a single-field substring is order-independent, so grep is fine. +check() { if printf '%s' "$2" | grep -qF "$3"; then echo " PASS $1"; else echo " FAIL $1"; return 1; fi; } +# Relation presence — parsed structurally with jq so JSON key order can't cause a +# false failure (grepping `"type":…,"targetRef":…` would be order-dependent). +check_rel() { + if printf '%s' "$2" | jq -e --arg t "$3" --arg r "$4" \ + '(.relations // []) | any(.type == $t and .targetRef == $r)' >/dev/null 2>&1; then + echo " PASS $1"; else echo " FAIL $1"; return 1; fi +} + +# Run every check unconditionally (each prints its own PASS/FAIL) and track the +# overall result — chaining with && would hide all checks after the first failure. +pass=1 +echo "Checks:" +# Cycle: kind + built-in relations emitted by CycleProcessor. +check "Cycle ingested" "$CYCLE" '"kind":"Cycle"' || pass=0 +check_rel "Cycle partOf mtl-soccer" "$CYCLE" partOf group:default/mtl-soccer || pass=0 +check_rel "Cycle ownedBy mtl-soccer" "$CYCLE" ownedBy group:default/mtl-soccer || pass=0 +check_rel "Cycle dependsOn field-1" "$CYCLE" dependsOn resource:default/field-1 || pass=0 +# Group tree ingested. +check "Group tree (mtl, organization)" "$GROUP" '"type":"organization"' || pass=0 +# Saga: kind + built-in relations emitted by SagaProcessor. +check "Saga ingested" "$SAGA" '"kind":"Saga"' || pass=0 +check_rel "Saga ownedBy skald (guest)" "$SAGA" ownedBy user:default/guest || pass=0 +check_rel "Saga ownedBy owner (mtl-soccer)" "$SAGA" ownedBy group:default/mtl-soccer || pass=0 +check_rel "Saga dependsOn Cycle (touches)" "$SAGA" dependsOn cycle:default/soccer-2026-spring || pass=0 +check "Saga doc annotation preserved" "$SAGA" 'siliconsaga.org/saga-doc' || pass=0 + +# Surface any catalog processing errors for the seed. +echo "--- catalog errors mentioning mtl/cycle/saga (if any) ---" +grep -iE "error|InputError|Unable to read" "$LOG" 2>/dev/null | grep -iE "mtl|cycle|saga" | tail -20 || true +echo "(end errors)" + +# Backend teardown is handled by the EXIT trap registered above. +if [[ "$pass" == 1 ]]; then + echo "smoke-catalog PASS: Cycle + Saga + Group tree ingested at runtime with their relations" + exit 0 +fi +echo "smoke-catalog FAIL: expected entities/relations missing. Recent log:" >&2 +tail -n 30 "$LOG" >&2 || true +exit 1 diff --git a/yarn.lock b/yarn.lock index 81cbbe5..401aeca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12483,6 +12483,7 @@ __metadata: version: 0.0.0-use.local resolution: "app@workspace:packages/app" dependencies: + "@backstage/catalog-model": "npm:^1.9.0" "@backstage/cli": "npm:^0.36.3" "@backstage/core-components": "npm:^0.18.11" "@backstage/core-plugin-api": "npm:^1.12.7" @@ -12497,6 +12498,7 @@ __metadata: "@backstage/plugin-catalog": "npm:^2.0.6" "@backstage/plugin-catalog-graph": "npm:^0.6.5" "@backstage/plugin-catalog-import": "npm:^0.13.14" + "@backstage/plugin-catalog-react": "npm:^3.1.0" "@backstage/plugin-kubernetes": "npm:^0.12.20" "@backstage/plugin-notifications": "npm:^0.5.18" "@backstage/plugin-org": "npm:^0.7.5"