Skip to content

Commit 99756e1

Browse files
Merge pull request #2 from asomensari-es/feat/cleanup-nrql-query
feat: add nrql_query CSV column, cleanup mode for deleting unused assets
2 parents cc891e2 + eb72e27 commit 99756e1

13 files changed

Lines changed: 1024 additions & 159 deletions

README.md

Lines changed: 271 additions & 116 deletions
Large diffs are not rendered by default.

cmd/main.go

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/newrelic/newrelic-client-go/v2/newrelic"
1919

2020
"github.com/asomensari-es/nr-tf-util/internal/audit"
21+
"github.com/asomensari-es/nr-tf-util/internal/cleanup"
2122
"github.com/asomensari-es/nr-tf-util/internal/config"
2223
"github.com/asomensari-es/nr-tf-util/internal/generator"
2324
"github.com/asomensari-es/nr-tf-util/internal/ratelimit"
@@ -55,8 +56,10 @@ func main() {
5556
)
5657
var accountIDs multiString
5758
var skipList multiString
59+
var assetTypes multiString
5860
flag.Var(&accountIDs, "account-id", "Account ID to scan (repeat for multiple; default: auto-discover)")
5961
flag.Var(&skipList, "skip", "Fetcher/asset type to skip (repeat for multiple)")
62+
flag.Var(&assetTypes, "asset-type", "Asset type to clean up (repeat for multiple; cleanup mode only)")
6063

6164
flag.Usage = func() {
6265
fmt.Fprintf(os.Stderr, `nr-tf-util %s — New Relic Terraform utility
@@ -65,11 +68,14 @@ USAGE
6568
nr-tf-util [options]
6669
6770
MODES
71+
--mode audit Scan accounts for hardcoded entity IDs and GUIDs that will
72+
break during an account migration, and flag alert configuration
73+
health issues (disabled conditions, empty policies, etc.).
74+
Optionally export findings to CSV with --csv.
75+
--mode cleanup Read an audit CSV (--csv) and permanently delete the matched
76+
assets. Requires --asset-type. Supports --dry-run.
6877
--mode import (default) Scan accounts and generate Terraform import blocks
6978
ready for: terraform plan -generate-config-out=resources.tf
70-
--mode audit Scan accounts for hardcoded entity IDs and GUIDs that will
71-
break during an account migration, and print a report with
72-
migration suggestions.
7379
7480
AUTHENTICATION
7581
--api-key <key> New Relic User API key (NRAK-...)
@@ -78,6 +84,7 @@ AUTHENTICATION
7884
ACCOUNT SELECTION
7985
--account-id <id> Account ID to scan (repeat for multiple accounts)
8086
Omit to auto-discover all accessible accounts
87+
(not used in cleanup mode — accounts come from CSV)
8188
8289
REGION
8390
--region <name> US (default) | EU | FedRAMP | JP
@@ -97,21 +104,52 @@ FILTERING
97104
workloads service_levels
98105
workflows
99106
107+
AUDIT MODE FLAGS
108+
--csv <file> Write findings to a CSV file (e.g. --csv findings.csv)
109+
110+
CLEANUP MODE FLAGS
111+
--csv <file> Input CSV file produced by audit mode (required)
112+
--asset-type <type> Asset type to delete (repeat for multiple; required)
113+
Supported values: nrql_condition alert_policy
114+
--dry-run Print what would be deleted without deleting anything
115+
100116
IMPORT MODE FLAGS
101117
--out <dir> Root output directory (default: ./output)
102118
One subdirectory per account: <out>/<accountId>-<slug>/
103119
--dry-run Show what would be written without writing any files
104120
105-
AUDIT MODE FLAGS
106-
(no additional flags — uses the same filtering, rate-limit, and timeout options)
107-
108121
BEHAVIOUR
109122
--verbose Show additional detail in progress output
110123
--quiet Suppress all non-error output
111124
--rate-limit <n> Max API requests per second (default: 25)
112125
--max-retries <n> Max retries on API failure (default: 3)
113126
--timeout <duration> Per-account timeout, e.g. 2m30s (default: 5m)
114127
128+
EXAMPLES — audit mode
129+
export NEW_RELIC_API_KEY=NRAK-...
130+
131+
# Audit all accessible accounts
132+
nr-tf-util --mode audit
133+
134+
# Audit a single account and export findings to CSV
135+
nr-tf-util --mode audit --account-id 2883194 --csv findings.csv
136+
137+
# Skip legacy-condition and uncovered-policy checks
138+
nr-tf-util --mode audit --skip legacy_conditions --skip uncovered_alert_policies
139+
140+
EXAMPLES — cleanup mode
141+
# Dry-run: see what would be deleted from the CSV
142+
nr-tf-util --mode cleanup --csv findings.csv \
143+
--asset-type nrql_condition --dry-run
144+
145+
# Delete disabled NRQL conditions
146+
nr-tf-util --mode cleanup --csv findings.csv \
147+
--asset-type nrql_condition
148+
149+
# Delete both empty policies and disabled conditions
150+
nr-tf-util --mode cleanup --csv findings.csv \
151+
--asset-type alert_policy --asset-type nrql_condition
152+
115153
EXAMPLES — import mode
116154
# Generate import blocks for all accessible accounts
117155
nr-tf-util --api-key NRAK-...
@@ -123,21 +161,6 @@ EXAMPLES — import mode
123161
# Skip dashboards and workloads
124162
nr-tf-util --api-key NRAK-... --skip dashboards --skip workloads
125163
126-
EXAMPLES — audit mode
127-
# Audit all accessible accounts for hardcoded IDs
128-
nr-tf-util --mode audit --api-key NRAK-...
129-
130-
# Audit a specific account
131-
nr-tf-util --mode audit --api-key NRAK-... --account-id 2883194
132-
133-
# Audit only dashboards and NRQL conditions
134-
nr-tf-util --mode audit --api-key NRAK-... \
135-
--skip workloads --skip service_levels --skip workflows
136-
137-
# Use an environment variable for the key
138-
export NEW_RELIC_API_KEY=NRAK-...
139-
nr-tf-util --mode audit --account-id 2883194
140-
141164
NEXT STEPS (after import mode)
142165
cd output/<account-dir>/
143166
terraform init
@@ -157,9 +180,9 @@ NEXT STEPS (after import mode)
157180

158181
// ── Validate mode ──────────────────────────────────────────────────────────
159182
switch *mode {
160-
case "import", "audit":
183+
case "import", "audit", "cleanup":
161184
default:
162-
fmt.Fprintf(os.Stderr, "error: unknown --mode %q (valid: import, audit)\n\n", *mode)
185+
fmt.Fprintf(os.Stderr, "error: unknown --mode %q (valid: audit, cleanup, import)\n\n", *mode)
163186
flag.Usage()
164187
os.Exit(1)
165188
}
@@ -198,11 +221,20 @@ NEXT STEPS (after import mode)
198221
printf("\nnr-tf-util %s [mode: %s]\n\n", Version, *mode)
199222
printf(" Region ..... %s\n", reg.Name)
200223
printf(" Endpoint ..... %s\n", reg.NerdGraphURL)
201-
if *mode == "import" {
224+
switch *mode {
225+
case "import":
202226
printf(" Output ..... %s\n", *outDir)
203227
if *dryRun {
204228
printf(" [DRY-RUN — no files will be written]\n")
205229
}
230+
case "cleanup":
231+
printf(" CSV input .... %s\n", *csvOut)
232+
if *dryRun {
233+
printf(" [DRY-RUN — no assets will be deleted]\n")
234+
}
235+
if len(assetTypes) > 0 {
236+
printf(" Asset types .. %s\n", strings.Join(assetTypes, ", "))
237+
}
206238
}
207239
printf(" Rate limit ... %d req/s\n", *rateLimit)
208240
if len(skipList) > 0 {
@@ -228,10 +260,17 @@ NEXT STEPS (after import mode)
228260

229261
ng := &nrClient.NerdGraph
230262

231-
// ── Resolve accounts ───────────────────────────────────────────────────────
263+
// ── Resolve accounts (not needed for cleanup — accounts come from the CSV) ──
232264
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
233265
defer cancel()
234266

267+
limiter := ratelimit.New(*rateLimit)
268+
269+
if *mode == "cleanup" {
270+
runCleanup(ctx, ng, *csvOut, assetTypes, *dryRun)
271+
return
272+
}
273+
235274
var accounts []resources.Account
236275

237276
if len(accountIDs) > 0 {
@@ -276,7 +315,6 @@ NEXT STEPS (after import mode)
276315
for _, s := range skipList {
277316
skipSet[s] = true
278317
}
279-
limiter := ratelimit.New(*rateLimit)
280318

281319
// ── Mode branch ────────────────────────────────────────────────────────────
282320
switch *mode {
@@ -459,6 +497,48 @@ func runAudit(
459497
printf("Duration: %s\n\n", elapsed)
460498
}
461499

500+
// ── Cleanup mode ─────────────────────────────────────────────────────────────
501+
502+
func runCleanup(ctx context.Context, ng resources.NerdGrapher, csvPath string, assetTypes []string, dryRun bool) {
503+
if csvPath == "" {
504+
fmt.Fprintln(os.Stderr, "error: --csv <file> is required for cleanup mode")
505+
os.Exit(1)
506+
}
507+
if len(assetTypes) == 0 {
508+
fmt.Fprintln(os.Stderr, "error: --asset-type is required for cleanup mode (supported: nrql_condition, alert_policy)")
509+
os.Exit(1)
510+
}
511+
supported := make([]string, 0, len(cleanup.SupportedAssetTypes))
512+
for k := range cleanup.SupportedAssetTypes {
513+
supported = append(supported, k)
514+
}
515+
sort.Strings(supported)
516+
for _, at := range assetTypes {
517+
if !cleanup.SupportedAssetTypes[at] {
518+
fmt.Fprintf(os.Stderr, "error: unknown --asset-type %q (supported: %s)\n", at, strings.Join(supported, ", "))
519+
os.Exit(1)
520+
}
521+
}
522+
523+
f, err := os.Open(csvPath)
524+
if err != nil {
525+
fmt.Fprintf(os.Stderr, "error: cannot open CSV file %q: %v\n", csvPath, err)
526+
os.Exit(1)
527+
}
528+
defer f.Close()
529+
530+
targets, err := cleanup.ParseCSV(f, assetTypes)
531+
if err != nil {
532+
fmt.Fprintf(os.Stderr, "error: parsing CSV: %v\n", err)
533+
os.Exit(1)
534+
}
535+
536+
if err := cleanup.Run(ctx, ng, targets, dryRun, os.Stdout, os.Stdin); err != nil {
537+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
538+
os.Exit(1)
539+
}
540+
}
541+
462542
// ── Helpers ───────────────────────────────────────────────────────────────────
463543

464544
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)

internal/audit/auditor.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ type AuditFetcher interface {
1919
Fetch(ctx context.Context, ng resources.NerdGrapher, accountID int) ([]Finding, error)
2020
}
2121

22-
// Finding is a single hardcoded ID reference found within a New Relic asset.
22+
// Finding is a single hardcoded ID reference or configuration issue found within a New Relic asset.
2323
type Finding struct {
2424
AssetType string // "dashboard", "nrql_condition", "workload", "service_level", "workflow"
2525
AssetName string // human-readable asset name
2626
AssetID string // GUID or numeric ID of the containing asset
27+
NRQLQuery string // NRQL query where the issue was found; empty for non-query assets
2728
Location string // e.g. "widget: Error Rate", "condition query", "static entity list"
2829
IDType string // "entityGuid", "appId", "hostId", "entityId", "monitorId"
2930
IDValue string // the raw hardcoded value found in the asset

internal/audit/csv.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
var csvHeaders = []string{
1010
"account_id", "account_name",
1111
"asset_type", "asset_id", "asset_name",
12+
"nrql_query",
1213
"location", "id_type", "id_value",
1314
"resolved", "entity_name", "entity_type",
1415
"suggestion",
@@ -31,6 +32,7 @@ func WriteCSV(w io.Writer, results []*AuditResult) error {
3132
row := []string{
3233
accountID, r.Account.Name,
3334
f.AssetType, f.AssetID, f.AssetName,
35+
f.NRQLQuery,
3436
f.Location, f.IDType, f.IDValue,
3537
resolved, f.EntityName, f.EntityType,
3638
f.Suggestion,

internal/audit/csv_test.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func TestWriteCSV_Headers(t *testing.T) {
3030
want := []string{
3131
"account_id", "account_name",
3232
"asset_type", "asset_id", "asset_name",
33+
"nrql_query",
3334
"location", "id_type", "id_value",
3435
"resolved", "entity_name", "entity_type",
3536
"suggestion",
@@ -84,13 +85,14 @@ func TestWriteCSV_IDFinding(t *testing.T) {
8485
assertEqual(t, row, 2, "nrql_condition")
8586
assertEqual(t, row, 3, "1234:99")
8687
assertEqual(t, row, 4, "High Error Rate")
87-
assertEqual(t, row, 5, "condition query")
88-
assertEqual(t, row, 6, "appId")
89-
assertEqual(t, row, 7, "555")
90-
assertEqual(t, row, 8, "true")
91-
assertEqual(t, row, 9, "Checkout Service")
92-
assertEqual(t, row, 10, "APPLICATION")
93-
assertEqual(t, row, 11, "Replace with: appName = 'Checkout Service'")
88+
assertEqual(t, row, 5, "") // nrql_query (not set on this finding)
89+
assertEqual(t, row, 6, "condition query")
90+
assertEqual(t, row, 7, "appId")
91+
assertEqual(t, row, 8, "555")
92+
assertEqual(t, row, 9, "true")
93+
assertEqual(t, row, 10, "Checkout Service")
94+
assertEqual(t, row, 11, "APPLICATION")
95+
assertEqual(t, row, 12, "Replace with: appName = 'Checkout Service'")
9496
}
9597

9698
func TestWriteCSV_ConfigFinding(t *testing.T) {
@@ -118,12 +120,13 @@ func TestWriteCSV_ConfigFinding(t *testing.T) {
118120
t.Fatalf("expected 2 rows, got %d", len(rows))
119121
}
120122
row := rows[1]
121-
assertEqual(t, row, 6, "") // id_type empty
122-
assertEqual(t, row, 7, "") // id_value empty
123-
assertEqual(t, row, 8, "false") // resolved false
124-
assertEqual(t, row, 9, "") // entity_name empty
125-
assertEqual(t, row, 10, "") // entity_type empty
126-
assertEqual(t, row, 11, "Condition is disabled — enable it or remove it to reduce policy noise")
123+
assertEqual(t, row, 5, "") // nrql_query (not set)
124+
assertEqual(t, row, 7, "") // id_type empty
125+
assertEqual(t, row, 8, "") // id_value empty
126+
assertEqual(t, row, 9, "false") // resolved false
127+
assertEqual(t, row, 10, "") // entity_name empty
128+
assertEqual(t, row, 11, "") // entity_type empty
129+
assertEqual(t, row, 12, "Condition is disabled — enable it or remove it to reduce policy noise")
127130
}
128131

129132
func TestWriteCSV_MultipleAccounts(t *testing.T) {

internal/audit/fetch_conditions.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ query($accountId: Int!, $cursor: String) {
6565
AssetType: "nrql_condition",
6666
AssetName: c.Name,
6767
AssetID: fmt.Sprintf("%d:%s", accountID, c.ID),
68+
NRQLQuery: c.NRQL.Query,
6869
Location: m.Location,
6970
IDType: m.IDType,
7071
IDValue: m.IDValue,

internal/audit/fetch_conditions_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ func TestConditionsAuditFetcher_HardcodedID(t *testing.T) {
3535
if f.IDValue != "55555" {
3636
t.Errorf("IDValue = %q, want 55555", f.IDValue)
3737
}
38+
wantQuery := "SELECT count(*) FROM Transaction WHERE appId = 55555"
39+
if f.NRQLQuery != wantQuery {
40+
t.Errorf("NRQLQuery = %q, want %q", f.NRQLQuery, wantQuery)
41+
}
3842
}
3943

4044
func TestConditionsAuditFetcher_NoHardcodedIDs(t *testing.T) {

internal/audit/fetch_dashboards.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strings"
78

89
"github.com/asomensari-es/nr-tf-util/internal/resources"
910
)
@@ -118,11 +119,13 @@ query($guid: EntityGuid!) {
118119
if page.Name != "" {
119120
location = fmt.Sprintf("page %q · widget: %s", page.Name, widget.Title)
120121
}
122+
nrqlQuery := extractWidgetNRQL(widget.RawConfiguration)
121123
for _, m := range ScanText(string(widget.RawConfiguration), location) {
122124
findings = append(findings, Finding{
123125
AssetType: "dashboard",
124126
AssetName: dash.name,
125127
AssetID: dash.guid,
128+
NRQLQuery: nrqlQuery,
126129
Location: m.Location,
127130
IDType: m.IDType,
128131
IDValue: m.IDValue,
@@ -134,3 +137,23 @@ query($guid: EntityGuid!) {
134137

135138
return findings, nil
136139
}
140+
141+
// extractWidgetNRQL parses a widget's rawConfiguration JSON and returns all
142+
// nrqlQueries[].query values joined with " | ". Returns "" if none are found.
143+
func extractWidgetNRQL(raw json.RawMessage) string {
144+
var cfg struct {
145+
NRQLQueries []struct {
146+
Query string `json:"query"`
147+
} `json:"nrqlQueries"`
148+
}
149+
if err := json.Unmarshal(raw, &cfg); err != nil || len(cfg.NRQLQueries) == 0 {
150+
return ""
151+
}
152+
queries := make([]string, 0, len(cfg.NRQLQueries))
153+
for _, q := range cfg.NRQLQueries {
154+
if q.Query != "" {
155+
queries = append(queries, q.Query)
156+
}
157+
}
158+
return strings.Join(queries, " | ")
159+
}

0 commit comments

Comments
 (0)