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 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-";