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.
- newrelic_as_code-0.1.0/.gitignore +18 -0
- newrelic_as_code-0.1.0/LICENSE +21 -0
- newrelic_as_code-0.1.0/PKG-INFO +125 -0
- newrelic_as_code-0.1.0/README.md +101 -0
- newrelic_as_code-0.1.0/newrelic_as_code/__init__.py +36 -0
- newrelic_as_code-0.1.0/newrelic_as_code/client.py +67 -0
- newrelic_as_code-0.1.0/newrelic_as_code/models.py +127 -0
- newrelic_as_code-0.1.0/newrelic_as_code/updater.py +264 -0
- newrelic_as_code-0.1.0/newrelic_as_code/utils.py +6 -0
- newrelic_as_code-0.1.0/pyproject.toml +124 -0
|
@@ -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,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"]
|