newrelic-as-code 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+ .DS_Store
13
+ .idea/
14
+ .vscode/
15
+
16
+ # Introspected NerdGraph schema for IDE GraphQL support (see graphql.config.yaml);
17
+ # large and regenerated locally, never committed.
18
+ nerdgraph.schema.graphql
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ilia Peterov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: newrelic-as-code
3
+ Version: 0.1.0
4
+ Summary: Define New Relic dashboards as code and reconcile them to an account
5
+ Project-URL: Homepage, https://github.com/ipeterov/newrelic-as-code
6
+ Project-URL: Repository, https://github.com/ipeterov/newrelic-as-code
7
+ Author-email: Ilia Peterov <ipeterov1@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: requests>=2.26
23
+ Description-Content-Type: text/markdown
24
+
25
+ # newrelic-as-code
26
+
27
+ Define New Relic dashboards as code and reconcile them to an account.
28
+
29
+ New Relic resources should be defined in version-controlled Python and
30
+ reconciled to the account on deploy, never hand-edited in the UI. You describe
31
+ dashboards as pydantic models and the library handles finding the resources it
32
+ manages, creating/updating/deleting them to match your declared set, and
33
+ refusing to clobber anything it didn't create.
34
+
35
+ Reconcile is safe by design:
36
+
37
+ - **Managed by tag.** The library only touches dashboards carrying its
38
+ `managed-by` tag. A same-named dashboard without the tag is refused, never
39
+ overwritten — so hand-built dashboards are safe.
40
+ - **Orphan cleanup.** A managed dashboard that's no longer in your declared set
41
+ is deleted.
42
+ - **Dry run.** Pass `dry_run=True` to print what would change without touching
43
+ the account.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install newrelic-as-code
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from newrelic_as_code import (
55
+ Dashboard,
56
+ NewRelicUpdater,
57
+ NrqlQuery,
58
+ Page,
59
+ Widget,
60
+ EU_ENDPOINT,
61
+ )
62
+
63
+ dashboard = Dashboard(
64
+ name='My service',
65
+ pages=[
66
+ Page(
67
+ name='Overview',
68
+ widgets=[
69
+ Widget(
70
+ title='Throughput',
71
+ visualization='viz.line',
72
+ column=1,
73
+ row=1,
74
+ queries=[
75
+ NrqlQuery(
76
+ query='SELECT rate(count(*), 1 minute) '
77
+ 'FROM Transaction TIMESERIES',
78
+ ),
79
+ ],
80
+ ),
81
+ ],
82
+ ),
83
+ ],
84
+ )
85
+
86
+ updater = NewRelicUpdater(
87
+ account_id=1234567,
88
+ api_key='NRAK-...',
89
+ # Defaults to the US region; pass EU_ENDPOINT (or any full URL) for others.
90
+ endpoint=EU_ENDPOINT,
91
+ # The tag that marks dashboards this tool owns. Choose a value unique to your
92
+ # deployment so several deployments can coexist in one account.
93
+ managed_tag_value='my-service-deploy',
94
+ )
95
+ updater.sync([dashboard])
96
+ ```
97
+
98
+ ## Notes
99
+
100
+ - **Account id.** `account_id` is the account dashboards are created in, and is
101
+ injected into any `NrqlQuery` that doesn't set its own `account_ids`. Set
102
+ `account_ids` on a query explicitly to query across (or a different) account.
103
+ - **`raw_configuration`.** `Widget.raw_configuration` is merged into New Relic's
104
+ `rawConfiguration` verbatim, so any chart option (units, colors, markers,
105
+ thresholds, yAxis, gauge settings, …) is available without the library
106
+ modelling it.
107
+ - **Variables.** Dashboard-level template variables are supported via the
108
+ `Variable` model and `Dashboard(variables=[...])`.
109
+ - **No builder.** The library's surface is the plain
110
+ `Dashboard`/`Page`/`Widget`/`NrqlQuery`/`Variable` model tree — you construct
111
+ those directly. Any layout/composition helpers (grid math, managed banners)
112
+ belong in your own code, since they're specific to how you lay dashboards out.
113
+ - **Extensible reconcile.** `sync()` reconciles dashboards today. The reconcile
114
+ core (find-by-managed-tag → create/update/delete-orphaned → refuse-unmanaged →
115
+ dry-run) is resource-agnostic, so alert policies/conditions can be added as
116
+ additional resource types later without reshaping it.
117
+
118
+ ## License
119
+
120
+ MIT
121
+
122
+ ---
123
+
124
+ <sub>Not affiliated with or endorsed by New Relic, Inc. "New Relic" is a
125
+ trademark of New Relic, Inc.</sub>
@@ -0,0 +1,101 @@
1
+ # newrelic-as-code
2
+
3
+ Define New Relic dashboards as code and reconcile them to an account.
4
+
5
+ New Relic resources should be defined in version-controlled Python and
6
+ reconciled to the account on deploy, never hand-edited in the UI. You describe
7
+ dashboards as pydantic models and the library handles finding the resources it
8
+ manages, creating/updating/deleting them to match your declared set, and
9
+ refusing to clobber anything it didn't create.
10
+
11
+ Reconcile is safe by design:
12
+
13
+ - **Managed by tag.** The library only touches dashboards carrying its
14
+ `managed-by` tag. A same-named dashboard without the tag is refused, never
15
+ overwritten — so hand-built dashboards are safe.
16
+ - **Orphan cleanup.** A managed dashboard that's no longer in your declared set
17
+ is deleted.
18
+ - **Dry run.** Pass `dry_run=True` to print what would change without touching
19
+ the account.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install newrelic-as-code
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from newrelic_as_code import (
31
+ Dashboard,
32
+ NewRelicUpdater,
33
+ NrqlQuery,
34
+ Page,
35
+ Widget,
36
+ EU_ENDPOINT,
37
+ )
38
+
39
+ dashboard = Dashboard(
40
+ name='My service',
41
+ pages=[
42
+ Page(
43
+ name='Overview',
44
+ widgets=[
45
+ Widget(
46
+ title='Throughput',
47
+ visualization='viz.line',
48
+ column=1,
49
+ row=1,
50
+ queries=[
51
+ NrqlQuery(
52
+ query='SELECT rate(count(*), 1 minute) '
53
+ 'FROM Transaction TIMESERIES',
54
+ ),
55
+ ],
56
+ ),
57
+ ],
58
+ ),
59
+ ],
60
+ )
61
+
62
+ updater = NewRelicUpdater(
63
+ account_id=1234567,
64
+ api_key='NRAK-...',
65
+ # Defaults to the US region; pass EU_ENDPOINT (or any full URL) for others.
66
+ endpoint=EU_ENDPOINT,
67
+ # The tag that marks dashboards this tool owns. Choose a value unique to your
68
+ # deployment so several deployments can coexist in one account.
69
+ managed_tag_value='my-service-deploy',
70
+ )
71
+ updater.sync([dashboard])
72
+ ```
73
+
74
+ ## Notes
75
+
76
+ - **Account id.** `account_id` is the account dashboards are created in, and is
77
+ injected into any `NrqlQuery` that doesn't set its own `account_ids`. Set
78
+ `account_ids` on a query explicitly to query across (or a different) account.
79
+ - **`raw_configuration`.** `Widget.raw_configuration` is merged into New Relic's
80
+ `rawConfiguration` verbatim, so any chart option (units, colors, markers,
81
+ thresholds, yAxis, gauge settings, …) is available without the library
82
+ modelling it.
83
+ - **Variables.** Dashboard-level template variables are supported via the
84
+ `Variable` model and `Dashboard(variables=[...])`.
85
+ - **No builder.** The library's surface is the plain
86
+ `Dashboard`/`Page`/`Widget`/`NrqlQuery`/`Variable` model tree — you construct
87
+ those directly. Any layout/composition helpers (grid math, managed banners)
88
+ belong in your own code, since they're specific to how you lay dashboards out.
89
+ - **Extensible reconcile.** `sync()` reconciles dashboards today. The reconcile
90
+ core (find-by-managed-tag → create/update/delete-orphaned → refuse-unmanaged →
91
+ dry-run) is resource-agnostic, so alert policies/conditions can be added as
92
+ additional resource types later without reshaping it.
93
+
94
+ ## License
95
+
96
+ MIT
97
+
98
+ ---
99
+
100
+ <sub>Not affiliated with or endorsed by New Relic, Inc. "New Relic" is a
101
+ trademark of New Relic, Inc.</sub>
@@ -0,0 +1,36 @@
1
+ """Define New Relic dashboards as code and reconcile them to an account."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .client import (
6
+ EU_ENDPOINT,
7
+ US_ENDPOINT,
8
+ NerdGraphClient,
9
+ NewRelicUpdaterError,
10
+ )
11
+ from .models import Dashboard, NrqlQuery, Page, Variable, Visualization, Widget
12
+ from .updater import NewRelicUpdater
13
+ from .utils import echo
14
+
15
+
16
+ try:
17
+ # pyproject.toml is the single source of truth; read it from the installed
18
+ # package metadata rather than duplicating the number here.
19
+ __version__ = version("newrelic-as-code")
20
+ except PackageNotFoundError: # not installed (e.g. running from a source tree)
21
+ __version__ = "0.0.0.dev0"
22
+
23
+ __all__ = [
24
+ "EU_ENDPOINT",
25
+ "US_ENDPOINT",
26
+ "Dashboard",
27
+ "NerdGraphClient",
28
+ "NewRelicUpdater",
29
+ "NewRelicUpdaterError",
30
+ "NrqlQuery",
31
+ "Page",
32
+ "Variable",
33
+ "Visualization",
34
+ "Widget",
35
+ "echo",
36
+ ]
@@ -0,0 +1,67 @@
1
+ """
2
+ Thin New Relic NerdGraph API transport.
3
+
4
+ ``NerdGraphClient`` wraps a single ``requests`` session with retry/backoff and a
5
+ dry-run gate. It knows nothing about dashboards or reconcile logic — it just
6
+ POSTs GraphQL and reports what a mutation would do. The reconcile logic lives in
7
+ ``updater.py`` and drives this client.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+
12
+ import requests
13
+ from requests.adapters import HTTPAdapter, Retry
14
+
15
+ from .utils import echo
16
+
17
+
18
+ # Region NerdGraph endpoints. Default is US; the EU data center uses a separate
19
+ # host. Pass a full URL to ``NewRelicUpdater(endpoint=...)`` for anything else.
20
+ US_ENDPOINT = "https://api.newrelic.com/graphql"
21
+ EU_ENDPOINT = "https://api.eu.newrelic.com/graphql"
22
+
23
+
24
+ class NewRelicUpdaterError(Exception):
25
+ """Raised when a NerdGraph call fails or the live state is ambiguous."""
26
+
27
+
28
+ class NerdGraphClient:
29
+ """Thin New Relic NerdGraph API client with a dry-run gate."""
30
+
31
+ def __init__(self, api_key: str, endpoint: str = US_ENDPOINT, dry_run: bool = False):
32
+ self.api_key = api_key
33
+ self.endpoint = endpoint
34
+ self.dry_run = dry_run
35
+ # NerdGraph occasionally responds slowly or with a transient 5xx. Retry
36
+ # with backoff so a blip doesn't fail the run. NerdGraph mutations are
37
+ # idempotent for us (create/update/delete by name+tag), so retrying a
38
+ # POST is safe.
39
+ self.session = requests.Session()
40
+ retry = Retry(
41
+ total=4,
42
+ backoff_factor=2, # 0s, 2s, 4s, 8s between attempts
43
+ status_forcelist=(429, 500, 502, 503, 504),
44
+ allowed_methods=frozenset({"POST"}),
45
+ )
46
+ self.session.mount("https://", HTTPAdapter(max_retries=retry))
47
+
48
+ def call(self, query: str, variables: dict) -> dict:
49
+ response = self.session.post(
50
+ self.endpoint,
51
+ json={"query": query, "variables": variables},
52
+ headers={"API-Key": self.api_key},
53
+ timeout=30,
54
+ )
55
+ response.raise_for_status()
56
+ payload = response.json()
57
+ if payload.get("errors"):
58
+ raise NewRelicUpdaterError(f"NerdGraph errors: {payload['errors']}")
59
+ return payload["data"]
60
+
61
+ def mutate(self, action: str, do: Callable[[], object]) -> object | None:
62
+ """Run a mutation, or just print what it would do in dry-run mode."""
63
+ if self.dry_run:
64
+ echo(f"Would {action}")
65
+ return None
66
+ echo(action[0].upper() + action[1:])
67
+ return do()
@@ -0,0 +1,127 @@
1
+ """
2
+ New Relic dashboards as code.
3
+
4
+ Typed pydantic models that each render to the JSON shape the New Relic
5
+ NerdGraph API expects via an ``as_nr_dict()`` method. Compose dashboards
6
+ declaratively and reconcile them with ``NewRelicUpdater``.
7
+
8
+ NerdGraph dashboard input reference:
9
+ https://docs.newrelic.com/docs/apis/nerdgraph/examples/nerdgraph-dashboards/
10
+ """
11
+
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ Visualization = Literal[
18
+ "viz.line",
19
+ "viz.area",
20
+ "viz.bar",
21
+ "viz.pie",
22
+ "viz.table",
23
+ "viz.billboard",
24
+ "viz.gauge",
25
+ "viz.markdown",
26
+ ]
27
+
28
+
29
+ class NrqlQuery(BaseModel):
30
+ query: str
31
+ # Accounts this query runs against. Leave empty to let ``NewRelicUpdater``
32
+ # fill in its own account id at reconcile time; set it explicitly to query
33
+ # across accounts (or a different account than the one you reconcile to).
34
+ account_ids: list[int] = Field(default_factory=list)
35
+
36
+ def as_nr_dict(self) -> dict:
37
+ return {
38
+ "accountIds": self.account_ids,
39
+ "query": self.query,
40
+ }
41
+
42
+
43
+ class Widget(BaseModel):
44
+ title: str
45
+ visualization: Visualization
46
+ queries: list[NrqlQuery]
47
+ column: int
48
+ row: int
49
+ width: int = 4
50
+ height: int = 3
51
+ # Extra keys merged into rawConfiguration (units, colors, markers, yAxis,
52
+ # gaugeSettings, ...). Kept as a free dict so we don't have to model every
53
+ # New Relic chart option.
54
+ raw_configuration: dict = Field(default_factory=dict)
55
+
56
+ def as_nr_dict(self) -> dict:
57
+ raw: dict = dict(self.raw_configuration)
58
+ # Query-backed widgets carry nrqlQueries; query-less widgets (e.g.
59
+ # markdown, which uses `text`) must not emit an empty nrqlQueries list.
60
+ if self.queries:
61
+ raw = {"nrqlQueries": [q.as_nr_dict() for q in self.queries], **raw}
62
+ return {
63
+ "title": self.title,
64
+ "layout": {
65
+ "column": self.column,
66
+ "row": self.row,
67
+ "width": self.width,
68
+ "height": self.height,
69
+ },
70
+ "linkedEntityGuids": None,
71
+ "visualization": {"id": self.visualization},
72
+ "rawConfiguration": raw,
73
+ }
74
+
75
+
76
+ class Variable(BaseModel):
77
+ name: str
78
+ title: str
79
+ values: list[str]
80
+ default: str
81
+ replacement_strategy: Literal["DEFAULT", "STRING", "NUMBER", "IDENTIFIER"] = "STRING"
82
+
83
+ def as_nr_dict(self) -> dict:
84
+ return {
85
+ "name": self.name,
86
+ "title": self.title,
87
+ "type": "ENUM",
88
+ "isMultiSelection": False,
89
+ "replacementStrategy": self.replacement_strategy,
90
+ "items": [{"title": None, "value": v} for v in self.values],
91
+ "defaultValues": [{"value": {"string": self.default}}],
92
+ "nrqlQuery": None,
93
+ "options": {"hiddenOnVariablesBar": False, "excluded": False},
94
+ }
95
+
96
+
97
+ class Page(BaseModel):
98
+ name: str
99
+ widgets: list[Widget]
100
+ description: str | None = None
101
+
102
+ def as_nr_dict(self) -> dict:
103
+ return {
104
+ "name": self.name,
105
+ "description": self.description,
106
+ "widgets": [w.as_nr_dict() for w in self.widgets],
107
+ }
108
+
109
+
110
+ class Dashboard(BaseModel):
111
+ name: str
112
+ pages: list[Page]
113
+ description: str | None = None
114
+ variables: list[Variable] = Field(default_factory=list)
115
+ permissions: Literal["PRIVATE", "PUBLIC_READ_ONLY", "PUBLIC_READ_WRITE"] = "PUBLIC_READ_WRITE"
116
+
117
+ def as_nr_dict(self) -> dict:
118
+ """Render to the ``DashboardInput`` shape for dashboardCreate/dashboardUpdate."""
119
+ result: dict = {
120
+ "name": self.name,
121
+ "description": self.description,
122
+ "permissions": self.permissions,
123
+ "pages": [p.as_nr_dict() for p in self.pages],
124
+ }
125
+ if self.variables:
126
+ result["variables"] = [v.as_nr_dict() for v in self.variables]
127
+ return result
@@ -0,0 +1,264 @@
1
+ """
2
+ Reconciles New Relic with resource definitions in code.
3
+
4
+ Config lives in code; the live state in the New Relic account is inspected, then
5
+ resources are created/updated/deleted to match. New Relic itself is the state
6
+ store — managed entities are found by a ``managed-by`` tag, so there is nothing
7
+ to persist between runs.
8
+
9
+ The thin API transport is ``NerdGraphClient`` (see ``client.py``);
10
+ ``NewRelicUpdater.sync()`` reconciles everything on top of it. The reconcile
11
+ core (``_reconcile``) is resource-agnostic:
12
+ find-by-managed-tag, create/update/delete-orphaned, refuse-to-clobber-unmanaged,
13
+ dry-run. Dashboards use it today; alert policies/conditions can be added as more
14
+ ``sync_*`` steps that call the same core with alert-specific find/create/update/
15
+ delete callables — without reshaping the logic here.
16
+ """
17
+
18
+ from collections.abc import Callable
19
+
20
+ from .client import US_ENDPOINT, NerdGraphClient, NewRelicUpdaterError
21
+ from .models import Dashboard
22
+ from .utils import echo
23
+
24
+
25
+ DEFAULT_MANAGED_TAG_KEY = "managed-by"
26
+
27
+
28
+ class NewRelicUpdater:
29
+ """Reconciles New Relic with the code definitions.
30
+
31
+ ``sync()`` is the single entry point. It reconciles dashboards today; alert
32
+ policies/conditions can be added as more ``_sync_*`` steps that reuse
33
+ ``_reconcile``.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ account_id: int,
39
+ api_key: str,
40
+ endpoint: str = US_ENDPOINT,
41
+ managed_tag_key: str = DEFAULT_MANAGED_TAG_KEY,
42
+ managed_tag_value: str = "newrelic-as-code",
43
+ dry_run: bool = False,
44
+ ):
45
+ self.account_id = account_id
46
+ self.managed_tag_key = managed_tag_key
47
+ self.managed_tag_value = managed_tag_value
48
+ self.client = NerdGraphClient(api_key, endpoint, dry_run)
49
+
50
+ def sync(self, dashboards: list[Dashboard]) -> None:
51
+ if self.client.dry_run:
52
+ echo("Dry run — no changes will be applied")
53
+ self._sync_dashboards(dashboards)
54
+ echo("OK")
55
+
56
+ # -- Generic reconcile core ----------------------------------------------
57
+
58
+ def _reconcile(
59
+ self,
60
+ kind: str,
61
+ entity_type: str,
62
+ defined: dict[str, object],
63
+ create: Callable[[object], None],
64
+ update: Callable[[str, object], None],
65
+ delete: Callable[[str], None],
66
+ ) -> None:
67
+ """Reconcile a set of managed resources of one ``entity_type``.
68
+
69
+ ``defined`` maps resource name -> the resource object to apply. For each,
70
+ we look up the existing managed entity (refusing to clobber a same-named
71
+ unmanaged one), then create or update. Finally, any managed entity of
72
+ this type no longer in ``defined`` is deleted as orphaned.
73
+
74
+ Resource-agnostic: dashboards pass their find/create/update/delete;
75
+ alerts can pass their own without reshaping this logic.
76
+ """
77
+ for name, resource in defined.items():
78
+ existing = self._find_entity(name, entity_type)
79
+ if existing:
80
+ self.client.mutate(
81
+ f"update “{name}”",
82
+ lambda r=resource, g=existing["guid"]: update(g, r),
83
+ )
84
+ else:
85
+ self.client.mutate(f"create “{name}”", lambda r=resource: create(r))
86
+
87
+ for entity in self._managed_entities(entity_type):
88
+ if entity["name"] not in defined:
89
+ self.client.mutate(
90
+ f"delete orphaned {kind} “{entity['name']}”",
91
+ lambda g=entity["guid"]: delete(g),
92
+ )
93
+
94
+ # language=graphql
95
+ _SEARCH = """
96
+ query ($query: String!) {
97
+ actor {
98
+ entitySearch(query: $query) {
99
+ results {
100
+ entities {
101
+ guid
102
+ name
103
+ tags {
104
+ key
105
+ values
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ """
113
+
114
+ def _managed_entities(self, entity_type: str) -> list[dict]:
115
+ """All entities of ``entity_type`` in the account tagged as managed by us."""
116
+ query = (
117
+ f"type = '{entity_type}' AND accountId = {self.account_id} "
118
+ f"AND tags.`{self.managed_tag_key}` = '{self.managed_tag_value}'"
119
+ )
120
+ data = self.client.call(self._SEARCH, {"query": query})
121
+ return data["actor"]["entitySearch"]["results"]["entities"]
122
+
123
+ def _find_entity(self, name: str, entity_type: str) -> dict | None:
124
+ """The managed entity of ``entity_type`` named ``name``, or None.
125
+
126
+ Raises if a same-named entity exists but is NOT managed by us — we must
127
+ never clobber a hand-built resource.
128
+ """
129
+ query = f"name = '{name}' AND type = '{entity_type}' AND accountId = {self.account_id}"
130
+ data = self.client.call(self._SEARCH, {"query": query})
131
+ entities = data["actor"]["entitySearch"]["results"]["entities"]
132
+ # entitySearch matches substrings; keep only exact-name matches.
133
+ entities = [e for e in entities if e["name"] == name]
134
+ if not entities:
135
+ return None
136
+ if len(entities) > 1:
137
+ raise NewRelicUpdaterError(
138
+ f"Multiple {entity_type} entities named '{name}' found — refusing to "
139
+ "guess. Resolve the duplicate in New Relic first."
140
+ )
141
+ entity = entities[0]
142
+ tags = {t["key"]: t["values"] for t in entity.get("tags") or []}
143
+ if self.managed_tag_value not in tags.get(self.managed_tag_key, []):
144
+ raise NewRelicUpdaterError(
145
+ f"A {entity_type} named '{name}' exists but is not managed by this tool "
146
+ f"(missing tag {self.managed_tag_key}={self.managed_tag_value}). Refusing "
147
+ "to overwrite it. Rename or delete it, or tag it manually to adopt it."
148
+ )
149
+ return entity
150
+
151
+ # -- Dashboards -----------------------------------------------------------
152
+
153
+ def _sync_dashboards(self, dashboards: list[Dashboard]) -> None:
154
+ """Upsert defined dashboards, then delete managed ones no longer defined."""
155
+ defined = {d.name: d for d in dashboards}
156
+ self._reconcile(
157
+ kind="dashboard",
158
+ entity_type="DASHBOARD",
159
+ defined=defined,
160
+ create=self._create_dashboard,
161
+ update=self._update_dashboard,
162
+ delete=self._delete_dashboard,
163
+ )
164
+
165
+ def _dashboard_input(self, dashboard: Dashboard) -> dict:
166
+ """Serialize a dashboard, injecting our account id into account-less queries."""
167
+ payload = dashboard.as_nr_dict()
168
+ for page in payload["pages"]:
169
+ for widget in page["widgets"]:
170
+ for nrql in widget["rawConfiguration"].get("nrqlQueries", []):
171
+ if not nrql["accountIds"]:
172
+ nrql["accountIds"] = [self.account_id]
173
+ return payload
174
+
175
+ # language=graphql
176
+ _CREATE = """
177
+ mutation ($accountId: Int!, $dashboard: DashboardInput!) {
178
+ dashboardCreate(accountId: $accountId, dashboard: $dashboard) {
179
+ entityResult {
180
+ guid
181
+ }
182
+ errors {
183
+ description
184
+ type
185
+ }
186
+ }
187
+ }
188
+ """
189
+
190
+ # language=graphql
191
+ _TAG = """
192
+ mutation ($guid: EntityGuid!, $tags: [TaggingTagInput!]!) {
193
+ taggingAddTagsToEntity(guid: $guid, tags: $tags) {
194
+ errors {
195
+ message
196
+ type
197
+ }
198
+ }
199
+ }
200
+ """
201
+
202
+ def _create_dashboard(self, dashboard: Dashboard) -> None:
203
+ data = self.client.call(
204
+ self._CREATE,
205
+ {"accountId": self.account_id, "dashboard": self._dashboard_input(dashboard)},
206
+ )
207
+ result = data["dashboardCreate"]
208
+ if result["errors"]:
209
+ raise NewRelicUpdaterError(f"dashboardCreate: {result['errors']}")
210
+ guid = result["entityResult"]["guid"]
211
+ # Tag it so future runs recognize it as ours.
212
+ tag_data = self.client.call(
213
+ self._TAG,
214
+ {
215
+ "guid": guid,
216
+ "tags": [{"key": self.managed_tag_key, "values": [self.managed_tag_value]}],
217
+ },
218
+ )
219
+ if tag_data["taggingAddTagsToEntity"]["errors"]:
220
+ raise NewRelicUpdaterError(
221
+ f"taggingAddTagsToEntity: {tag_data['taggingAddTagsToEntity']['errors']}"
222
+ )
223
+
224
+ # language=graphql
225
+ _UPDATE = """
226
+ mutation ($guid: EntityGuid!, $dashboard: DashboardInput!) {
227
+ dashboardUpdate(guid: $guid, dashboard: $dashboard) {
228
+ entityResult {
229
+ guid
230
+ }
231
+ errors {
232
+ description
233
+ type
234
+ }
235
+ }
236
+ }
237
+ """
238
+
239
+ def _update_dashboard(self, guid: str, dashboard: Dashboard) -> None:
240
+ data = self.client.call(
241
+ self._UPDATE, {"guid": guid, "dashboard": self._dashboard_input(dashboard)}
242
+ )
243
+ result = data["dashboardUpdate"]
244
+ if result["errors"]:
245
+ raise NewRelicUpdaterError(f"dashboardUpdate: {result['errors']}")
246
+
247
+ # language=graphql
248
+ _DELETE = """
249
+ mutation ($guid: EntityGuid!) {
250
+ dashboardDelete(guid: $guid) {
251
+ status
252
+ errors {
253
+ description
254
+ type
255
+ }
256
+ }
257
+ }
258
+ """
259
+
260
+ def _delete_dashboard(self, guid: str) -> None:
261
+ data = self.client.call(self._DELETE, {"guid": guid})
262
+ result = data["dashboardDelete"]
263
+ if result["errors"]:
264
+ raise NewRelicUpdaterError(f"dashboardDelete: {result['errors']}")
@@ -0,0 +1,6 @@
1
+ def echo(message: str, prefix: str = "->> ") -> None:
2
+ """Print a message with a visible prefix.
3
+
4
+ The prefix makes reconcile output easy to spot in CI logs.
5
+ """
6
+ print(f"{prefix}{message}", flush=True) # noqa: T201
@@ -0,0 +1,124 @@
1
+ [project]
2
+ name = "newrelic-as-code"
3
+ # Changing the version automatically publishes to PyPI (see DEVELOPMENT.md)
4
+ version = "0.1.0"
5
+ description = "Define New Relic dashboards as code and reconcile them to an account"
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ authors = [{ name = "Ilia Peterov", email = "ipeterov1@gmail.com" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pydantic>=2.0",
12
+ "requests>=2.26",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Topic :: System :: Systems Administration",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/ipeterov/newrelic-as-code"
29
+ Repository = "https://github.com/ipeterov/newrelic-as-code"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = [
37
+ "newrelic_as_code/",
38
+ "README.md",
39
+ "LICENSE",
40
+ ]
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["newrelic_as_code"]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=9.1",
48
+ "ruff>=0.15",
49
+ ]
50
+
51
+ [tool.pytest.ini_options]
52
+ addopts = "--tb=short"
53
+
54
+ [tool.ruff]
55
+ line-length = 100 # Soft target
56
+
57
+ [tool.ruff.lint]
58
+ unfixable = ["F401"]
59
+ select = [
60
+ "E",
61
+ "W",
62
+ "F",
63
+ "ERA",
64
+ "I",
65
+ "ASYNC",
66
+ "S",
67
+ "BLE",
68
+ "B",
69
+ "A",
70
+ "C4",
71
+ "DTZ",
72
+ "T10",
73
+ "EXE",
74
+ "ISC",
75
+ "INP",
76
+ "PIE",
77
+ "T20",
78
+ "LOG",
79
+ "G",
80
+ "RSE",
81
+ "RET",
82
+ "SLF",
83
+ "SIM",
84
+ "SLOT",
85
+ "TC",
86
+ "PTH",
87
+ "C90",
88
+ "N",
89
+ "PGH",
90
+ "PL",
91
+ "UP",
92
+ "FURB",
93
+ "RUF",
94
+ ]
95
+ ignore = [
96
+ "E501", # don't enforce line length - just try to fit in 100 chars when formatting
97
+ "G004", # f-strings are better than clunky logging.basicConfig
98
+ "PLC0415", # dynamic imports are ok to avoid circular
99
+ "PLC0414", # conflicts with F401 explicit re-export
100
+ "SIM103", # I don't like simplifying the last condition with early return pattern
101
+ "SIM108", # Ternaries can be less readable - I want to decide myself
102
+ "N815", # can't enforce this for external APIs, and I already follow it
103
+ "S101", # asserts are totally normal in pytest
104
+ "PLR0913", # totally normal in pytest to have lots of args (fixtures)
105
+ "PLR2004", # too sensitive to normal magic constants in tests
106
+ "N818", # not every exception is an error
107
+ "C901",
108
+ "PLR0911",
109
+ "PLR0912",
110
+ "PLR0915",
111
+ "TC001", # those break libs that rely on type inspection
112
+ "TC002",
113
+ "TC003",
114
+ "UP037",
115
+ ]
116
+
117
+ [tool.ruff.lint.isort]
118
+ lines-after-imports = 2
119
+ force-wrap-aliases = true
120
+ combine-as-imports = true
121
+
122
+ [tool.ruff.lint.per-file-ignores]
123
+ # Reconcile tests deliberately reach into private reconcile internals.
124
+ "tests/**" = ["SLF001"]