Skip to content

Commit bc956d2

Browse files
authored
feat: populate custom fields from Backstage entity annotations (#14)
* feat: populate service custom fields from Backstage entity annotations 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-<api_attribute>` passes through to service API attributes (color, notify_emails, slack_channels, etc.) - `rootly.com/service-property-<slug_or_id>` 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 * docs: add annotation pass-through and custom properties to README * fix: prevent properties injection via service-attr- prefix Move `properties` into hardcoded override section of all 4 methods that support it (service import/update, catalog entity import/update). Previously, `rootly.com/service-attr-properties` could bypass the structured property annotation path because the conditional spread sat between passthrough and hardcoded fields. Now `properties` always overrides passthrough, matching the same protection as name/backstage_id/pagerduty_id. Add 2 safety tests documenting the override contract.
1 parent 7577102 commit bc956d2

4 files changed

Lines changed: 324 additions & 0 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,57 @@ rootly.com/catalog-slug: customer-tier # Alternative to catalog-id. The catalog
114114
rootly.com/catalog-description: Customer pricing tiers # Optional. Description for the catalog when auto-creating.
115115
```
116116
117+
#### Attribute pass-through annotations
118+
119+
You can set any Rootly API attribute on services, functionalities, teams, and catalog entities using the `attr` prefix:
120+
121+
```yaml
122+
rootly.com/service-attr-<api_attribute>: "value"
123+
rootly.com/functionality-attr-<api_attribute>: "value"
124+
rootly.com/team-attr-<api_attribute>: "value"
125+
rootly.com/catalog-entity-attr-<api_attribute>: "value"
126+
```
127+
128+
Value coercion rules:
129+
- `"true"` / `"false"` → boolean
130+
- Values starting with `[` or `{` → parsed as JSON (arrays, objects)
131+
- Everything else → plain string
132+
133+
Example:
134+
135+
```yaml
136+
annotations:
137+
rootly.com/service-slug: my-service
138+
rootly.com/service-auto-import: enabled
139+
rootly.com/service-attr-color: "#FF5733"
140+
rootly.com/service-attr-notify_emails: '["oncall@company.com","team@company.com"]'
141+
rootly.com/service-attr-slack_channels: '[{"id":"C01ABC123","name":"oncall-channel"}]'
142+
rootly.com/service-attr-show_uptime: "true"
143+
rootly.com/service-attr-github_repository_name: "myorg/my-service"
144+
```
145+
146+
Hardcoded fields (`name`, `description`, `backstage_id`, `pagerduty_id`, `owner_group_ids`) always take precedence and cannot be overridden via pass-through annotations.
147+
148+
#### Custom catalog property annotations
149+
150+
You can populate custom catalog properties (metadata fields) on services and catalog entities:
151+
152+
```yaml
153+
rootly.com/service-property-<slug_or_id>: "value"
154+
rootly.com/catalog-entity-property-<slug_or_id>: "value"
155+
```
156+
157+
The key after the prefix is the catalog property slug or UUID. The value is passed as-is to the Rootly API.
158+
159+
Example:
160+
161+
```yaml
162+
annotations:
163+
rootly.com/service-slug: my-service
164+
rootly.com/service-property-impact-level: "critical"
165+
rootly.com/service-property-slack-channel: '{"id":"C01FE4P7458","name":"service-channel"}'
166+
```
167+
117168
#### Example
118169

119170
```yaml

src/api.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { extractPassthroughAttributes, extractProperties } from './api';
2+
3+
describe('extractPassthroughAttributes', () => {
4+
const prefix = 'rootly.com/service-attr-';
5+
6+
it('returns empty object when annotations is undefined', () => {
7+
expect(extractPassthroughAttributes(undefined, prefix)).toEqual({});
8+
});
9+
10+
it('returns empty object when no annotations match prefix', () => {
11+
const annotations = {
12+
'rootly.com/service-id': '123',
13+
'pagerduty.com/service-id': 'PD123',
14+
};
15+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({});
16+
});
17+
18+
it('passes through simple string values', () => {
19+
const annotations = {
20+
'rootly.com/service-attr-color': '#FF5733',
21+
'rootly.com/service-attr-github_repository_name': 'rootlyhq/my-service',
22+
};
23+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
24+
color: '#FF5733',
25+
github_repository_name: 'rootlyhq/my-service',
26+
});
27+
});
28+
29+
it('coerces "true" and "false" to booleans', () => {
30+
const annotations = {
31+
'rootly.com/service-attr-show_uptime': 'true',
32+
'rootly.com/service-attr-alert_broadcast_enabled': 'false',
33+
};
34+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
35+
show_uptime: true,
36+
alert_broadcast_enabled: false,
37+
});
38+
});
39+
40+
it('parses JSON arrays', () => {
41+
const annotations = {
42+
'rootly.com/service-attr-notify_emails': '["alice@co.com","bob@co.com"]',
43+
};
44+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
45+
notify_emails: ['alice@co.com', 'bob@co.com'],
46+
});
47+
});
48+
49+
it('parses JSON objects', () => {
50+
const annotations = {
51+
'rootly.com/service-attr-slack_channels': '[{"id":"C01ABC","name":"oncall"}]',
52+
};
53+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
54+
slack_channels: [{ id: 'C01ABC', name: 'oncall' }],
55+
});
56+
});
57+
58+
it('falls back to string on invalid JSON', () => {
59+
const annotations = {
60+
'rootly.com/service-attr-bad_json': '{not valid json',
61+
};
62+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
63+
bad_json: '{not valid json',
64+
});
65+
});
66+
67+
it('ignores empty attribute name after prefix', () => {
68+
const annotations = {
69+
'rootly.com/service-attr-': 'value',
70+
};
71+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({});
72+
});
73+
74+
it('handles mixed matching and non-matching annotations', () => {
75+
const annotations = {
76+
'rootly.com/service-id': '123',
77+
'rootly.com/service-attr-color': '#000',
78+
'rootly.com/functionality-attr-color': '#FFF',
79+
'rootly.com/service-attr-show_uptime': 'true',
80+
};
81+
expect(extractPassthroughAttributes(annotations, prefix)).toEqual({
82+
color: '#000',
83+
show_uptime: true,
84+
});
85+
});
86+
87+
it('works with different prefixes', () => {
88+
const annotations = {
89+
'rootly.com/team-attr-color': 'blue',
90+
};
91+
expect(extractPassthroughAttributes(annotations, 'rootly.com/team-attr-')).toEqual({
92+
color: 'blue',
93+
});
94+
});
95+
});
96+
97+
describe('extractProperties', () => {
98+
const prefix = 'rootly.com/service-property-';
99+
100+
it('returns empty array when annotations is undefined', () => {
101+
expect(extractProperties(undefined, prefix)).toEqual([]);
102+
});
103+
104+
it('returns empty array when no annotations match prefix', () => {
105+
const annotations = {
106+
'rootly.com/service-id': '123',
107+
};
108+
expect(extractProperties(annotations, prefix)).toEqual([]);
109+
});
110+
111+
it('extracts properties with slug keys', () => {
112+
const annotations = {
113+
'rootly.com/service-property-my-custom-field': 'hello',
114+
};
115+
expect(extractProperties(annotations, prefix)).toEqual([
116+
{ catalog_property_id: 'my-custom-field', value: 'hello' },
117+
]);
118+
});
119+
120+
it('extracts properties with UUID keys', () => {
121+
const annotations = {
122+
'rootly.com/service-property-dcece7f7-b73c-4f32-8b09-695abad42e60': 'world',
123+
};
124+
expect(extractProperties(annotations, prefix)).toEqual([
125+
{ catalog_property_id: 'dcece7f7-b73c-4f32-8b09-695abad42e60', value: 'world' },
126+
]);
127+
});
128+
129+
it('keeps value as raw string (no coercion)', () => {
130+
const annotations = {
131+
'rootly.com/service-property-slack-channel': '{"id":"C01FE4P7458","name":"oncall"}',
132+
};
133+
expect(extractProperties(annotations, prefix)).toEqual([
134+
{ catalog_property_id: 'slack-channel', value: '{"id":"C01FE4P7458","name":"oncall"}' },
135+
]);
136+
});
137+
138+
it('extracts multiple properties', () => {
139+
const annotations = {
140+
'rootly.com/service-property-field-a': 'val1',
141+
'rootly.com/service-property-field-b': 'val2',
142+
};
143+
const result = extractProperties(annotations, prefix);
144+
expect(result).toHaveLength(2);
145+
expect(result).toContainEqual({ catalog_property_id: 'field-a', value: 'val1' });
146+
expect(result).toContainEqual({ catalog_property_id: 'field-b', value: 'val2' });
147+
});
148+
149+
it('ignores empty property id after prefix', () => {
150+
const annotations = {
151+
'rootly.com/service-property-': 'value',
152+
};
153+
expect(extractProperties(annotations, prefix)).toEqual([]);
154+
});
155+
});
156+
157+
describe('passthrough override safety', () => {
158+
const attrPrefix = 'rootly.com/service-attr-';
159+
160+
it('hardcoded fields are not present in passthrough when same key used', () => {
161+
const annotations = {
162+
'rootly.com/service-attr-name': 'injected-name',
163+
'rootly.com/service-attr-backstage_id': 'injected-id',
164+
'rootly.com/service-attr-color': '#FF0000',
165+
};
166+
const result = extractPassthroughAttributes(annotations, attrPrefix);
167+
expect(result).toEqual({
168+
name: 'injected-name',
169+
backstage_id: 'injected-id',
170+
color: '#FF0000',
171+
});
172+
// These keys ARE extracted — the protection is that hardcoded fields
173+
// override them when spread into the payload (spread order matters).
174+
// This test documents that extraction itself does not filter reserved keys.
175+
});
176+
177+
it('properties key in passthrough attrs does not bypass structured properties', () => {
178+
const annotations = {
179+
'rootly.com/service-attr-properties': '[{"catalog_property_id":"x","value":"injected"}]',
180+
};
181+
const passthroughAttrs = extractPassthroughAttributes(annotations, attrPrefix);
182+
expect(passthroughAttrs.properties).toBeDefined();
183+
// The payload construction puts `properties` in the hardcoded section AFTER
184+
// the spread, so this injected value gets overridden. This test documents
185+
// that the extraction function does return it — the safety is in spread order.
186+
});
187+
});

0 commit comments

Comments
 (0)