diff --git a/workspaces/scorecard/.changeset/add-catalog-metadata-module.md b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md new file mode 100644 index 0000000000..4adba3f52f --- /dev/null +++ b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog': patch +--- + +Add catalog backend module for the scorecard plugin. diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index f41c2fc2f0..91f409f1f7 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -380,6 +380,20 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 10 } + catalog: + requiredAttributes: + options: + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } filecheck: fileExistence: options: diff --git a/workspaces/scorecard/examples/all-scorecards-location.yaml b/workspaces/scorecard/examples/all-scorecards-location.yaml index 60736cb5c6..bdb37b4d50 100644 --- a/workspaces/scorecard/examples/all-scorecards-location.yaml +++ b/workspaces/scorecard/examples/all-scorecards-location.yaml @@ -8,6 +8,8 @@ spec: targets: - ./components/all-scorecards-service-different-owner.yaml - ./components/all-scorecards.yaml + - ./components/catalog-metadata-scorecard-with-title.yaml + - ./components/catalog-scorecard-without-title.yaml - ./components/code-coverage-scorecard-only.yaml - ./components/dependabot-scorecard-only.yaml - ./components/github-scorecard-only.yaml diff --git a/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml b/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml new file mode 100644 index 0000000000..6253439fb1 --- /dev/null +++ b/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: catalog-metadata-scorecard-with-title + title: Catalog Metadata Scorecard With Title + annotations: + scorecard.example: catalog-metadata +spec: + type: service + owner: group:development/guests + lifecycle: production diff --git a/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml b/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml new file mode 100644 index 0000000000..c00c80d5df --- /dev/null +++ b/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: catalog-scorecard-without-title + annotations: + scorecard.example: catalog-metadata +spec: + type: service + owner: group:development/guests + lifecycle: production diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md new file mode 100644 index 0000000000..5949fc6548 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md @@ -0,0 +1,348 @@ +# Scorecard Backend Module for Catalog + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity metrics, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (metric-level > options-level > hardcoded defaults). + +The module supports: + +- **Required attribute metrics** — verify that a field exists and is non-empty +- **Value whitelist metrics** — verify that a field contains one of a set of accepted values +- **Per-metric entity filters** — scope each metric to specific entity kinds or types +- **Configurable status mapping** — control what status is reported for each field state (`exists`, `empty`, `emptyString`, `emptyArray`, `missed`) and for specific field values +- **Automatic threshold rule generation** — threshold rules are derived from the status mappings, so you don't need to define them manually + +## Prerequisites + +Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions. + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +// Scorecard backend plugin +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +// Install the Catalog module +/* highlight-add-next-line */ +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog' + ), +); + +backend.start(); +``` + +## Configuration + +All metrics are defined under `scorecard.metricProviders.catalog.requiredAttributes.options.metrics` in your `app-config.yaml`. The `metrics` key is an object where each key is a metric ID and each value specifies an entity filter, a dotted field path to evaluate, and an optional status mapping override. + +If no metrics are configured, the module has no effect. + +### Example 1: Required attribute metric + +The simplest use case — verify that a field exists and is non-empty. Uses the default status mapping where existing fields map to `found` and missing/empty fields map to `missed`. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + options: + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title +``` + +This produces a single metric `catalog.title` that reports `found` when the entity has a non-empty `metadata.title`, or `missed` when it is absent, null, or empty. + +### Example 2: Value whitelist metric + +Verify that a field contains one of a set of accepted values. Values not in the whitelist are reported with the `exists` status (here overridden to `invalid`). + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + options: + metrics: + lifecycle: + title: Lifecycle must be a known value + description: The spec.lifecycle field should be prod, stage, test, or dev. + filter: + kind: Component + field: spec.lifecycle + statusMapping: + exists: invalid + values: + prod: ok + stage: ok + test: ok + dev: ok +``` + +This produces metric `catalog.lifecycle` with three possible statuses: + +| Field state | Status | +| ------------------------------------------ | --------- | +| Value is `prod`, `stage`, `test`, or `dev` | `ok` | +| Value exists but is not in the whitelist | `invalid` | +| Field is missing, null, or empty | `missed` | + +### Example 3: Multiple metrics with different entity kinds + +Define multiple metrics targeting different entity kinds. The module aggregates kind filters for efficient catalog querying. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + options: + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + + owner: + title: Owner is required + description: Every component should declare an owner. + filter: + kind: Component + field: spec.owner + + templateOwner: + title: Template owner is required + description: Every template should declare an owner. + filter: + kind: Template + field: spec.owner +``` + +This produces three metrics: `catalog.title`, `catalog.owner`, and `catalog.templateOwner`. The module automatically queries only Component and Template entities from the catalog. + +### Example 4: Options-level status mapping defaults + +Set default status strings for all metrics at the options level. Individual metrics can still override specific fields. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + options: + statusMapping: + exists: present + empty: absent + emptyString: absent + emptyArray: absent + missed: absent + metrics: + title: + title: Title is required + description: The metadata.title should be defined. + filter: + kind: Component + field: metadata.title + # Inherits options-level mapping: present/absent + + tags: + title: Tags are required + description: Components should have at least one tag. + filter: + kind: Component + field: metadata.tags + statusMapping: + exists: present + emptyArray: warning + # Overrides only emptyArray; other states inherit from options-level +``` + +### Example 5: Metric with multi-field entity filter + +Filter by multiple entity fields. All filter conditions must match (AND logic). Filter values are compared case-insensitively. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + options: + metrics: + serviceLifecycle: + title: Service lifecycle is required + description: Service components should have a lifecycle set. + filter: + kind: Component + spec.type: service + field: spec.lifecycle +``` + +This metric only runs against entities where `kind` is `Component` **and** `spec.type` is `service`. + +### Example 6: Full configuration with schedule and per-metric thresholds + +A comprehensive example combining schedule configuration, options-level defaults, multiple metrics, and per-metric threshold overrides. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + schedule: + frequency: + cron: '0 */2 * * *' + timeout: + minutes: 10 + initialDelay: + seconds: 30 + + options: + statusMapping: + exists: found + missed: missed + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + + lifecycle: + title: Lifecycle must be valid + description: The spec.lifecycle field should be one of the accepted values. + filter: + kind: Component + field: spec.lifecycle + statusMapping: + exists: invalid + values: + production: ok + experimental: warning + deprecated: warning + + metrics: + lifecycle: + thresholds: + rules: + - key: ok + expression: '==0' + color: 'success.main' + icon: scorecardSuccessStatusIcon + - key: warning + expression: '==1' + color: 'warning.main' + icon: scorecardWarningStatusIcon + - key: invalid + expression: '==2' + color: 'error.main' + icon: scorecardErrorStatusIcon + - key: missed + expression: '==3' + color: 'error.main' + icon: scorecardErrorStatusIcon +``` + +## How It Works + +### Field Resolution + +Fields are resolved using dotted path notation on the entity object. For example, `metadata.title` resolves to `entity.metadata.title`, and `spec.lifecycle` resolves to `entity.spec.lifecycle`. + +> **Note:** Dotted annotation keys (e.g., `backstage.io/source-location`) cannot be resolved directly because the path is split on `.`. Use annotations with non-dotted keys, or check a different field path. + +### Status Evaluation + +Each field value is evaluated against the metric's status mapping to produce a status string: + +| Field state | Status mapping key | Default status | +| --------------------------------------------------- | ------------------ | -------------- | +| Field exists with a non-empty value, no value match | `exists` | `found` | +| Field exists, value matches an entry in `values` | `values.` | _(per value)_ | +| Field resolves to `null` or `undefined` | `empty` | `missed` | +| Field resolves to an empty string (`""`) | `emptyString` | `missed` | +| Field resolves to an empty array (`[]`) | `emptyArray` | `missed` | +| Field path does not resolve on the entity | `missed` | `missed` | + +### Three-Tier Status Mapping Merge + +Status mappings are resolved with the following priority: + +1. **Metric-level** (`metrics..statusMapping`) — highest priority +2. **Options-level** (`options.statusMapping`) — middle priority +3. **Hardcoded defaults** — lowest priority (see table above) + +Each field in the status mapping is resolved independently, so a metric can override just `exists` while inheriting the options-level `missed` value. + +The `values` maps are deep-merged: hardcoded defaults (empty), then options-level values, then metric-level values. A metric-level entry for the same key wins over the options-level entry. + +### Automatic Threshold Generation + +The module automatically generates threshold rules from each metric's resolved status mapping. Each distinct status string becomes a threshold rule with a numeric code. Well-known status strings get default colors and icons: + +| Status string | Color | Icon | +| -------------------------------------- | ---------------- | ---------------------------- | +| `found`, `ok`, `success`, `valid` | success (green) | `scorecardSuccessStatusIcon` | +| `missed`, `invalid`, `error`, `failed` | error (red) | `scorecardErrorStatusIcon` | +| `warning` | warning (yellow) | `scorecardWarningStatusIcon` | +| _(any other)_ | warning (yellow) | `scorecardWarningStatusIcon` | + +You can override the auto-generated thresholds using per-metric threshold configuration (see Example 6). + +## Available Metrics + +### Catalog metric (`catalog.`) + +Each configured metric produces one numeric metric. + +- **Metric ID**: `catalog.` (where `` is the key from the `metrics` object) +- **Provider ID**: `catalog.requiredAttributes` +- **Type**: Number (numeric code mapped to a status string via threshold rules) +- **Datasource**: `catalog` + +## Schedule Configuration + +The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. You can change this schedule in the `app-config.yaml` file: + +```yaml +scorecard: + metricProviders: + catalog: + requiredAttributes: + schedule: + frequency: + cron: '0 6 * * *' + timeout: + minutes: 5 + initialDelay: + seconds: 5 +``` + +The schedule configuration follows Backstage's `SchedulerServiceTaskScheduleDefinitionConfig` [schema](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts#L157). For more details on how to configure schedule, see [Metric Collection Scheduling](../scorecard-backend/docs/providers.md#metric-collection-scheduling). diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts new file mode 100644 index 0000000000..3d0bc5caa5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts @@ -0,0 +1,94 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; +import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export interface Config { + /** Configuration for scorecard plugin */ + scorecard?: { + /** Metric providers calculate one or more metrics on a schedule. */ + metricProviders?: { + /** Catalog check configuration */ + catalog?: { + requiredAttributes?: { + /** How often catalog.requiredAttributes metrics will be calculated */ + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + /** How catalog metadata metric values are categorized */ + thresholds?: ThresholdConfig; + /** Provider-specific options */ + options?: { + /** Metrics to evaluate — keys are metric IDs (used as catalog.) */ + metrics?: { + [metricId: string]: { + /** Human-readable title */ + title: string; + /** Human-readable description */ + description: string; + /** Entity filter — keys are dotted field paths, values are expected values */ + filter: { + [fieldPath: string]: string; + }; + /** Dotted field path to check on the entity (e.g. metadata.title, spec.lifecycle) */ + field: string; + /** Per-metric status mapping overrides */ + statusMapping?: { + /** Status when field exists with a non-empty value not matched by values */ + exists?: string; + /** Status when field resolves to null or undefined */ + empty?: string; + /** Status when field resolves to an empty string */ + emptyString?: string; + /** Status when field resolves to an empty array */ + emptyArray?: string; + /** Status when the field path does not resolve */ + missed?: string; + /** Status per specific field value */ + values?: { + [value: string]: string; + }; + }; + }; + }; + /** Options-level status mapping defaults for all metrics */ + statusMapping?: { + /** Status when field exists with a non-empty value not matched by values */ + exists?: string; + /** Status when field resolves to null or undefined */ + empty?: string; + /** Status when field resolves to an empty string */ + emptyString?: string; + /** Status when field resolves to an empty array */ + emptyArray?: string; + /** Status when the field path does not resolve */ + missed?: string; + /** Status per specific field value */ + values?: { + [value: string]: string; + }; + }; + }; + /** Per-metric configuration. Keys are local metric names (no datasource prefix). */ + metrics?: { + [metricName: string]: { + thresholds?: ThresholdConfig; + }; + }; + }; + }; + }; + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json new file mode 100644 index 0000000000..ca4713298a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json @@ -0,0 +1,67 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog", + "version": "0.0.0", + "license": "Apache-2.0", + "description": "The catalog backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "configSchema": "config.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.9.2", + "@backstage/catalog-model": "^1.9.0", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.11.4", + "@backstage/cli": "^0.36.3", + "@backstage/config": "^1.3.8" + }, + "files": [ + "config.d.ts", + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-catalog" + }, + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://red.ht/rhdh", + "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", + "author": "Red Hat" +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md new file mode 100644 index 0000000000..65b1f9500a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const scorecardModuleCatalog: BackendFeature; +export default scorecardModuleCatalog; +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts new file mode 100644 index 0000000000..280bfec457 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The catalog backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleCatalog as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts new file mode 100644 index 0000000000..87985643d5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts @@ -0,0 +1,227 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Config } from '@backstage/config'; + +/** + * Status mapping that maps field states to status strings (threshold keys). + */ +export type StatusMapping = { + /** Status when field exists with a non-empty value not matched by values */ + exists: string; + /** Status when field resolves to null or undefined */ + empty: string; + /** Status when field resolves to an empty string */ + emptyString: string; + /** Status when field resolves to an empty array */ + emptyArray: string; + /** Status when the field path does not resolve */ + missed: string; + /** Status per specific field value */ + values: Record; +}; + +/** + * A single metric configuration parsed from app-config.yaml. + */ +export type MetricConfig = { + id: string; + title: string; + description: string; + filter: Record; + field: string; + statusMapping: StatusMapping; +}; + +/** + * Parsed configuration for the catalog required attributes metric provider. + */ +export type CatalogRequiredAttributesConfig = { + metrics: MetricConfig[]; +}; + +/** Hardcoded default status mapping as described in the issue. */ +export const DEFAULT_STATUS_MAPPING: StatusMapping = { + exists: 'found', + empty: 'missed', + emptyString: 'missed', + emptyArray: 'missed', + missed: 'missed', + values: {}, +}; + +/** + * Merges status mappings with priority: check-level > options-level > defaults. + * Each field is individually resolved by priority. + */ +export function mergeStatusMappings( + checkMapping: Partial | undefined, + optionsMapping: Partial | undefined, +): StatusMapping { + return { + exists: + checkMapping?.exists ?? + optionsMapping?.exists ?? + DEFAULT_STATUS_MAPPING.exists, + empty: + checkMapping?.empty ?? + optionsMapping?.empty ?? + DEFAULT_STATUS_MAPPING.empty, + emptyString: + checkMapping?.emptyString ?? + optionsMapping?.emptyString ?? + DEFAULT_STATUS_MAPPING.emptyString, + emptyArray: + checkMapping?.emptyArray ?? + optionsMapping?.emptyArray ?? + DEFAULT_STATUS_MAPPING.emptyArray, + missed: + checkMapping?.missed ?? + optionsMapping?.missed ?? + DEFAULT_STATUS_MAPPING.missed, + values: { + ...DEFAULT_STATUS_MAPPING.values, + ...(optionsMapping?.values ?? {}), + ...(checkMapping?.values ?? {}), + }, + }; +} + +/** + * Reads a partial StatusMapping from a Backstage Config node. + */ +function readStatusMapping(config: Config): Partial | undefined { + const result: Partial = {}; + let hasAny = false; + + const exists = config.getOptionalString('exists'); + if (exists !== undefined) { + result.exists = exists; + hasAny = true; + } + + const empty = config.getOptionalString('empty'); + if (empty !== undefined) { + result.empty = empty; + hasAny = true; + } + + const emptyString = config.getOptionalString('emptyString'); + if (emptyString !== undefined) { + result.emptyString = emptyString; + hasAny = true; + } + + const emptyArray = config.getOptionalString('emptyArray'); + if (emptyArray !== undefined) { + result.emptyArray = emptyArray; + hasAny = true; + } + + const missed = config.getOptionalString('missed'); + if (missed !== undefined) { + result.missed = missed; + hasAny = true; + } + + const valuesConfig = config.getOptionalConfig('values'); + if (valuesConfig) { + const values: Record = {}; + for (const key of valuesConfig.keys()) { + values[key] = valuesConfig.getString(key); + } + if (Object.keys(values).length > 0) { + result.values = values; + hasAny = true; + } + } + + return hasAny ? result : undefined; +} + +/** + * Parses the catalog required attributes configuration from the root Backstage config. + * Returns undefined if no metrics are configured. + */ +export function parseCatalogRequiredAttributesConfig( + config: Config, +): CatalogRequiredAttributesConfig | undefined { + const optionsConfig = config.getOptionalConfig( + 'scorecard.metricProviders.catalog.requiredAttributes.options', + ); + + if (!optionsConfig) { + return undefined; + } + + const metricsConfig = optionsConfig.getOptionalConfig('metrics'); + if (!metricsConfig) { + return undefined; + } + + const metricKeys = metricsConfig.keys(); + if (metricKeys.length === 0) { + return undefined; + } + + // Read options-level status mapping + const optionsStatusMappingConfig = + optionsConfig.getOptionalConfig('statusMapping'); + const optionsStatusMapping = optionsStatusMappingConfig + ? readStatusMapping(optionsStatusMappingConfig) + : undefined; + + const metrics: MetricConfig[] = metricKeys.map(metricId => { + const metricConfig = metricsConfig.getConfig(metricId); + + if (!metricId) { + throw new Error(`Metric has an empty id (object key)`); + } + + const title = metricConfig.getString('title'); + const description = metricConfig.getString('description'); + + // Read filter + const filterConfig = metricConfig.getConfig('filter'); + const filter: Record = {}; + for (const key of filterConfig.keys()) { + filter[key] = filterConfig.getString(key); + } + + // Read field + const field = metricConfig.getString('field'); + if (!field) { + throw new Error(`Metric '${metricId}' has an empty field path`); + } + + // Read metric-level status mapping + const metricStatusMappingConfig = + metricConfig.getOptionalConfig('statusMapping'); + const metricStatusMapping = metricStatusMappingConfig + ? readStatusMapping(metricStatusMappingConfig) + : undefined; + + // Merge status mappings: metric > options > defaults + const statusMapping = mergeStatusMappings( + metricStatusMapping, + optionsStatusMapping, + ); + + return { id: metricId, title, description, filter, field, statusMapping }; + }); + + return { metrics }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts new file mode 100644 index 0000000000..080eb63bb4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts @@ -0,0 +1,772 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { + mergeStatusMappings, + DEFAULT_STATUS_MAPPING, +} from './CatalogRequiredAttributesConfig'; +import { + createCatalogRequiredAttributesMetricProvider, + resolveFieldPath, + evaluateFieldStatus, + entityMatchesFilter, +} from './CatalogRequiredAttributesMetricProvider'; + +// ── helpers ──────────────────────────────────────────────────────────── + +function buildConfig( + metrics: Record, + optionsStatusMapping?: object, +) { + return { + scorecard: { + metricProviders: { + catalog: { + requiredAttributes: { + options: { + metrics, + ...(optionsStatusMapping + ? { statusMapping: optionsStatusMapping } + : {}), + }, + }, + }, + }, + }, + }; +} + +function titleMetric(overrides?: object) { + return { + title: 'Title is required', + description: 'The metadata.title should be defined.', + filter: { kind: 'Component' }, + field: 'metadata.title', + ...overrides, + }; +} + +function lifecycleMetric(overrides?: object) { + return { + title: 'lifecycle should be prod, stage, test or dev', + description: + 'The spec.lifecycle field should be one of four accepted values.', + filter: { kind: 'Component' }, + field: 'spec.lifecycle', + statusMapping: { + exists: 'invalid', + values: { + prod: 'ok', + stage: 'ok', + test: 'ok', + dev: 'ok', + }, + }, + ...overrides, + }; +} + +const componentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + title: 'My Component', + tags: ['typescript', 'backstage'], + annotations: { + 'backstage.io/source-location': + 'url:https://github.com/org/my-repo/tree/main/', + }, + links: [{ url: 'https://example.com', title: 'Homepage' }], + }, + spec: { + type: 'service', + lifecycle: 'prod', + owner: 'team-a', + }, +}; + +const templateEntity: Entity = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'test-template', + }, + spec: { + type: 'service', + owner: 'team-b', + }, +}; + +// ── resolveFieldPath ─────────────────────────────────────────────────── + +describe('resolveFieldPath', () => { + const entity = componentEntity; + + it('should resolve a top-level field', () => { + expect(resolveFieldPath(entity, 'kind')).toBe('Component'); + }); + + it('should resolve a nested field', () => { + expect(resolveFieldPath(entity, 'metadata.name')).toBe('test-component'); + }); + + it('should resolve a deeply nested field', () => { + expect(resolveFieldPath(entity, 'spec.lifecycle')).toBe('prod'); + }); + + it('should return NOT_FOUND for a missing top-level field', () => { + const result = resolveFieldPath(entity, 'nonexistent'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND for a missing nested field', () => { + const result = resolveFieldPath(entity, 'metadata.nonexistent'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND when traversing through a non-object', () => { + const result = resolveFieldPath(entity, 'metadata.name.something'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND for dotted annotation keys', () => { + // Dotted annotation keys like "backstage.io/source-location" require + // special handling. The path splits on "." so it tries + // entity.metadata.annotations.backstage which does not exist. + // This is expected behavior — callers should use a non-dotted + // annotation key or a custom resolution strategy. + const result = resolveFieldPath( + entity, + 'metadata.annotations.backstage.io/source-location', + ); + expect(typeof result).toBe('symbol'); + }); + + it('should resolve array fields', () => { + expect(resolveFieldPath(entity, 'metadata.tags')).toEqual([ + 'typescript', + 'backstage', + ]); + }); +}); + +// ── evaluateFieldStatus ──────────────────────────────────────────────── + +describe('evaluateFieldStatus', () => { + it('should return "found" for an existing non-empty value', () => { + const status = evaluateFieldStatus( + componentEntity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('found'); + }); + + it('should return "missed" for a missing field', () => { + const status = evaluateFieldStatus( + templateEntity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for a null field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { + ...componentEntity.metadata, + title: null as unknown as string, + }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for an empty string field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, title: '' }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for an empty array field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, tags: [] }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.tags', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return matched value status for a known value', () => { + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok', stage: 'ok', test: 'ok', dev: 'ok' }, + }; + const status = evaluateFieldStatus( + componentEntity, + 'spec.lifecycle', + statusMapping, + ); + expect(status).toBe('ok'); + }); + + it('should return "exists" status for an unknown value', () => { + const entity: Entity = { + ...componentEntity, + spec: { ...componentEntity.spec, lifecycle: 'experimental' }, + }; + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok', stage: 'ok' }, + }; + const status = evaluateFieldStatus(entity, 'spec.lifecycle', statusMapping); + expect(status).toBe('invalid'); + }); + + it('should return "missed" for a missing field with values mapping', () => { + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok' }, + }; + const status = evaluateFieldStatus( + templateEntity, + 'spec.lifecycle', + statusMapping, + ); + expect(status).toBe('missed'); + }); + + it('should handle non-empty arrays as "exists"', () => { + const status = evaluateFieldStatus( + componentEntity, + 'metadata.tags', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('found'); + }); +}); + +// ── entityMatchesFilter ──────────────────────────────────────────────── + +describe('entityMatchesFilter', () => { + it('should match with an empty filter', () => { + expect(entityMatchesFilter(componentEntity, {})).toBe(true); + }); + + it('should match on kind (case-insensitive)', () => { + expect(entityMatchesFilter(componentEntity, { kind: 'Component' })).toBe( + true, + ); + expect(entityMatchesFilter(componentEntity, { kind: 'component' })).toBe( + true, + ); + }); + + it('should not match on wrong kind', () => { + expect(entityMatchesFilter(componentEntity, { kind: 'Template' })).toBe( + false, + ); + }); + + it('should match on multiple filter fields', () => { + expect( + entityMatchesFilter(componentEntity, { + kind: 'Component', + 'spec.type': 'service', + }), + ).toBe(true); + }); + + it('should not match when one filter field does not match', () => { + expect( + entityMatchesFilter(componentEntity, { + kind: 'Component', + 'spec.type': 'website', + }), + ).toBe(false); + }); + + it('should not match when filter field path does not resolve', () => { + expect( + entityMatchesFilter(componentEntity, { + 'spec.nonexistent': 'value', + }), + ).toBe(false); + }); +}); + +// ── mergeStatusMappings ──────────────────────────────────────────────── + +describe('mergeStatusMappings', () => { + it('should return defaults when no overrides', () => { + const result = mergeStatusMappings(undefined, undefined); + expect(result).toEqual(DEFAULT_STATUS_MAPPING); + }); + + it('should apply options-level overrides', () => { + const result = mergeStatusMappings(undefined, { + exists: 'present', + }); + expect(result.exists).toBe('present'); + expect(result.missed).toBe('missed'); + }); + + it('should apply check-level overrides over options-level', () => { + const result = mergeStatusMappings( + { exists: 'check-level' }, + { exists: 'options-level' }, + ); + expect(result.exists).toBe('check-level'); + }); + + it('should merge values maps with check > options > defaults', () => { + const result = mergeStatusMappings( + { values: { prod: 'check-prod' } }, + { values: { prod: 'options-prod', stage: 'options-stage' } }, + ); + expect(result.values).toEqual({ + prod: 'check-prod', + stage: 'options-stage', + }); + }); + + it('should fall back to defaults for unset fields', () => { + const result = mergeStatusMappings({ exists: 'custom' }, undefined); + expect(result.exists).toBe('custom'); + expect(result.empty).toBe('missed'); + expect(result.emptyString).toBe('missed'); + expect(result.emptyArray).toBe('missed'); + expect(result.missed).toBe('missed'); + }); +}); + +// ── createCatalogRequiredAttributesMetricProvider ──────────────────────────────── + +describe('createCatalogRequiredAttributesMetricProvider', () => { + it('should return undefined when no config is provided', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader({}), + ); + expect(provider).toBeUndefined(); + }); + + it('should return undefined when metrics object is empty', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({})), + ); + expect(provider).toBeUndefined(); + }); + + it('should create provider with a single metric', () => { + const config = new ConfigReader(buildConfig({ title: titleMetric() })); + const provider = createCatalogRequiredAttributesMetricProvider(config); + + expect(provider).toBeDefined(); + expect(provider?.getMetrics().map(m => m.id)).toEqual(['catalog.title']); + }); + + it('should create provider with multiple metrics', () => { + const config = new ConfigReader( + buildConfig({ + title: titleMetric(), + lifecycle: lifecycleMetric(), + }), + ); + const provider = createCatalogRequiredAttributesMetricProvider(config); + + expect(provider).toBeDefined(); + expect(provider?.getMetrics().map(m => m.id)).toEqual([ + 'catalog.title', + 'catalog.lifecycle', + ]); + }); +}); + +// ── provider methods ─────────────────────────────────────────────────── + +describe('CatalogRequiredAttributesMetricProvider', () => { + describe('provider identification', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + + it('should return correct provider ID', () => { + expect(provider?.getProviderId()).toBe('catalog.requiredAttributes'); + }); + + it('should return correct datasource ID', () => { + expect(provider?.getProviderDatasourceId()).toBe('catalog'); + }); + }); + + describe('getMetrics', () => { + it('should return metrics with correct type', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const metrics = provider?.getMetrics(); + + expect(metrics).toHaveLength(1); + metrics?.forEach(m => { + expect(m.type).toBe('number'); + }); + }); + + it('should generate threshold rules from status mapping', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const metrics = provider?.getMetrics(); + + // Default status mapping produces 'found' and 'missed' statuses + const thresholds = metrics?.[0].thresholds; + expect(thresholds?.rules).toBeDefined(); + const keys = thresholds?.rules.map(r => r.key); + expect(keys).toContain('found'); + expect(keys).toContain('missed'); + }); + + it('should generate threshold rules for value-specific mapping', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), + ); + const metrics = provider?.getMetrics(); + + const thresholds = metrics?.[0].thresholds; + const keys = thresholds?.rules.map(r => r.key); + expect(keys).toContain('ok'); + expect(keys).toContain('invalid'); + expect(keys).toContain('missed'); + }); + + it('should include metric metadata', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const metric = provider?.getMetrics()[0]; + + expect(metric?.id).toBe('catalog.title'); + expect(metric?.title).toBe('Title is required'); + expect(metric?.description).toBe('The metadata.title should be defined.'); + }); + }); + + describe('getCatalogFilter', () => { + it('should return kind filter when all metrics share the same kind', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + lifecycle: lifecycleMetric({ filter: { kind: 'Component' } }), + }), + ), + ); + expect(provider?.getCatalogFilter()).toEqual({ + kind: 'component', + }); + }); + + it('should return multi-kind filter for different kinds', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + templateOwner: titleMetric({ + title: 'Owner', + description: 'desc', + filter: { kind: 'Template' }, + field: 'spec.owner', + }), + }), + ), + ); + const catalogFilter = provider?.getCatalogFilter(); + expect(catalogFilter?.kind).toBeDefined(); + expect( + Array.isArray(catalogFilter?.kind) + ? (catalogFilter?.kind as string[]).sort() + : [], + ).toEqual(['component', 'template']); + }); + + it('should return empty filter when any metric has no kind filter', () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + allTitle: titleMetric({ + title: 'T', + description: 'D', + filter: {}, + field: 'metadata.title', + }), + }), + ), + ); + expect(provider?.getCatalogFilter()).toEqual({}); + }); + }); + + describe('calculateMetrics', () => { + it('should return "found" status code for existing field', async () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const result = await provider?.calculateMetrics(componentEntity); + + // The metric value is a numeric code mapping to "found" + const metrics = provider?.getMetrics(); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const foundRule = titleMet?.thresholds.rules.find(r => r.key === 'found'); + const expectedCode = Number(foundRule?.expression.replace('==', '')); + + expect(result?.get('catalog.title')).toBe(expectedCode); + }); + + it('should return "missed" status code for missing field', async () => { + const entityWithoutTitle: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'no-title-component' }, + spec: { type: 'service', lifecycle: 'prod', owner: 'team-a' }, + }; + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const result = await provider?.calculateMetrics(entityWithoutTitle); + + const metrics = provider?.getMetrics(); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const missedRule = titleMet?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalog.title')).toBe(expectedCode); + }); + + it('should skip metrics for non-matching entities', async () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + }), + ), + ); + const result = await provider?.calculateMetrics(templateEntity); + + expect(result?.has('catalog.title')).toBe(false); + }); + + it('should return "ok" for valid lifecycle value', async () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), + ); + const result = await provider?.calculateMetrics(componentEntity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); + const okRule = lcMetric?.thresholds.rules.find(r => r.key === 'ok'); + const expectedCode = Number(okRule?.expression.replace('==', '')); + + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); + }); + + it('should return "invalid" for unknown lifecycle value', async () => { + const entity: Entity = { + ...componentEntity, + spec: { ...componentEntity.spec, lifecycle: 'experimental' }, + }; + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); + const invalidRule = lcMetric?.thresholds.rules.find( + r => r.key === 'invalid', + ); + const expectedCode = Number(invalidRule?.expression.replace('==', '')); + + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); + }); + + it('should return "missed" for missing lifecycle value', async () => { + const entity: Entity = { + ...componentEntity, + spec: { type: 'service', owner: 'team-a' }, + }; + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); + const missedRule = lcMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); + }); + + it('should handle empty string field with default mapping', async () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, title: '' }, + }; + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric() })), + ); + const result = await provider?.calculateMetrics(entity); + + // Default mapping: emptyString → 'missed' + const metrics = provider?.getMetrics(); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const missedRule = titleMet?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalog.title')).toBe(expectedCode); + }); + + it('should handle empty array field with default mapping', async () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, tags: [] }, + }; + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + tags: titleMetric({ + title: 'Tags', + description: 'Tags should exist', + field: 'metadata.tags', + }), + }), + ), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const tagsMetric = metrics?.find(m => m.id === 'catalog.tags'); + const missedRule = tagsMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalog.tags')).toBe(expectedCode); + }); + + it('should handle multiple metrics on the same entity', async () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader( + buildConfig({ + title: titleMetric(), + lifecycle: lifecycleMetric(), + }), + ), + ); + const result = await provider?.calculateMetrics(componentEntity); + + expect(result?.has('catalog.title')).toBe(true); + expect(result?.has('catalog.lifecycle')).toBe(true); + }); + + it('should apply options-level status mapping to all metrics', async () => { + const config = new ConfigReader( + buildConfig( + { title: titleMetric() }, + { + exists: 'present', + missed: 'absent', + emptyString: 'absent', + emptyArray: 'absent', + empty: 'absent', + }, + ), + ); + const provider = createCatalogRequiredAttributesMetricProvider(config); + const metrics = provider?.getMetrics(); + + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const keys = titleMet?.thresholds.rules.map(r => r.key); + expect(keys).toContain('present'); + expect(keys).toContain('absent'); + }); + + it('should override options-level mapping with metric-level mapping', async () => { + const config = new ConfigReader( + buildConfig( + { + title: titleMetric({ + statusMapping: { + exists: 'metric-present', + }, + }), + }, + { + exists: 'options-present', + }, + ), + ); + const provider = createCatalogRequiredAttributesMetricProvider(config); + const metrics = provider?.getMetrics(); + + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const keys = titleMet?.thresholds.rules.map(r => r.key); + expect(keys).toContain('metric-present'); + expect(keys).not.toContain('options-present'); + }); + + it('should apply metric filter with empty filter matching all entities', async () => { + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader(buildConfig({ title: titleMetric({ filter: {} }) })), + ); + + const componentResult = await provider?.calculateMetrics(componentEntity); + expect(componentResult?.has('catalog.title')).toBe(true); + + const templateResult = await provider?.calculateMetrics(templateEntity); + expect(templateResult?.has('catalog.title')).toBe(true); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts new file mode 100644 index 0000000000..6aa8b84ecb --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts @@ -0,0 +1,315 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import type { Config } from '@backstage/config'; +import { + Metric, + ScorecardThresholdRuleColors, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { + type CatalogRequiredAttributesConfig, + type MetricConfig, + type StatusMapping, + parseCatalogRequiredAttributesConfig, +} from './CatalogRequiredAttributesConfig'; + +/** Sentinel for a field path that does not resolve. */ +const NOT_FOUND = Symbol('NOT_FOUND'); + +/** + * Resolves a dotted field path on an entity object. + * Returns the value at the path, or NOT_FOUND if the path does not resolve. + */ +export function resolveFieldPath( + entity: Entity, + path: string, +): unknown | typeof NOT_FOUND { + const parts = path.split('.'); + let current: unknown = entity; + for (const part of parts) { + if ( + current === null || + current === undefined || + typeof current !== 'object' + ) { + return NOT_FOUND; + } + if (!(part in (current as Record))) { + return NOT_FOUND; + } + current = (current as Record)[part]; + } + return current; +} + +/** + * Determines the status string for a field value using the status mapping. + */ +export function evaluateFieldStatus( + entity: Entity, + field: string, + statusMapping: StatusMapping, +): string { + const value = resolveFieldPath(entity, field); + + if (value === NOT_FOUND) { + return statusMapping.missed; + } + + if (value === null || value === undefined) { + return statusMapping.empty; + } + + if (typeof value === 'string' && value === '') { + return statusMapping.emptyString; + } + + if (Array.isArray(value) && value.length === 0) { + return statusMapping.emptyArray; + } + + // Field exists with a non-empty value — check for specific value matches + if ( + typeof value === 'string' && + Object.keys(statusMapping.values).length > 0 && + value in statusMapping.values + ) { + return statusMapping.values[value]; + } + + return statusMapping.exists; +} + +/** + * Returns whether an entity matches a check's filter. + * An empty filter matches all entities. + * Each key in the filter is a dotted field path; the entity's field + * value (stringified) must equal the filter value (case-insensitive). + */ +export function entityMatchesFilter( + entity: Entity, + filter: Record, +): boolean { + for (const [path, expected] of Object.entries(filter)) { + const value = resolveFieldPath(entity, path); + if (value === NOT_FOUND) { + return false; + } + if (String(value).toLowerCase() !== String(expected).toLowerCase()) { + return false; + } + } + return true; +} + +/** + * Collects all distinct status strings from a StatusMapping. + */ +function collectDistinctStatuses(statusMapping: StatusMapping): string[] { + const statuses = new Set(); + statuses.add(statusMapping.exists); + statuses.add(statusMapping.empty); + statuses.add(statusMapping.emptyString); + statuses.add(statusMapping.emptyArray); + statuses.add(statusMapping.missed); + for (const value of Object.values(statusMapping.values)) { + statuses.add(value); + } + return [...statuses]; +} + +/** + * Builds a mapping from status strings to numeric codes and generates + * threshold rules that map those codes back to status strings. + */ +function buildStatusCodeMapping(statusMapping: StatusMapping): { + statusToCode: Map; + thresholds: ThresholdConfig; +} { + const statuses = collectDistinctStatuses(statusMapping); + const statusToCode = new Map(); + + statuses.forEach((status, index) => { + statusToCode.set(status, index); + }); + + const rules = statuses.map((status, index) => ({ + key: status, + expression: `==${index}`, + color: getDefaultColor(status), + icon: getDefaultIcon(status), + })); + + return { statusToCode, thresholds: { rules } }; +} + +/** + * Returns a default color for well-known status strings. + */ +function getDefaultColor(status: string): string { + switch (status.toLowerCase()) { + case 'found': + case 'ok': + case 'success': + case 'valid': + return ScorecardThresholdRuleColors.SUCCESS; + case 'missed': + case 'invalid': + case 'error': + case 'failed': + return ScorecardThresholdRuleColors.ERROR; + case 'warning': + return ScorecardThresholdRuleColors.WARNING; + default: + return ScorecardThresholdRuleColors.WARNING; + } +} + +/** + * Returns a default icon for well-known status strings. + */ +function getDefaultIcon(status: string): string { + switch (status.toLowerCase()) { + case 'found': + case 'ok': + case 'success': + case 'valid': + return 'scorecardSuccessStatusIcon'; + case 'missed': + case 'invalid': + case 'error': + case 'failed': + return 'scorecardErrorStatusIcon'; + default: + return 'scorecardWarningStatusIcon'; + } +} + +export class CatalogRequiredAttributesMetricProvider + implements MetricProvider<'number'> +{ + private readonly metricConfigs: MetricConfig[]; + private readonly statusCodeMappings: Map< + string, + { statusToCode: Map; thresholds: ThresholdConfig } + >; + + constructor( + catalogRequiredAttributesConfig: CatalogRequiredAttributesConfig, + ) { + this.metricConfigs = catalogRequiredAttributesConfig.metrics; + this.statusCodeMappings = new Map(); + for (const metric of this.metricConfigs) { + this.statusCodeMappings.set( + metric.id, + buildStatusCodeMapping(metric.statusMapping), + ); + } + } + + getProviderDatasourceId(): string { + return 'catalog'; + } + + getProviderId(): string { + return 'catalog.requiredAttributes'; + } + + getMetrics(): Metric<'number'>[] { + return this.metricConfigs.map(metric => { + const mapping = this.statusCodeMappings.get(metric.id)!; + return { + id: `catalog.${metric.id}`, + title: metric.title, + description: metric.description, + type: 'number' as const, + thresholds: mapping.thresholds, + }; + }); + } + + getCatalogFilter(): Record { + // Aggregate kind filters from all metrics for efficient catalog querying. + // If any metric has no filter or does not filter by kind, return an + // empty filter (all entities). + const kinds = new Set(); + let allHaveKind = true; + + for (const metric of this.metricConfigs) { + const kindValue = metric.filter.kind; + if (kindValue) { + kinds.add(kindValue.toLowerCase()); + } else { + allHaveKind = false; + } + } + + if (allHaveKind && kinds.size > 0) { + if (kinds.size === 1) { + return { kind: [...kinds][0] }; + } + return { kind: [...kinds] }; + } + + // If not all metrics filter by kind, return empty filter (all entities) + return {}; + } + + async calculateMetrics(entity: Entity): Promise> { + const results = new Map(); + + for (const metric of this.metricConfigs) { + // Apply per-metric filter + if (!entityMatchesFilter(entity, metric.filter)) { + continue; + } + + const status = evaluateFieldStatus( + entity, + metric.field, + metric.statusMapping, + ); + + const mapping = this.statusCodeMappings.get(metric.id)!; + const code = mapping.statusToCode.get(status); + if (code !== undefined) { + results.set(`catalog.${metric.id}`, code); + } + } + + return results; + } +} + +/** + * Creates a CatalogRequiredAttributesMetricProvider from root Backstage config. + * Returns undefined if no metrics are configured. + */ +export function createCatalogRequiredAttributesMetricProvider( + config: Config, +): CatalogRequiredAttributesMetricProvider | undefined { + const catalogRequiredAttributesConfig = + parseCatalogRequiredAttributesConfig(config); + if (!catalogRequiredAttributesConfig) { + return undefined; + } + return new CatalogRequiredAttributesMetricProvider( + catalogRequiredAttributesConfig, + ); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts new file mode 100644 index 0000000000..42df5b0ee1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts @@ -0,0 +1,40 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { createCatalogRequiredAttributesMetricProvider } from './metricProviders/CatalogRequiredAttributesMetricProvider'; + +export const scorecardModuleCatalog = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'catalog', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + metrics: scorecardMetricsExtensionPoint, + }, + async init({ config, metrics }) { + const provider = createCatalogRequiredAttributesMetricProvider(config); + if (provider) { + metrics.addMetricProvider(provider); + } + }, + }); + }, +}); diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 107b9a7697..28a734655f 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -9605,6 +9605,20 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog@workspace:plugins/scorecard-backend-module-catalog": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog@workspace:plugins/scorecard-backend-module-catalog" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/backend-test-utils": "npm:^1.11.4" + "@backstage/catalog-model": "npm:^1.9.0" + "@backstage/cli": "npm:^0.36.3" + "@backstage/config": "npm:^1.3.8" + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage"