Programming/MSA

Backstage) Custom Relation, Custom Entity 적용하기

armyost 2026. 2. 10. 22:35
728x90

Backstage의 Entity 종류는 9 가지가 있고, 각자 고유한 정의를 따른다. Backstage 내의 Entity와 Relation은 Core library에서 어느정도 정의해서 제공하고 있다. 그러다 보니 단순이 app-config.yaml에서 allow되는 Entity 목록에 완전히 새로운걸 추가한다던지 그런 수준의 yaml만 수정해서는 새로운 Entity를 만들수 없다. 

 

 

Descriptor Format of Catalog Entities | Backstage Software Catalog and Developer Platform

Documentation on Descriptor Format of Catalog Entities which describes the default data shape and semantics of catalog entities

backstage.io

 

Core에 커스텀을 입힐 정도의 추가 개발이 필요하다. 많은 여러 시도를 통해 검증되었지만, 이것은 불가피하다. 

 

과연 무엇을 옵티마이징 해야할지 여기 공유하겠다. 

 

1. 새로운 Type의 Entity 정의하기

우선 내가 새로이 추가하고 싶은 Type의 Entity는 Feature이며, 이는 다음과 같은 속성을 가진다. 

# Feature entity - Custom entity kind
apiVersion: backstage.io/v1alpha1
kind: Feature
metadata:
  name: user-authentication
  namespace: default
  description: User authentication feature with OAuth2 and JWT support
spec:
  type: capability
  lifecycle: production
  owner: admin
  featureOf:
    - component:default/micro-service-a
  upstreamOf:
    - api:default/example-grpc-api

 

`micro-service-a` 라는 Component에 귀속되며 이의 관계는 ['featureOf''hasFeature'] 의 쌍을 가진다.

그리고 `example-grpc-api`라는 API와 ['upstreamOf''upstreamBy'] ['downstreamOf''downstreamBy'] 의 관계를 가지고 있다.

 

이 새로운 Entity Type을 사용하기 위해서는 Backend에서 Core를 호출하는 부분에서 수정이 필요하다. 

관련 Commit 1 : https://github.com/armyost/backstage-armyost/commit/74c5cbec521e13f5dd70ae377ea28f936b4d2dc0

 

debuging · armyost/backstage-armyost@f288b25

+ <EntityCatalogGraphCard variant="gridItem" renderNode={CustomRenderNode} height={400} kinds={["component", "system", "api", "resource"]} relationTypes={["featureOf","hasFeature"]}/>

github.com

 

관련 Commit 2: https://github.com/armyost/backstage-armyost/commit/0b29677ece3ed75c3ea13c8b866b86bb47f51882

 

update feature entity · armyost/backstage-armyost@0b29677

+ <EntityCatalogGraphCard variant="gridItem" renderNode={CustomRenderNode} height={400} kinds={["component", "system", "api", "resource", "feature"]} />

github.com

 

다음을 Backend에 추가해주어야 한다. 

 

packages/backend/src/plugins/catalogModuleCustomRelationProcessor.ts

import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { createBackendModule } from '@backstage/backend-plugin-api';
import { CustomRelationProcessor } from './CustomRelationProcessor';

/**
 * Module to register custom relation processor for the catalog
 * Processes: hasFeature, featureOf custom relations
 */
export const catalogModuleCustomRelationProcessor = createBackendModule({
  pluginId: 'catalog',
  moduleId: 'custom-relation-processor',
  register(env) {
    env.registerInit({
      deps: {
        catalog: catalogProcessingExtensionPoint,
      },
      async init({ catalog }) {
        catalog.addProcessor(new CustomRelationProcessor());
      },
    });
  },
});

export default catalogModuleCustomRelationProcessor;

 

 

packages/backend/src/plugins/catalogModuleFeatureEntityKind.ts

import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { createBackendModule } from '@backstage/backend-plugin-api';
import { CatalogProcessor, CatalogProcessorEmit } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';
import { LocationSpec } from '@backstage/plugin-catalog-common';

/**
 * Custom processor that validates and processes Feature entities
 */
class FeatureEntityProcessor implements CatalogProcessor {
  getProcessorName(): string {
    return 'FeatureEntityProcessor';
  }

  async validateEntityKind(entity: Entity): Promise<boolean> {
    return entity.kind === 'Feature';
  }

  async postProcessEntity(
    entity: Entity,
    _location: LocationSpec,
    _emit: CatalogProcessorEmit,
  ): Promise<Entity> {
    // Accept and pass through Feature entities
    if (entity.kind === 'Feature') {
      // Basic validation
      if (!entity.spec) {
        throw new Error('Feature entity must have a spec');
      }

      if (!entity.spec.type) {
        throw new Error('Feature entity must have a spec.type');
      }

      if (!entity.spec.lifecycle) {
        throw new Error('Feature entity must have a spec.lifecycle');
      }

      if (!entity.spec.owner) {
        throw new Error('Feature entity must have a spec.owner');
      }
    }

    return entity;
  }
}

/**
 * Module to register Feature entity kind processor
 */
export const catalogModuleFeatureEntityKind = createBackendModule({
  pluginId: 'catalog',
  moduleId: 'feature-entity-kind',
  register(env) {
    env.registerInit({
      deps: {
        catalog: catalogProcessingExtensionPoint,
      },
      async init({ catalog }) {
        catalog.addProcessor(new FeatureEntityProcessor());
      },
    });
  },
});

export default catalogModuleFeatureEntityKind;

 

packages/backend/src/plugins/CustomRelationProcessor.ts

import { CatalogProcessor, CatalogProcessorEmit, processingResult } from '@backstage/plugin-catalog-node';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { Entity } from '@backstage/catalog-model';

/**
 * Custom catalog processor that adds featureOf/hasFeature relations
 * based on custom fields in entity spec
 */
export class CustomRelationProcessor implements CatalogProcessor {
  getProcessorName(): string {
    return 'CustomRelationProcessor';
  }

  async preProcessEntity(
    entity: Entity,
    location: LocationSpec,
    emit: CatalogProcessorEmit,
  ): Promise<Entity> {
    // Handle hasFeature field
    const hasFeature = (entity.spec?.hasFeature as string[] | string | undefined);
    if (hasFeature) {
      const features = Array.isArray(hasFeature) ? hasFeature : [hasFeature];
      
      for (const targetRef of features) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'hasFeature',
          target: {
            kind: targetRef.split(':')[0] || 'Feature',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }
      
      // Remove from spec after processing to avoid validation errors
      delete entity.spec.hasFeature;
    }

    // Handle featureOf field
    const featureOf = (entity.spec?.featureOf as string[] | string | undefined);
    if (featureOf) {
      const components = Array.isArray(featureOf) ? featureOf : [featureOf];
      
      for (const targetRef of components) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'featureOf',
          target: {
            kind: targetRef.split(':')[0] || 'Component',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }
      
      // Remove from spec after processing to avoid validation errors
      delete entity.spec.featureOf;
    }

///// Up/Down Stream feature

    // Handle upstream field
    const upstreamOf = (entity.spec?.upstreamOf as string[] | string | undefined);
    if (upstreamOf) {
      const apis = Array.isArray(upstreamOf) ? upstreamOf : [upstreamOf];
      
      for (const targetRef of apis) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'upstreamOf',
          target: {
            kind: targetRef.split(':')[0] || 'Api',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }

      // Remove from spec after processing to avoid validation errors
      delete entity.spec.upstreamOf;
    }

          // Handle upstream field
    const upstreamBy = (entity.spec?.upstreamBy as string[] | string | undefined);
    if (upstreamBy) {
      const apis = Array.isArray(upstreamBy) ? upstreamBy : [upstreamBy];
      
      for (const targetRef of apis) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'upstreamBy',
          target: {
            kind: targetRef.split(':')[0] || 'Feature',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }
      
      // Remove from spec after processing to avoid validation errors
      delete entity.spec.upstreamBy;
    }

    // Handle upstream field
    const downstreamOf = (entity.spec?.downstreamOf as string[] | string | undefined);
    if (downstreamOf) {
      const apis = Array.isArray(downstreamOf) ? downstreamOf : [downstreamOf];
      
      for (const targetRef of apis) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'downstreamOf',
          target: {
            kind: targetRef.split(':')[0] || 'Api',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }
      
      // Remove from spec after processing to avoid validation errors
      delete entity.spec.downstreamOf;
    }

    // Handle upstream field
    const downstreamBy = (entity.spec?.downstreamBy as string[] | string | undefined);
    if (downstreamBy) {
      const apis = Array.isArray(downstreamBy) ? downstreamBy : [downstreamBy];
      
      for (const targetRef of apis) {
        emit(processingResult.relation({
          source: {
            kind: entity.kind,
            namespace: entity.metadata.namespace || 'default',
            name: entity.metadata.name,
          },
          type: 'downstreamBy',
          target: {
            kind: targetRef.split(':')[0] || 'Feature',
            namespace: targetRef.split('/')[0].split(':')[1] || 'default',
            name: targetRef.split('/')[1] || targetRef,
          },
        }));
      }
      
      // Remove from spec after processing to avoid validation errors
      delete entity.spec.downstreamBy;
    }

    return entity;
  }
}

 

 

그리고 위 정의한 Custom Processor를 Import 해야한다.

 

packages/backend/src/index.ts

// custom Feature entity kind
backend.add(import('./plugins/catalogModuleFeatureEntityKind'));

// custom catalog relation processor
backend.add(import('./plugins/catalogModuleCustomRelationProcessor'));

 

 

 

 

Entity의 Overview에서 Catalog Graph Card의 속성에서 기본적으로 표시할 Type을 선정할 수 있고 이중에 Feature를 추가하였다. 

 

packages/app/src/components/catalog/EntityPage.tsx

const overviewContent = (
  <Grid container spacing={3} alignItems="stretch">
    {entityWarningContent}
    <Grid item md={6}>
      <EntityAboutCard variant="gridItem" />
    </Grid>
    <Grid item md={6} xs={12}>
      <EntityCatalogGraphCard variant="gridItem" renderNode={CustomRenderNode} height={400} kinds={["component", "api", "resource", "feature"]}/>
    </Grid>
...
  </Grid>
);

 

packages/backend/src/plugins/FeatureEntityProvider.ts

import { Entity } from '@backstage/catalog-model';

/**
 * Feature Entity Kind definition
 * Validates Feature entities in the catalog
 */
export interface FeatureEntityV1alpha1 extends Entity {
  apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1';
  kind: 'Feature';
  spec: {
    type: string;
    lifecycle: string;
    owner: string;
    system?: string;
    featureOf?: string[];
    upstreamOf?: string[];
    downstreamOf?: string[];
  };
}

/**
 * Type guard for Feature entities
 */
export function isFeatureEntity(entity: Entity): entity is FeatureEntityV1alpha1 {
  return entity.kind === 'Feature';
}

 

 

 

Backstage에서 호환할 Entity Type 은 다음 필드에서 정의한다. catalog.rules.allow

 

app-config.yaml

catalog:
  rules:
    - allow: [Component, System, API, Resource, Location, Feature]

 

 

 

 

2. Catalog Graph에서 새로운 Relation을 추가하고 Default Choice를 수정하기

 

 

Catalog Graph에서 표시할 Default Setting은 다음과 같이 정의한다. 기본적으로 온보딩되어 있는 scmIntegrationsApi는 그대로 두며, backstage/plugin-catalog-graph 라이브러리를 사용하고 있는 catalogGraphApiRef 부분부터 추가해주면 된다.

        defaultRelationTypes: {
          // Don't exclude any custom relations - show them by default
          exclude: ['providesApi', 'consumesApi','apiConsumedBy','apiProvidedBy'],
        },

이 부분은 Default로 제외할 Relation을 정의한다. 

 

packages/app/src/apis.ts

import {
  ScmIntegrationsApi,
  scmIntegrationsApiRef,
  ScmAuth,
} from '@backstage/integration-react';
import {
  AnyApiFactory,
  configApiRef,
  createApiFactory,
} from '@backstage/core-plugin-api';
import {
  ALL_RELATIONS,
  ALL_RELATION_PAIRS,
  catalogGraphApiRef,
  DefaultCatalogGraphApi,
} from '@backstage/plugin-catalog-graph';

export const apis: AnyApiFactory[] = [
  createApiFactory({
    api: scmIntegrationsApiRef,
    deps: { configApi: configApiRef },
    factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
  }),
  ScmAuth.createDefaultApiFactory(),
  
  // Insert Custom Relation and default relation setting
  createApiFactory({
    api: catalogGraphApiRef,
    deps: {},
    factory: () =>
      new DefaultCatalogGraphApi({
        // The relations to support
        knownRelations: [...ALL_RELATIONS, 'featureOf', 'hasFeature','upstreamOf','downstreamOf', 'upstreamBy','downstreamBy'],
        // The relation pairs to support
        knownRelationPairs: [
          ...ALL_RELATION_PAIRS,
          ['featureOf', 'hasFeature'],
          ['upstreamOf', 'upstreamBy'],
          ['downstreamOf', 'downstreamBy'],
        ],
        // Select what relations to be shown by default
        defaultRelationTypes: {
          // Don't exclude any custom relations - show them by default
          exclude: ['providesApi', 'consumesApi','apiConsumedBy','apiProvidedBy'],
        },
      }),
  }),
];