Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,15 @@ Total: 2 asset(s) across 1 account(s)
Type "delete 2 assets" to confirm (Ctrl-C to cancel):
```

**Supported asset types for deletion:**
**Supported asset types:**

| `--asset-type` value | What is deleted | NerdGraph mutation |
|---|---|---|
| `nrql_condition` | NRQL alert conditions | `alertsNrqlConditionDelete` |
| `alert_policy` | Alert policies and all their conditions | `alertsPolicyDelete` |
| `--asset-type` value | Operation | What changes | NerdGraph mutation |
|---|---|---|---|
| `nrql_condition` | Full delete | Removes the NRQL condition entirely | `alertsNrqlConditionDelete` |
| `alert_policy` | Full delete | Removes the policy and all its conditions | `alertsPolicyDelete` |
| `workload_entity` | Entity removal | Removes one entity GUID from a workload's static entity list | `workloadUpdate` |
| `workflow_entity` | Entity removal | Removes one entity GUID from a workflow's issue filter predicates | `aiWorkflowsUpdateWorkflow` |
| `synthetics_monitor` | Full delete | Permanently removes a synthetic monitor | `syntheticsDeleteMonitor` |

> **Warning:** Deleting an `alert_policy` removes the policy and all conditions
> attached to it. Use `--dry-run` to review the scope before confirming.
Expand Down Expand Up @@ -178,6 +181,9 @@ nr-tf-util --mode audit --account-id 2883194 --csv findings.csv
# Skip legacy-condition and uncovered-policy checks
nr-tf-util --mode audit --skip legacy_conditions --skip uncovered_alert_policies

# Skip synthetic monitor scanning (useful if account has many monitors)
nr-tf-util --mode audit --skip synthetics_monitors

# ID-reference checks only (skip all config health checks)
nr-tf-util --mode audit \
--skip disabled_conditions \
Expand All @@ -198,6 +204,18 @@ nr-tf-util --mode cleanup --csv findings.csv \
nr-tf-util --mode cleanup --csv findings.csv \
--asset-type alert_policy --asset-type nrql_condition

# Remove a hardcoded entity GUID from workload static entity lists
nr-tf-util --mode cleanup --csv findings.csv \
--asset-type workload_entity --dry-run

# Remove a hardcoded entity GUID from workflow issue filter predicates
nr-tf-util --mode cleanup --csv findings.csv \
--asset-type workflow_entity

# Delete disabled synthetic monitors
nr-tf-util --mode cleanup --csv findings.csv \
--asset-type synthetics_monitor

# ── Import mode (default) ────────────────────────────────────────────────────

# Generate import blocks for all accessible accounts
Expand Down Expand Up @@ -246,7 +264,7 @@ terraform apply
| Flag | Default | Description |
|---|---|---|
| `--csv` | — | **Required.** Input CSV file produced by audit mode. |
| `--asset-type` | — | **Required.** Asset type to delete. Repeat for multiple. Supported: `nrql_condition`, `alert_policy`. |
| `--asset-type` | — | **Required.** Asset type to delete. Repeat for multiple. Supported: `nrql_condition`, `alert_policy`, `workload_entity`, `workflow_entity`, `synthetics_monitor`. |
| `--dry-run` | `false` | Print the deletion plan without deleting anything. |

### Import mode flags
Expand All @@ -273,12 +291,14 @@ terraform apply
| Workloads | `workloads` | Static entity GUID list + dynamic entity filter expressions |
| Service levels | `service_levels` | Monitored entity GUID + events WHERE clauses |
| Workflows | `workflows` | Issue filter predicate values |
| Synthetic monitors | `synthetics_monitors` | Script body of SCRIPT_API and SCRIPT_BROWSER monitors |

#### Alert configuration health checks

| Check | `--skip` value | What is flagged | Rationale |
|---|---|---|---|
| Disabled NRQL conditions | `disabled_conditions` | Conditions with `enabled=false` | A disabled condition never fires. It should be re-enabled if monitoring is still needed, or removed to keep the policy readable. |
| Disabled synthetic monitors | `synthetics_monitors` | Monitors with `status=DISABLED` (any monitor type) | A disabled monitor produces no results and incurs no checks-per-month cost, but it clutters the monitors list and may give a false sense of coverage. |
| Legacy metric conditions | `legacy_conditions` | APM/Browser/Mobile metric conditions (non-NRQL) | These predate NRQL alerting and are no longer the recommended format. NRQL conditions offer finer control, support streaming alerts, and will receive future New Relic improvements; legacy conditions will not. |
| Policies with no conditions | `uncovered_alert_policies` | Alert policies that have zero conditions | An empty policy can never generate an alert. It is dead configuration that clutters the policy list and may give a false sense of coverage. |
| Policies with no notification | `uncovered_alert_policies` | Policies with at least one enabled condition but no aiWorkflow routing their alerts | When a condition fires, New Relic creates a violation and opens an issue — but if no workflow routes that issue to a destination, nobody is notified. This is one of the most common causes of silent alerting failures. |
Expand Down Expand Up @@ -432,10 +452,23 @@ makes it safe to use in scripts that check `$?`.

### Supported asset types

| `--asset-type` | `asset_type` in CSV | NerdGraph mutation | Notes |
#### Full deletions

| `--asset-type` | Matches CSV rows where | NerdGraph mutation | Notes |
|---|---|---|---|
| `nrql_condition` | `asset_type=nrql_condition`, `id_type` empty | `alertsNrqlConditionDelete` | Deletes the condition. The parent policy is not affected. |
| `alert_policy` | `asset_type=alert_policy`, `id_type` empty | `alertsPolicyDelete` | Deletes the policy and all conditions attached to it. |
| `synthetics_monitor` | `asset_type=synthetics_monitor`, `id_type` empty | `syntheticsDeleteMonitor` | Permanently deletes the monitor. Applies to all monitor types (SIMPLE, BROWSER, SCRIPT_API, etc.). |

#### Entity removal (read-modify-write)

Entity-removal types select rows where `id_type=entityGuid` and perform a
fetch-then-update instead of a straight delete.

| `--asset-type` | Matches CSV rows where | NerdGraph operations | Notes |
|---|---|---|---|
| `nrql_condition` | `nrql_condition` | `alertsNrqlConditionDelete(accountId, id)` | Deletes the single condition. The parent policy is not affected. |
| `alert_policy` | `alert_policy` | `alertsPolicyDelete(accountId, id)` | Deletes the policy. All conditions attached to the policy are also deleted by New Relic. |
| `workload_entity` | `asset_type=workload`, `id_type=entityGuid`, `location=static entity list` | `workload { collection }` → `workloadUpdate` | Removes the GUID from the workload's static entity list. Dynamic filter expressions are not touched. |
| `workflow_entity` | `asset_type=workflow`, `id_type=entityGuid` | `aiWorkflows { workflows }` → `aiWorkflowsUpdateWorkflow` | Removes the GUID from every predicate that contains it. Empty predicates are dropped. Returns an error if removing the GUID would leave the workflow with no predicates at all — that case must be handled manually. |

---

Expand Down
7 changes: 4 additions & 3 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,16 @@ FILTERING
Audit mode asset types:
dashboards nrql_conditions
workloads service_levels
workflows
workflows synthetics_monitors

AUDIT MODE FLAGS
--csv <file> Write findings to a CSV file (e.g. --csv findings.csv)

CLEANUP MODE FLAGS
--csv <file> Input CSV file produced by audit mode (required)
--asset-type <type> Asset type to delete (repeat for multiple; required)
Supported values: nrql_condition alert_policy
Full deletions: nrql_condition alert_policy synthetics_monitor
Entity removal: workload_entity workflow_entity
--dry-run Print what would be deleted without deleting anything

IMPORT MODE FLAGS
Expand Down Expand Up @@ -505,7 +506,7 @@ func runCleanup(ctx context.Context, ng resources.NerdGrapher, csvPath string, a
os.Exit(1)
}
if len(assetTypes) == 0 {
fmt.Fprintln(os.Stderr, "error: --asset-type is required for cleanup mode (supported: nrql_condition, alert_policy)")
fmt.Fprintln(os.Stderr, "error: --asset-type is required for cleanup mode (supported: nrql_condition, alert_policy, workload_entity, workflow_entity)")
os.Exit(1)
}
supported := make([]string, 0, len(cleanup.SupportedAssetTypes))
Expand Down
1 change: 1 addition & 0 deletions internal/audit/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func AllFetchers() []AuditFetcher {
&workloadsAuditFetcher{},
&serviceLevelsAuditFetcher{},
&workflowsAuditFetcher{},
&syntheticsAuditFetcher{},
&uncoveredPoliciesAuditFetcher{},
}
}
Expand Down
145 changes: 145 additions & 0 deletions internal/audit/fetch_dashboards_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package audit

import (
"context"
"encoding/json"
"testing"
)

func TestExtractWidgetNRQL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "single query",
raw: `{"nrqlQueries":[{"query":"SELECT count(*) FROM Transaction"}]}`,
want: "SELECT count(*) FROM Transaction",
},
{
name: "multiple queries joined",
raw: `{"nrqlQueries":[{"query":"SELECT 1"},{"query":"SELECT 2"}]}`,
want: "SELECT 1 | SELECT 2",
},
{
name: "empty nrqlQueries list",
raw: `{"nrqlQueries":[]}`,
want: "",
},
{
name: "blank query strings skipped",
raw: `{"nrqlQueries":[{"query":""},{"query":"SELECT 1"}]}`,
want: "SELECT 1",
},
{
name: "no nrqlQueries key",
raw: `{"title":"widget"}`,
want: "",
},
{
name: "invalid json",
raw: `not json`,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := extractWidgetNRQL(json.RawMessage(tc.raw))
if got != tc.want {
t.Errorf("extractWidgetNRQL(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}

func TestDashboardsAuditFetcher_FindsHardcodedID(t *testing.T) {
mock := newMock(
`{"actor":{"entitySearch":{"results":{"entities":[{"guid":"DASH-1","name":"Ops Dashboard"}],"nextCursor":""}}}}`,
`{"actor":{"entity":{"pages":[{"name":"Overview","widgets":[{"title":"Error Rate","rawConfiguration":{"nrqlQueries":[{"query":"SELECT count(*) FROM Transaction WHERE appId = 99999"}]}}]}]}}}`,
)
findings, err := (&dashboardsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 {
t.Fatalf("got %d findings, want 1", len(findings))
}
f := findings[0]
if f.AssetType != "dashboard" {
t.Errorf("AssetType = %q, want dashboard", f.AssetType)
}
if f.AssetID != "DASH-1" {
t.Errorf("AssetID = %q, want DASH-1", f.AssetID)
}
if f.AssetName != "Ops Dashboard" {
t.Errorf("AssetName = %q, want Ops Dashboard", f.AssetName)
}
if f.IDType != "appId" {
t.Errorf("IDType = %q, want appId", f.IDType)
}
if f.IDValue != "99999" {
t.Errorf("IDValue = %q, want 99999", f.IDValue)
}
wantNRQL := "SELECT count(*) FROM Transaction WHERE appId = 99999"
if f.NRQLQuery != wantNRQL {
t.Errorf("NRQLQuery = %q, want %q", f.NRQLQuery, wantNRQL)
}
}

func TestDashboardsAuditFetcher_NoDashboards(t *testing.T) {
mock := newMock(`{"actor":{"entitySearch":{"results":{"entities":[],"nextCursor":""}}}}`)
findings, err := (&dashboardsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("got %d findings, want 0", len(findings))
}
}

func TestDashboardsAuditFetcher_ListAPIError(t *testing.T) {
mock := newMock(`not valid json`)
_, err := (&dashboardsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err == nil {
t.Error("expected error from malformed JSON, got nil")
}
}

func TestDashboardsAuditFetcher_PaginatedList(t *testing.T) {
mock := newMock(
`{"actor":{"entitySearch":{"results":{"entities":[{"guid":"D1","name":"D1"}],"nextCursor":"cur2"}}}}`,
`{"actor":{"entitySearch":{"results":{"entities":[{"guid":"D2","name":"D2"}],"nextCursor":""}}}}`,
`{"actor":{"entity":{"pages":[]}}}`,
`{"actor":{"entity":{"pages":[]}}}`,
)
findings, err := (&dashboardsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("got %d findings, want 0", len(findings))
}
if mock.calls() != 4 {
t.Errorf("expected 4 API calls, got %d", mock.calls())
}
}

func TestDashboardsAuditFetcher_SkipsDashboardContentError(t *testing.T) {
mock := newMock(
`{"actor":{"entitySearch":{"results":{"entities":[{"guid":"DASH-1","name":"D1"},{"guid":"DASH-2","name":"D2"}],"nextCursor":""}}}}`,
`not valid json`,
`{"actor":{"entity":{"pages":[{"name":"P","widgets":[{"title":"W","rawConfiguration":{"nrqlQueries":[{"query":"SELECT * FROM T WHERE appId = 11111"}]}}]}]}}}`,
)
findings, err := (&dashboardsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// DASH-1 fetch errored (skipped); DASH-2 yielded one finding
if len(findings) != 1 {
t.Errorf("got %d findings, want 1 (skipped dashboard error)", len(findings))
}
if findings[0].AssetID != "DASH-2" {
t.Errorf("AssetID = %q, want DASH-2", findings[0].AssetID)
}
}
105 changes: 105 additions & 0 deletions internal/audit/fetch_servicelevels_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package audit

import (
"context"
"testing"
)

func TestServiceLevelsAuditFetcher_EntityGUID(t *testing.T) {
// Phase 1: entitySearch returns one SLI GUID.
searchResp := `{"actor":{"entitySearch":{"results":{"entities":[{"guid":"SLI-1","name":"Checkout SLO"}],"nextCursor":""}}}}`
// Phase 2: entity detail with entityGuid populated.
detailResp := `{"actor":{"entities":[{"guid":"SLI-1","name":"Checkout SLO","entityGuid":"MONITORED-ENTITY-GUID-42","events":{}}]}}`

findings, err := (&serviceLevelsAuditFetcher{}).Fetch(context.Background(), newMock(searchResp, detailResp), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 1 {
t.Fatalf("got %d findings, want 1", len(findings))
}
f := findings[0]
if f.AssetType != "service_level" {
t.Errorf("AssetType = %q, want service_level", f.AssetType)
}
if f.AssetID != "SLI-1" {
t.Errorf("AssetID = %q, want SLI-1", f.AssetID)
}
if f.AssetName != "Checkout SLO" {
t.Errorf("AssetName = %q, want Checkout SLO", f.AssetName)
}
if f.IDType != "entityGuid" {
t.Errorf("IDType = %q, want entityGuid", f.IDType)
}
if f.IDValue != "MONITORED-ENTITY-GUID-42" {
t.Errorf("IDValue = %q, want MONITORED-ENTITY-GUID-42", f.IDValue)
}
}

func TestServiceLevelsAuditFetcher_HardcodedIDInWhereClause(t *testing.T) {
searchResp := `{"actor":{"entitySearch":{"results":{"entities":[{"guid":"SLI-2","name":"Payments SLO"}],"nextCursor":""}}}}`
detailResp := `{"actor":{"entities":[{
"guid":"SLI-2","name":"Payments SLO","entityGuid":"",
"events":{
"validEvents":{"from":"Transaction","where":"appId = 77777"},
"goodEvents":{"from":"Transaction","where":"appId = 77777 AND error IS FALSE"}
}
}]}}`

findings, err := (&serviceLevelsAuditFetcher{}).Fetch(context.Background(), newMock(searchResp, detailResp), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// appId = 77777 in validEvents AND goodEvents — dedup per ScanText means 2 separate findings (different locations)
if len(findings) == 0 {
t.Fatal("expected findings from WHERE clause scan, got 0")
}
for _, f := range findings {
if f.AssetType != "service_level" {
t.Errorf("AssetType = %q, want service_level", f.AssetType)
}
if f.IDType != "appId" {
t.Errorf("IDType = %q, want appId", f.IDType)
}
if f.IDValue != "77777" {
t.Errorf("IDValue = %q, want 77777", f.IDValue)
}
}
}

func TestServiceLevelsAuditFetcher_NoSLIs(t *testing.T) {
searchResp := `{"actor":{"entitySearch":{"results":{"entities":[],"nextCursor":""}}}}`
findings, err := (&serviceLevelsAuditFetcher{}).Fetch(context.Background(), newMock(searchResp), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("got %d findings, want 0", len(findings))
}
}

func TestServiceLevelsAuditFetcher_SearchAPIError(t *testing.T) {
_, err := (&serviceLevelsAuditFetcher{}).Fetch(context.Background(), newMock(`not json`), 1)
if err == nil {
t.Error("expected error from malformed JSON, got nil")
}
}

func TestServiceLevelsAuditFetcher_Pagination(t *testing.T) {
page1 := `{"actor":{"entitySearch":{"results":{"entities":[{"guid":"SLI-A","name":"A"}],"nextCursor":"cur2"}}}}`
page2 := `{"actor":{"entitySearch":{"results":{"entities":[{"guid":"SLI-B","name":"B"}],"nextCursor":""}}}}`
// Phase 2: batch of 2 GUIDs — both without hardcoded refs
detailResp := `{"actor":{"entities":[{"guid":"SLI-A","name":"A","entityGuid":"","events":{}},{"guid":"SLI-B","name":"B","entityGuid":"","events":{}}]}}`

mock := newMock(page1, page2, detailResp)
findings, err := (&serviceLevelsAuditFetcher{}).Fetch(context.Background(), mock, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(findings) != 0 {
t.Errorf("got %d findings, want 0", len(findings))
}
if mock.calls() != 3 {
t.Errorf("expected 3 API calls (2 search pages + 1 detail batch), got %d", mock.calls())
}
}
Loading
Loading