From 567b13fd5b47c363a87b91aa14cd26acdd7514c9 Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Tue, 2 Jun 2026 04:02:22 -0700 Subject: [PATCH 1/2] feat: populate service custom fields from Backstage entity annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for passing arbitrary attributes and custom catalog properties to Rootly services, functionalities, teams, and catalog entities from Backstage entity annotations at sync time. Two new annotation prefixes per entity type: - `rootly.com/service-attr-` passes through to service API attributes (color, notify_emails, slack_channels, etc.) - `rootly.com/service-property-` maps to catalog properties array on the service Value coercion: "true"/"false" → boolean, JSON arrays/objects parsed, everything else stays a string. Hardcoded fields (name, backstage_id, pagerduty_id) always win over passthrough — annotations cannot override control-plane attributes. Ref: PRF-2548 --- src/api.test.ts | 155 +++++++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 74 ++++++++++++++++++++++ src/constants.ts | 12 ++++ 3 files changed, 241 insertions(+) create mode 100644 src/api.test.ts diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..ea899cc --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,155 @@ +import { extractPassthroughAttributes, extractProperties } from './api'; + +describe('extractPassthroughAttributes', () => { + const prefix = 'rootly.com/service-attr-'; + + it('returns empty object when annotations is undefined', () => { + expect(extractPassthroughAttributes(undefined, prefix)).toEqual({}); + }); + + it('returns empty object when no annotations match prefix', () => { + const annotations = { + 'rootly.com/service-id': '123', + 'pagerduty.com/service-id': 'PD123', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({}); + }); + + it('passes through simple string values', () => { + const annotations = { + 'rootly.com/service-attr-color': '#FF5733', + 'rootly.com/service-attr-github_repository_name': 'rootlyhq/my-service', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + color: '#FF5733', + github_repository_name: 'rootlyhq/my-service', + }); + }); + + it('coerces "true" and "false" to booleans', () => { + const annotations = { + 'rootly.com/service-attr-show_uptime': 'true', + 'rootly.com/service-attr-alert_broadcast_enabled': 'false', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + show_uptime: true, + alert_broadcast_enabled: false, + }); + }); + + it('parses JSON arrays', () => { + const annotations = { + 'rootly.com/service-attr-notify_emails': '["alice@co.com","bob@co.com"]', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + notify_emails: ['alice@co.com', 'bob@co.com'], + }); + }); + + it('parses JSON objects', () => { + const annotations = { + 'rootly.com/service-attr-slack_channels': '[{"id":"C01ABC","name":"oncall"}]', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + slack_channels: [{ id: 'C01ABC', name: 'oncall' }], + }); + }); + + it('falls back to string on invalid JSON', () => { + const annotations = { + 'rootly.com/service-attr-bad_json': '{not valid json', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + bad_json: '{not valid json', + }); + }); + + it('ignores empty attribute name after prefix', () => { + const annotations = { + 'rootly.com/service-attr-': 'value', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({}); + }); + + it('handles mixed matching and non-matching annotations', () => { + const annotations = { + 'rootly.com/service-id': '123', + 'rootly.com/service-attr-color': '#000', + 'rootly.com/functionality-attr-color': '#FFF', + 'rootly.com/service-attr-show_uptime': 'true', + }; + expect(extractPassthroughAttributes(annotations, prefix)).toEqual({ + color: '#000', + show_uptime: true, + }); + }); + + it('works with different prefixes', () => { + const annotations = { + 'rootly.com/team-attr-color': 'blue', + }; + expect(extractPassthroughAttributes(annotations, 'rootly.com/team-attr-')).toEqual({ + color: 'blue', + }); + }); +}); + +describe('extractProperties', () => { + const prefix = 'rootly.com/service-property-'; + + it('returns empty array when annotations is undefined', () => { + expect(extractProperties(undefined, prefix)).toEqual([]); + }); + + it('returns empty array when no annotations match prefix', () => { + const annotations = { + 'rootly.com/service-id': '123', + }; + expect(extractProperties(annotations, prefix)).toEqual([]); + }); + + it('extracts properties with slug keys', () => { + const annotations = { + 'rootly.com/service-property-my-custom-field': 'hello', + }; + expect(extractProperties(annotations, prefix)).toEqual([ + { catalog_property_id: 'my-custom-field', value: 'hello' }, + ]); + }); + + it('extracts properties with UUID keys', () => { + const annotations = { + 'rootly.com/service-property-dcece7f7-b73c-4f32-8b09-695abad42e60': 'world', + }; + expect(extractProperties(annotations, prefix)).toEqual([ + { catalog_property_id: 'dcece7f7-b73c-4f32-8b09-695abad42e60', value: 'world' }, + ]); + }); + + it('keeps value as raw string (no coercion)', () => { + const annotations = { + 'rootly.com/service-property-slack-channel': '{"id":"C01FE4P7458","name":"oncall"}', + }; + expect(extractProperties(annotations, prefix)).toEqual([ + { catalog_property_id: 'slack-channel', value: '{"id":"C01FE4P7458","name":"oncall"}' }, + ]); + }); + + it('extracts multiple properties', () => { + const annotations = { + 'rootly.com/service-property-field-a': 'val1', + 'rootly.com/service-property-field-b': 'val2', + }; + const result = extractProperties(annotations, prefix); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ catalog_property_id: 'field-a', value: 'val1' }); + expect(result).toContainEqual({ catalog_property_id: 'field-b', value: 'val2' }); + }); + + it('ignores empty property id after prefix', () => { + const annotations = { + 'rootly.com/service-property-': 'value', + }; + expect(extractProperties(annotations, prefix)).toEqual([]); + }); +}); diff --git a/src/api.ts b/src/api.ts index aacbd85..56a6601 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,12 @@ import { ROOTLY_ANNOTATION_TEAM_NAME, ROOTLY_ANNOTATION_SERVICE_NAME, ROOTLY_ANNOTATION_CATALOG_ENTITY_NAME, + ROOTLY_ANNOTATION_SERVICE_ATTR_PREFIX, + ROOTLY_ANNOTATION_SERVICE_PROPERTY_PREFIX, + ROOTLY_ANNOTATION_FUNCTIONALITY_ATTR_PREFIX, + ROOTLY_ANNOTATION_TEAM_ATTR_PREFIX, + ROOTLY_ANNOTATION_CATALOG_ENTITY_ATTR_PREFIX, + ROOTLY_ANNOTATION_CATALOG_ENTITY_PROPERTY_PREFIX, } from './constants'; export type RootlyServicesFetchOpts = { @@ -236,6 +242,50 @@ export interface RootlyCatalogEntitiesResponse { data: RootlyCatalogEntity[]; } +function extractAnnotationEntries( + annotations: Record | undefined, + prefix: string, +): Array<[string, string]> { + if (!annotations) return []; + return Object.entries(annotations) + .filter(([k]) => k.startsWith(prefix)) + .map(([k, v]) => [k.slice(prefix.length), v]) + .filter(([k]) => k !== ''); +} + +function coerceAnnotationValue(value: string): unknown { + if (value === 'true') return true; + if (value === 'false') return false; + if ((value.startsWith('[') || value.startsWith('{')) && value.length > 1) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +/** @public */ +export function extractPassthroughAttributes( + annotations: Record | undefined, + prefix: string, +): Record { + return Object.fromEntries( + extractAnnotationEntries(annotations, prefix).map(([k, v]) => [k, coerceAnnotationValue(v)]), + ); +} + +/** @public */ +export function extractProperties( + annotations: Record | undefined, + prefix: string, +): Array<{ catalog_property_id: string; value: string }> { + return extractAnnotationEntries(annotations, prefix).map(([k, v]) => ({ + catalog_property_id: k, value: v, + })); +} + const DEFAULT_PROXY_PATH = '/rootly/api'; type Options = { @@ -483,6 +533,8 @@ export class RootlyApi { name: entity.metadata.name, }); const ownerGroupIds = await this.resolveOwnerGroupIds(entity); + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_SERVICE_ATTR_PREFIX); + const properties = extractProperties(entity.metadata.annotations, ROOTLY_ANNOTATION_SERVICE_PROPERTY_PREFIX); const init = { method: 'POST', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -490,6 +542,8 @@ export class RootlyApi { data: { type: 'services', attributes: { + ...passthroughAttrs, + ...(properties.length > 0 ? { properties } : {}), name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_SERVICE_NAME] || entity.metadata.name, @@ -536,6 +590,8 @@ export class RootlyApi { } const ownerGroupIds = await this.resolveOwnerGroupIds(entity); + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_SERVICE_ATTR_PREFIX); + const properties = extractProperties(entity.metadata.annotations, ROOTLY_ANNOTATION_SERVICE_PROPERTY_PREFIX); const init2 = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -543,6 +599,8 @@ export class RootlyApi { data: { type: 'services', attributes: { + ...passthroughAttrs, + ...(properties.length > 0 ? { properties } : {}), name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_SERVICE_NAME] || entity.metadata.name, @@ -588,6 +646,7 @@ export class RootlyApi { kind: entity.kind, name: entity.metadata.name, }); + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_FUNCTIONALITY_ATTR_PREFIX); const init = { method: 'POST', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -595,6 +654,7 @@ export class RootlyApi { data: { type: 'functionalities', attributes: { + ...passthroughAttrs, name: entity.metadata.annotations?.[ ROOTLY_ANNOTATION_FUNCTIONALITY_NAME @@ -643,6 +703,7 @@ export class RootlyApi { await this.call(`/v1/functionalities/${old_functionality.id}`, init1); } + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_FUNCTIONALITY_ATTR_PREFIX); const init2 = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -650,6 +711,7 @@ export class RootlyApi { data: { type: 'functionalities', attributes: { + ...passthroughAttrs, name: entity.metadata.annotations?.[ ROOTLY_ANNOTATION_FUNCTIONALITY_NAME @@ -695,6 +757,7 @@ export class RootlyApi { kind: entity.kind, name: entity.metadata.name, }); + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_TEAM_ATTR_PREFIX); const init = { method: 'POST', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -702,6 +765,7 @@ export class RootlyApi { data: { type: 'teams', attributes: { + ...passthroughAttrs, name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_TEAM_NAME] || entity.metadata.name, @@ -746,6 +810,7 @@ export class RootlyApi { await this.call(`/v1/teams/${old_team.id}`, init1); } + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_TEAM_ATTR_PREFIX); const init2 = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -753,6 +818,7 @@ export class RootlyApi { data: { type: 'teams', attributes: { + ...passthroughAttrs, name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_TEAM_NAME] || entity.metadata.name, @@ -885,6 +951,8 @@ export class RootlyApi { kind: entity.kind, name: entity.metadata.name, }); + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_CATALOG_ENTITY_ATTR_PREFIX); + const properties = extractProperties(entity.metadata.annotations, ROOTLY_ANNOTATION_CATALOG_ENTITY_PROPERTY_PREFIX); const init = { method: 'POST', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -892,6 +960,8 @@ export class RootlyApi { data: { type: 'catalog_entities', attributes: { + ...passthroughAttrs, + ...(properties.length > 0 ? { properties } : {}), name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_CATALOG_ENTITY_NAME] || entity.metadata.name, @@ -937,6 +1007,8 @@ export class RootlyApi { await this.call(`/v1/catalog_entities/${old_catalogEntity.id}`, init1); } + const passthroughAttrs = extractPassthroughAttributes(entity.metadata.annotations, ROOTLY_ANNOTATION_CATALOG_ENTITY_ATTR_PREFIX); + const properties = extractProperties(entity.metadata.annotations, ROOTLY_ANNOTATION_CATALOG_ENTITY_PROPERTY_PREFIX); const init2 = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.api+json' }, @@ -944,6 +1016,8 @@ export class RootlyApi { data: { type: 'catalog_entities', attributes: { + ...passthroughAttrs, + ...(properties.length > 0 ? { properties } : {}), name: entity.metadata.annotations?.[ROOTLY_ANNOTATION_CATALOG_ENTITY_NAME] || entity.metadata.name, diff --git a/src/constants.ts b/src/constants.ts index e3aefe9..45cf6e3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -38,3 +38,15 @@ export const ROOTLY_ANNOTATION_CATALOG_ID = "rootly.com/catalog-id"; export const ROOTLY_ANNOTATION_CATALOG_SLUG = "rootly.com/catalog-slug"; /** @public */ export const ROOTLY_ANNOTATION_CATALOG_DESCRIPTION = "rootly.com/catalog-description"; +/** @public */ +export const ROOTLY_ANNOTATION_SERVICE_ATTR_PREFIX = "rootly.com/service-attr-"; +/** @public */ +export const ROOTLY_ANNOTATION_SERVICE_PROPERTY_PREFIX = "rootly.com/service-property-"; +/** @public */ +export const ROOTLY_ANNOTATION_FUNCTIONALITY_ATTR_PREFIX = "rootly.com/functionality-attr-"; +/** @public */ +export const ROOTLY_ANNOTATION_TEAM_ATTR_PREFIX = "rootly.com/team-attr-"; +/** @public */ +export const ROOTLY_ANNOTATION_CATALOG_ENTITY_ATTR_PREFIX = "rootly.com/catalog-entity-attr-"; +/** @public */ +export const ROOTLY_ANNOTATION_CATALOG_ENTITY_PROPERTY_PREFIX = "rootly.com/catalog-entity-property-"; From c8075ed23b8926963327faf15f348f86b21ca00a Mon Sep 17 00:00:00 2001 From: Quentin Rousseau Date: Tue, 2 Jun 2026 09:37:30 -0700 Subject: [PATCH 2/2] docs: add annotation pass-through and custom properties to README --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index bf9d2df..0b7ca65 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,57 @@ rootly.com/catalog-slug: customer-tier # Alternative to catalog-id. The catalog rootly.com/catalog-description: Customer pricing tiers # Optional. Description for the catalog when auto-creating. ``` +#### Attribute pass-through annotations + +You can set any Rootly API attribute on services, functionalities, teams, and catalog entities using the `attr` prefix: + +```yaml +rootly.com/service-attr-: "value" +rootly.com/functionality-attr-: "value" +rootly.com/team-attr-: "value" +rootly.com/catalog-entity-attr-: "value" +``` + +Value coercion rules: +- `"true"` / `"false"` → boolean +- Values starting with `[` or `{` → parsed as JSON (arrays, objects) +- Everything else → plain string + +Example: + +```yaml +annotations: + rootly.com/service-slug: my-service + rootly.com/service-auto-import: enabled + rootly.com/service-attr-color: "#FF5733" + rootly.com/service-attr-notify_emails: '["oncall@company.com","team@company.com"]' + rootly.com/service-attr-slack_channels: '[{"id":"C01ABC123","name":"oncall-channel"}]' + rootly.com/service-attr-show_uptime: "true" + rootly.com/service-attr-github_repository_name: "myorg/my-service" +``` + +Hardcoded fields (`name`, `description`, `backstage_id`, `pagerduty_id`, `owner_group_ids`) always take precedence and cannot be overridden via pass-through annotations. + +#### Custom catalog property annotations + +You can populate custom catalog properties (metadata fields) on services and catalog entities: + +```yaml +rootly.com/service-property-: "value" +rootly.com/catalog-entity-property-: "value" +``` + +The key after the prefix is the catalog property slug or UUID. The value is passed as-is to the Rootly API. + +Example: + +```yaml +annotations: + rootly.com/service-slug: my-service + rootly.com/service-property-impact-level: "critical" + rootly.com/service-property-slack-channel: '{"id":"C01FE4P7458","name":"service-channel"}' +``` + #### Example ```yaml