diff --git a/.changeset/skip-template-generation-annotation.md b/.changeset/skip-template-generation-annotation.md new file mode 100644 index 000000000..5d28ad9fa --- /dev/null +++ b/.changeset/skip-template-generation-annotation.md @@ -0,0 +1,6 @@ +--- +'@openchoreo/backstage-plugin-catalog-backend-module': minor +'@openchoreo/openchoreo-client-node': minor +--- + +support opting a (Cluster)ComponentType or (Cluster)ResourceType out of auto-generated scaffolder Template (create card) emission via the `openchoreo.dev/skip-template-generation: "true"` annotation. Both the periodic full sync and the event-driven delta path honor it; the delta path actively removes a previously emitted Template when the annotation is added to a live resource. Useful when a type is served by a hand-authored template or is not meant to be user-creatable from the portal. diff --git a/packages/openchoreo-client-node/src/index.ts b/packages/openchoreo-client-node/src/index.ts index d893ad949..2efda8849 100644 --- a/packages/openchoreo-client-node/src/index.ts +++ b/packages/openchoreo-client-node/src/index.ts @@ -44,6 +44,8 @@ export { getAnnotation, getDisplayName, getDescription, + SKIP_TEMPLATE_GENERATION_ANNOTATION, + isTemplateGenerationSkipped, getConditions, getCondition, getConditionStatus, diff --git a/packages/openchoreo-client-node/src/resource-utils.test.ts b/packages/openchoreo-client-node/src/resource-utils.test.ts index 185155601..fbe215cb2 100644 --- a/packages/openchoreo-client-node/src/resource-utils.test.ts +++ b/packages/openchoreo-client-node/src/resource-utils.test.ts @@ -9,6 +9,8 @@ import { getAnnotation, getDisplayName, getDescription, + isTemplateGenerationSkipped, + SKIP_TEMPLATE_GENERATION_ANNOTATION, getConditions, getCondition, getConditionStatus, @@ -176,6 +178,40 @@ describe('getDisplayName', () => { }); }); +describe('isTemplateGenerationSkipped', () => { + it('returns true when the annotation is "true"', () => { + const skipped = { + metadata: { + name: 'api-proxy', + annotations: { [SKIP_TEMPLATE_GENERATION_ANNOTATION]: 'true' }, + }, + }; + expect(isTemplateGenerationSkipped(skipped)).toBe(true); + }); + + it('returns false for any other value', () => { + const explicitFalse = { + metadata: { + name: 'api-proxy', + annotations: { [SKIP_TEMPLATE_GENERATION_ANNOTATION]: 'false' }, + }, + }; + expect(isTemplateGenerationSkipped(explicitFalse)).toBe(false); + const nonBoolean = { + metadata: { + name: 'api-proxy', + annotations: { [SKIP_TEMPLATE_GENERATION_ANNOTATION]: 'yes' }, + }, + }; + expect(isTemplateGenerationSkipped(nonBoolean)).toBe(false); + }); + + it('returns false when the annotation is absent', () => { + expect(isTemplateGenerationSkipped(fullResource)).toBe(false); + expect(isTemplateGenerationSkipped(emptyResource)).toBe(false); + }); +}); + describe('getDescription', () => { it('returns description annotation', () => { expect(getDescription(fullResource)).toBe('A test resource'); diff --git a/packages/openchoreo-client-node/src/resource-utils.ts b/packages/openchoreo-client-node/src/resource-utils.ts index 105424845..225988ee0 100644 --- a/packages/openchoreo-client-node/src/resource-utils.ts +++ b/packages/openchoreo-client-node/src/resource-utils.ts @@ -77,6 +77,25 @@ export function getAnnotation( return resource.metadata?.annotations?.[key]; } +/** + * Annotation that opts a (Cluster)ComponentType or (Cluster)ResourceType out + * of auto-generated scaffolder Template (create card) emission. Set it to + * `"true"` on the resource to hide its create card, e.g. when the type is + * served by a hand-authored template or is not meant to be user-creatable. + */ +export const SKIP_TEMPLATE_GENERATION_ANNOTATION = + 'openchoreo.dev/skip-template-generation'; + +/** + * Returns true when the resource carries + * {@link SKIP_TEMPLATE_GENERATION_ANNOTATION} set to `"true"`. + */ +export function isTemplateGenerationSkipped(resource: HasMetadata): boolean { + return ( + getAnnotation(resource, SKIP_TEMPLATE_GENERATION_ANNOTATION) === 'true' + ); +} + // --------------------------------------------------------------------------- // Display helpers // --------------------------------------------------------------------------- diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts index 3f5533eba..139bd5f67 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts @@ -718,4 +718,121 @@ describe('EventDeltaApplier.handleEvent', () => { expect.stringContaining('no CatalogService is wired'), ); }); + + describe('skip-template-generation annotation', () => { + function ok(data: any) { + return { data, error: undefined, response: { ok: true, status: 200 } }; + } + + const cctBase = { + metadata: { + name: 'api-proxy', + uid: 'uid-api-proxy', + creationTimestamp: '2025-01-06T10:00:00Z', + annotations: { 'openchoreo.dev/display-name': 'API Proxy' }, + }, + spec: { workloadType: 'proxy' }, + }; + + it('upserts the ClusterComponentType but actively removes its Template when annotated', async () => { + const applier = newApplier(connection); + const annotated = { + ...cctBase, + metadata: { + ...cctBase.metadata, + annotations: { + ...cctBase.metadata.annotations, + 'openchoreo.dev/skip-template-generation': 'true', + }, + }, + }; + mockGET.mockImplementation((path: string) => + Promise.resolve( + path.includes('/schema') + ? ok({ type: 'object', properties: {} }) + : ok(annotated), + ), + ); + + await applier.handleEvent( + 'ClusterComponentType', + 'api-proxy', + undefined, + 'updated', + ); + + // First mutation: upsert of the CCT entity only — no Template. + const upsert = applyMutation.mock.calls[0][0]; + expect(upsert.added.map((e: any) => e.entity.kind)).toEqual([ + 'ClusterComponentType', + ]); + // Second mutation: active removal of the (possibly pre-existing) + // Template — the annotation may have been added after emission. + const removal = applyMutation.mock.calls[1][0]; + expect(removal.removed.map((r: any) => r.entityRef)).toEqual([ + 'template:openchoreo-cluster/template-api-proxy', + ]); + }); + + it('still emits the Template when the annotation is absent', async () => { + const applier = newApplier(connection); + mockGET.mockImplementation((path: string) => + Promise.resolve( + path.includes('/schema') + ? ok({ type: 'object', properties: {} }) + : ok(cctBase), + ), + ); + + await applier.handleEvent( + 'ClusterComponentType', + 'api-proxy', + undefined, + 'updated', + ); + + expect(applyMutation).toHaveBeenCalledTimes(1); + const kinds = applyMutation.mock.calls[0][0].added.map( + (e: any) => e.entity.kind, + ); + expect(kinds.sort()).toEqual(['ClusterComponentType', 'Template']); + }); + + it('removes the namespaced Template when an annotated ComponentType is refreshed', async () => { + const applier = newApplier(connection); + const annotatedCt = { + metadata: { + name: 'hidden-ct', + uid: 'uid-hidden-ct', + namespace: 'test-ns', + creationTimestamp: '2025-01-06T10:00:00Z', + annotations: { 'openchoreo.dev/skip-template-generation': 'true' }, + }, + spec: { workloadType: 'deployment' }, + }; + mockGET.mockImplementation((path: string) => + Promise.resolve( + path.includes('/schema') + ? ok({ type: 'object', properties: {} }) + : ok(annotatedCt), + ), + ); + + await applier.handleEvent( + 'ComponentType', + 'hidden-ct', + 'test-ns', + 'updated', + ); + + const upsert = applyMutation.mock.calls[0][0]; + expect(upsert.added.map((e: any) => e.entity.kind)).toEqual([ + 'ComponentType', + ]); + const removal = applyMutation.mock.calls[1][0]; + expect(removal.removed.map((r: any) => r.entityRef)).toEqual([ + 'template:test-ns/template-hidden-ct', + ]); + }); + }); }); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts index a1f29275e..735d87847 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts @@ -51,6 +51,8 @@ import { getDescription, getDisplayName, getName, + isTemplateGenerationSkipped, + SKIP_TEMPLATE_GENERATION_ANNOTATION, } from '@openchoreo/openchoreo-client-node'; import { CtdToTemplateConverter } from '../converters/CtdToTemplateConverter'; import { RtdToTemplateConverter } from '../converters/RtdToTemplateConverter'; @@ -1065,6 +1067,18 @@ export class EventDeltaApplier { ns, this.translatorContext, ); + if (isTemplateGenerationSkipped(ct)) { + // Annotation may have been added after the Template was emitted, so + // actively remove it rather than just skipping the upsert. + this.logger.debug( + `Template generation skipped for ComponentType ${ns}/${name} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + await this.upsertEntities([ctEntity]); + await this.removeEntityRefs([ + this.buildEntityRef('template', ns, `template-${name}`), + ]); + return; + } const templateEntity = await this.buildComponentTypeTemplateEntity( client, ns, @@ -1105,6 +1119,16 @@ export class EventDeltaApplier { ns, this.translatorContext, ) as Entity; + if (isTemplateGenerationSkipped(rt)) { + this.logger.debug( + `Template generation skipped for ResourceType ${ns}/${name} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + await this.upsertEntities([rtEntity]); + await this.removeEntityRefs([ + this.buildEntityRef('template', ns, `template-resource-${name}`), + ]); + return; + } const templateEntity = await this.buildResourceTypeTemplateEntity( client, ns, @@ -1183,6 +1207,20 @@ export class EventDeltaApplier { cct, this.translatorContext, ) as Entity; + if (isTemplateGenerationSkipped(cct)) { + this.logger.debug( + `Template generation skipped for ClusterComponentType ${name} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + await this.upsertEntities([cctEntity]); + await this.removeEntityRefs([ + this.buildEntityRef( + 'template', + 'openchoreo-cluster', + `template-${name}`, + ), + ]); + return; + } const templateEntity = await this.buildClusterComponentTypeTemplateEntity( client, cct, @@ -1211,6 +1249,20 @@ export class EventDeltaApplier { crt, this.translatorContext, ) as Entity; + if (isTemplateGenerationSkipped(crt)) { + this.logger.debug( + `Template generation skipped for ClusterResourceType ${name} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + await this.upsertEntities([crtEntity]); + await this.removeEntityRefs([ + this.buildEntityRef( + 'template', + 'openchoreo-cluster', + `template-resource-${name}`, + ), + ]); + return; + } const templateEntity = await this.buildClusterResourceTypeTemplateEntity( client, crt, diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts index 548100e12..83e0a6e79 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts @@ -1050,4 +1050,123 @@ describe('OpenChoreoEntityProvider', () => { expect((comp?.spec as any).owner).toBe('group:default/test-owner'); }); }); + + describe('skip-template-generation annotation', () => { + const skipAnnotation = { + 'openchoreo.dev/skip-template-generation': 'true', + }; + + const k8sHiddenComponentType = { + metadata: k8sMeta('hidden-ct', { + annotations: { + 'openchoreo.dev/display-name': 'Hidden CT', + ...skipAnnotation, + }, + }), + spec: { workloadType: 'deployment' }, + status: { conditions: [readyCondition] }, + }; + + const k8sHiddenResourceType = { + metadata: k8sMeta('hidden-rt', { + annotations: { + 'openchoreo.dev/display-name': 'Hidden RT', + ...skipAnnotation, + }, + }), + spec: { retainPolicy: 'Delete', resources: [] }, + status: { conditions: [readyCondition] }, + }; + + const k8sProxyClusterComponentType = { + metadata: { + name: 'api-proxy', + uid: 'uid-api-proxy', + creationTimestamp: '2025-01-06T10:00:00Z', + annotations: { + 'openchoreo.dev/display-name': 'API Proxy', + ...skipAnnotation, + }, + }, + spec: { workloadType: 'proxy' }, + status: { conditions: [readyCondition] }, + }; + + beforeEach(() => { + setupPathBasedMocks({ + '/api/v1/namespaces/{namespaceName}/componenttypes/{ctName}/schema': + okData({ type: 'object', properties: {} }), + '/api/v1/namespaces/{namespaceName}/componenttypes': okData({ + items: [k8sComponentType, k8sHiddenComponentType], + }), + '/api/v1/namespaces/{namespaceName}/resourcetypes/{rtName}/schema': + okData({ type: 'object', properties: {} }), + '/api/v1/namespaces/{namespaceName}/resourcetypes': okData({ + items: [k8sResourceType, k8sHiddenResourceType], + }), + '/api/v1/clustercomponenttypes/{cctName}/schema': okData({ + type: 'object', + properties: {}, + }), + '/api/v1/clustercomponenttypes': okData({ + items: [k8sProxyClusterComponentType], + }), + '/api/v1/namespaces': okData({ items: [k8sNamespace] }), + }); + }); + + it('skips Template emission for annotated ComponentTypes but keeps the ComponentType entity', async () => { + const entities = await runProvider(); + const templateNames = findEntities(entities, 'Template').map( + e => e.metadata.name, + ); + expect(templateNames).toContain('template-go-service'); + expect(templateNames).not.toContain('template-hidden-ct'); + expect( + findEntities(entities, 'ComponentType').map(e => e.metadata.name), + ).toEqual(expect.arrayContaining(['go-service', 'hidden-ct'])); + }); + + it('skips Template emission for annotated ClusterComponentTypes but keeps the ClusterComponentType entity', async () => { + const entities = await runProvider(); + const templateNames = findEntities(entities, 'Template').map( + e => e.metadata.name, + ); + expect(templateNames).not.toContain('template-api-proxy'); + expect( + findEntities(entities, 'ClusterComponentType').map( + e => e.metadata.name, + ), + ).toContain('api-proxy'); + }); + + it('skips Template emission for annotated ResourceTypes but keeps the ResourceType entity', async () => { + const entities = await runProvider(); + const templateNames = findEntities(entities, 'Template').map( + e => e.metadata.name, + ); + expect(templateNames).toContain('template-resource-postgres'); + expect(templateNames).not.toContain('template-resource-hidden-rt'); + expect( + findEntities(entities, 'ResourceType').map(e => e.metadata.name), + ).toEqual(expect.arrayContaining(['postgres', 'hidden-rt'])); + }); + + it('does not fetch the schema for skipped types', async () => { + await runProvider(); + const schemaCalls = mockGET.mock.calls.filter(([path]) => + String(path).includes('/schema'), + ); + const fetchedNames = schemaCalls.map( + ([, opts]) => + opts?.params?.path?.ctName ?? + opts?.params?.path?.cctName ?? + opts?.params?.path?.rtName ?? + opts?.params?.path?.crtName, + ); + expect(fetchedNames).not.toContain('hidden-ct'); + expect(fetchedNames).not.toContain('hidden-rt'); + expect(fetchedNames).not.toContain('api-proxy'); + }); + }); }); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts index 38d6e27e9..a0a1ecac6 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts @@ -17,6 +17,8 @@ import { getDescription, getDisplayName, getName, + isTemplateGenerationSkipped, + SKIP_TEMPLATE_GENERATION_ANNOTATION, type OpenChoreoComponents, } from '@openchoreo/openchoreo-client-node'; import { OpenChoreoTokenService } from '@openchoreo/openchoreo-auth'; @@ -786,6 +788,12 @@ export class OpenChoreoEntityProvider implements EntityProvider { componentTypes.map(async ct => { const ctName = getName(ct); if (!ctName) return null; + if (isTemplateGenerationSkipped(ct)) { + this.logger.debug( + `Skipping template generation for CTD ${ctName} in namespace ${nsName} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + return null; + } try { const { data: schemaData, error: schemaError } = await client.GET( @@ -977,6 +985,12 @@ export class OpenChoreoEntityProvider implements EntityProvider { resourceTypes.map(async rt => { const rtName = getName(rt); if (!rtName) return null; + if (isTemplateGenerationSkipped(rt)) { + this.logger.debug( + `Skipping template generation for ResourceType ${rtName} in namespace ${nsName} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + return null; + } try { const { data: schemaData, error: schemaError } = await client.GET( @@ -1182,6 +1196,12 @@ export class OpenChoreoEntityProvider implements EntityProvider { clusterComponentTypes.map(async cct => { const cctName = getName(cct); if (!cctName) return null; + if (isTemplateGenerationSkipped(cct)) { + this.logger.debug( + `Skipping template generation for ClusterComponentType ${cctName} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + return null; + } try { const { data: schemaData, error: schemaError } = await client.GET( '/api/v1/clustercomponenttypes/{cctName}/schema', @@ -1307,6 +1327,12 @@ export class OpenChoreoEntityProvider implements EntityProvider { clusterResourceTypes.map(async crt => { const crtName = getName(crt); if (!crtName) return null; + if (isTemplateGenerationSkipped(crt)) { + this.logger.debug( + `Skipping template generation for ClusterResourceType ${crtName} (${SKIP_TEMPLATE_GENERATION_ANNOTATION})`, + ); + return null; + } try { const { data: schemaData, error: schemaError } = await client.GET( '/api/v1/clusterresourcetypes/{crtName}/schema',