Skip to content

Commit 8d675e9

Browse files
author
Adilson Somensari
committed
feat: add legacy_condition support to cleanup mode
Cleanup mode can now delete non-NRQL (legacy) alert conditions via the REST API v2. Adds DeleteLegacyCondition to nrRESTClient, a LegacyConditionDeleter interface in the cleanup package, and threads the REST client through Run/deleteAsset. ParseCSV matches migration-notice rows (empty id_type) and ignores per-entity rows, ensuring each condition is targeted exactly once. Usage: --mode cleanup --asset-type legacy_condition --csv findings.csv
1 parent 4e9cb5d commit 8d675e9

5 files changed

Lines changed: 151 additions & 21 deletions

File tree

cmd/main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,10 @@ NEXT STEPS (after import mode)
268268

269269
limiter := ratelimit.New(*rateLimit)
270270

271+
restClient := newNRRESTClient(key, reg.RESTURL)
272+
271273
if *mode == "cleanup" {
272-
runCleanup(ctx, ng, *csvOut, assetTypes, *dryRun)
274+
runCleanup(ctx, ng, restClient, *csvOut, assetTypes, *dryRun)
273275
return
274276
}
275277

@@ -319,8 +321,6 @@ NEXT STEPS (after import mode)
319321
}
320322

321323
// ── Mode branch ────────────────────────────────────────────────────────────
322-
restClient := newNRRESTClient(key, reg.RESTURL)
323-
324324
switch *mode {
325325
case "import":
326326
runImport(ctx, ng, limiter, accounts, skipSet, reg, *outDir, *dryRun, *verbose, *timeout, Version, printf, out)
@@ -504,7 +504,7 @@ func runAudit(
504504

505505
// ── Cleanup mode ─────────────────────────────────────────────────────────────
506506

507-
func runCleanup(ctx context.Context, ng resources.NerdGrapher, csvPath string, assetTypes []string, dryRun bool) {
507+
func runCleanup(ctx context.Context, ng resources.NerdGrapher, rest cleanup.LegacyConditionDeleter, csvPath string, assetTypes []string, dryRun bool) {
508508
if csvPath == "" {
509509
fmt.Fprintln(os.Stderr, "error: --csv <file> is required for cleanup mode")
510510
os.Exit(1)
@@ -538,7 +538,7 @@ func runCleanup(ctx context.Context, ng resources.NerdGrapher, csvPath string, a
538538
os.Exit(1)
539539
}
540540

541-
if err := cleanup.Run(ctx, ng, targets, dryRun, os.Stdout, os.Stdin); err != nil {
541+
if err := cleanup.Run(ctx, ng, rest, targets, dryRun, os.Stdout, os.Stdin); err != nil {
542542
fmt.Fprintf(os.Stderr, "error: %v\n", err)
543543
os.Exit(1)
544544
}

cmd/rest_client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ func newNRRESTClient(apiKey, baseURL string) *nrRESTClient {
2727
}
2828
}
2929

30+
// DeleteLegacyCondition deletes a single non-NRQL condition via the REST API v2.
31+
func (c *nrRESTClient) DeleteLegacyCondition(ctx context.Context, conditionID int) error {
32+
reqURL := fmt.Sprintf("%s/v2/alerts_conditions/%d.json", c.baseURL, conditionID)
33+
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
34+
if err != nil {
35+
return err
36+
}
37+
req.Header.Set("Api-Key", c.apiKey)
38+
resp, err := c.httpClient.Do(req)
39+
if err != nil {
40+
return err
41+
}
42+
body, _ := io.ReadAll(resp.Body)
43+
resp.Body.Close()
44+
if resp.StatusCode != http.StatusOK {
45+
return fmt.Errorf("REST API returned %d: %s", resp.StatusCode, string(body))
46+
}
47+
return nil
48+
}
49+
3050
// ListLegacyConditions fetches all non-NRQL conditions for policyID, handling
3151
// REST API v2 page-based pagination internally.
3252
func (c *nrRESTClient) ListLegacyConditions(ctx context.Context, policyID int) ([]audit.LegacyCondition, error) {

internal/cleanup/cleanup.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var SupportedAssetTypes = map[string]bool{
3535
"workload_entity": true,
3636
"workflow_entity": true,
3737
"synthetics_monitor": true,
38+
"legacy_condition": true,
3839
}
3940

4041
// configFindingTypes map a virtual asset type to the asset_type column value it
@@ -43,6 +44,7 @@ var configFindingTypes = map[string]string{
4344
"nrql_condition": "nrql_condition",
4445
"alert_policy": "alert_policy",
4546
"synthetics_monitor": "synthetics_monitor",
47+
"legacy_condition": "legacy_condition",
4648
}
4749

4850
// ParseCSV reads an audit CSV, filters rows to those matching assetTypes,
@@ -128,7 +130,7 @@ func matchRow(row []string, col map[string]int, activeTypes []string) (Target, b
128130

129131
for _, vt := range activeTypes {
130132
switch vt {
131-
case "nrql_condition", "alert_policy", "synthetics_monitor":
133+
case "nrql_condition", "alert_policy", "synthetics_monitor", "legacy_condition":
132134
if idType == "" && csvAssetType == configFindingTypes[vt] {
133135
return Target{
134136
AccountID: accountID,
@@ -173,8 +175,9 @@ func matchRow(row []string, col map[string]int, activeTypes []string) (Target, b
173175

174176
// Run prints the operation plan, confirms with the user (unless dryRun), and
175177
// executes each operation sequentially. Progress is written to out; the
176-
// confirmation token is read from in.
177-
func Run(ctx context.Context, ng resources.NerdGrapher, targets []Target, dryRun bool, out io.Writer, in io.Reader) error {
178+
// confirmation token is read from in. rest may be nil when REST API access is
179+
// unavailable; legacy_condition targets will error at execution time if so.
180+
func Run(ctx context.Context, ng resources.NerdGrapher, rest LegacyConditionDeleter, targets []Target, dryRun bool, out io.Writer, in io.Reader) error {
178181
if len(targets) == 0 {
179182
fmt.Fprintln(out, "No assets matched the filter — nothing to delete.")
180183
return nil
@@ -232,7 +235,7 @@ func Run(ctx context.Context, ng resources.NerdGrapher, targets []Target, dryRun
232235
fmt.Fprintln(out)
233236
var succeeded, failed int
234237
for _, t := range targets {
235-
if err := deleteAsset(ctx, ng, t); err != nil {
238+
if err := deleteAsset(ctx, ng, rest, t); err != nil {
236239
if t.IDValue != "" {
237240
fmt.Fprintf(out, " FAIL [%s] %q (remove %s): %v\n", t.AssetType, t.AssetName, t.IDValue, err)
238241
} else {

internal/cleanup/cleanup_test.go

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ func TestParseCSV_EntityDeduplication_DifferentGUIDs(t *testing.T) {
258258
func TestRun_NoTargets(t *testing.T) {
259259
mock := &mockNG{}
260260
var out bytes.Buffer
261-
if err := Run(context.Background(), mock, nil, false, &out, strings.NewReader("")); err != nil {
261+
if err := Run(context.Background(), mock, nil, nil, false, &out, strings.NewReader("")); err != nil {
262262
t.Fatalf("unexpected error: %v", err)
263263
}
264264
if mock.callCount() != 0 {
@@ -272,7 +272,7 @@ func TestRun_DryRun(t *testing.T) {
272272
}
273273
mock := &mockNG{}
274274
var out bytes.Buffer
275-
if err := Run(context.Background(), mock, targets, true, &out, strings.NewReader("")); err != nil {
275+
if err := Run(context.Background(), mock, nil, targets, true, &out, strings.NewReader("")); err != nil {
276276
t.Fatalf("unexpected error: %v", err)
277277
}
278278
if mock.callCount() != 0 {
@@ -289,7 +289,7 @@ func TestRun_WrongConfirmation(t *testing.T) {
289289
}
290290
mock := &mockNG{}
291291
var out bytes.Buffer
292-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("yes\n")); err == nil {
292+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("yes\n")); err == nil {
293293
t.Fatal("expected error for wrong confirmation, got nil")
294294
}
295295
if mock.callCount() != 0 {
@@ -303,7 +303,7 @@ func TestRun_CorrectConfirmation_NRQLCondition(t *testing.T) {
303303
}
304304
mock := &mockNG{}
305305
var out bytes.Buffer
306-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
306+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
307307
t.Fatalf("unexpected error: %v", err)
308308
}
309309
if mock.callCount() != 1 {
@@ -324,7 +324,7 @@ func TestRun_CorrectConfirmation_AlertPolicy(t *testing.T) {
324324
}
325325
mock := &mockNG{}
326326
var out bytes.Buffer
327-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
327+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
328328
t.Fatalf("unexpected error: %v", err)
329329
}
330330
vars := mock.callVars(0)
@@ -342,7 +342,7 @@ func TestRun_CorrectConfirmation_WorkloadEntity(t *testing.T) {
342342
`{}`, // update response
343343
)
344344
var out bytes.Buffer
345-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
345+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
346346
t.Fatalf("unexpected error: %v", err)
347347
}
348348
if mock.callCount() != 2 {
@@ -362,7 +362,7 @@ func TestRun_CorrectConfirmation_WorkflowEntity(t *testing.T) {
362362
}
363363
mock := newMock(workflowFetchResp("wf-1", predicates), workflowUpdateOKResp())
364364
var out bytes.Buffer
365-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
365+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
366366
t.Fatalf("unexpected error: %v", err)
367367
}
368368
if mock.callCount() != 2 {
@@ -380,7 +380,7 @@ func TestRun_PartialFailure_ReportsError(t *testing.T) {
380380
}
381381
mock := &mockNG{err: errors.New("API unavailable")}
382382
var out bytes.Buffer
383-
err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 2 assets\n"))
383+
err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 2 assets\n"))
384384
if err == nil {
385385
t.Fatal("expected error when deletions fail")
386386
}
@@ -565,7 +565,7 @@ func TestRemoveWorkflowEntity_WorkflowNotFound(t *testing.T) {
565565

566566
func TestDeleteAsset_UnsupportedType(t *testing.T) {
567567
mock := newMock()
568-
err := deleteAsset(context.Background(), mock, Target{
568+
err := deleteAsset(context.Background(), mock, nil, Target{
569569
AssetType: "unsupported_type",
570570
AssetID: "123",
571571
})
@@ -635,7 +635,7 @@ func TestRun_CorrectConfirmation_SyntheticsMonitor(t *testing.T) {
635635
}
636636
mock := newMock(`{"syntheticsDeleteMonitor":{"deletedGuid":"MON-GUID-42"}}`)
637637
var out bytes.Buffer
638-
if err := Run(context.Background(), mock, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
638+
if err := Run(context.Background(), mock, nil, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
639639
t.Fatalf("unexpected error: %v", err)
640640
}
641641
if mock.callCount() != 1 {
@@ -649,3 +649,84 @@ func TestRun_CorrectConfirmation_SyntheticsMonitor(t *testing.T) {
649649
t.Errorf("expected OK in output, got: %q", out.String())
650650
}
651651
}
652+
653+
// ── Legacy condition deletion tests ──────────────────────────────────────────
654+
655+
type mockRESTClient struct {
656+
calls []int
657+
err error
658+
}
659+
660+
func (m *mockRESTClient) DeleteLegacyCondition(_ context.Context, conditionID int) error {
661+
m.calls = append(m.calls, conditionID)
662+
return m.err
663+
}
664+
665+
func TestParseCSV_LegacyCondition(t *testing.T) {
666+
csv := makeCSV(
667+
// migration-notice row (id_type empty) — should be matched
668+
configRow("1", "Prod", "legacy_condition", "10:55", "Apdex", "Legacy apm_app_metric condition"),
669+
// entity-ID row (id_type non-empty) — should be skipped
670+
"1,Prod,legacy_condition,10:55,Apdex,,monitored entities list,appId,111111,false,,,",
671+
)
672+
targets, err := ParseCSV(strings.NewReader(csv), []string{"legacy_condition"})
673+
if err != nil {
674+
t.Fatalf("unexpected error: %v", err)
675+
}
676+
if len(targets) != 1 {
677+
t.Fatalf("got %d targets, want 1 (only migration-notice row)", len(targets))
678+
}
679+
if targets[0].AssetType != "legacy_condition" {
680+
t.Errorf("AssetType = %q, want legacy_condition", targets[0].AssetType)
681+
}
682+
if targets[0].AssetID != "10:55" {
683+
t.Errorf("AssetID = %q, want 10:55", targets[0].AssetID)
684+
}
685+
}
686+
687+
func TestDeleteLegacyCondition_Success(t *testing.T) {
688+
rest := &mockRESTClient{}
689+
if err := deleteLegacyCondition(context.Background(), rest, "10:55"); err != nil {
690+
t.Fatalf("unexpected error: %v", err)
691+
}
692+
if len(rest.calls) != 1 || rest.calls[0] != 55 {
693+
t.Errorf("expected DeleteLegacyCondition(55), got calls=%v", rest.calls)
694+
}
695+
}
696+
697+
func TestDeleteLegacyCondition_NilRest(t *testing.T) {
698+
if err := deleteLegacyCondition(context.Background(), nil, "10:55"); err == nil {
699+
t.Fatal("expected error when REST client is nil")
700+
}
701+
}
702+
703+
func TestDeleteLegacyCondition_BadAssetID(t *testing.T) {
704+
rest := &mockRESTClient{}
705+
if err := deleteLegacyCondition(context.Background(), rest, "no-colon"); err == nil {
706+
t.Fatal("expected error for malformed asset_id")
707+
}
708+
if len(rest.calls) != 0 {
709+
t.Errorf("expected no REST calls on bad asset_id, got %d", len(rest.calls))
710+
}
711+
}
712+
713+
func TestRun_LegacyCondition(t *testing.T) {
714+
targets := []Target{
715+
{AccountID: 1, AccountName: "Prod", AssetType: "legacy_condition", AssetID: "10:55", AssetName: "Apdex"},
716+
}
717+
ng := &mockNG{}
718+
rest := &mockRESTClient{}
719+
var out bytes.Buffer
720+
if err := Run(context.Background(), ng, rest, targets, false, &out, strings.NewReader("delete 1 assets\n")); err != nil {
721+
t.Fatalf("unexpected error: %v", err)
722+
}
723+
if ng.callCount() != 0 {
724+
t.Errorf("expected 0 NerdGraph calls for legacy_condition, got %d", ng.callCount())
725+
}
726+
if len(rest.calls) != 1 || rest.calls[0] != 55 {
727+
t.Errorf("expected REST DeleteLegacyCondition(55), got calls=%v", rest.calls)
728+
}
729+
if !strings.Contains(out.String(), "OK") {
730+
t.Errorf("expected OK in output, got: %q", out.String())
731+
}
732+
}

internal/cleanup/deleter.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ package cleanup
33
import (
44
"context"
55
"fmt"
6+
"strconv"
67
"strings"
78

89
"github.com/asomensari-es/nr-tf-util/internal/resources"
910
)
1011

11-
// deleteAsset dispatches to the correct NerdGraph operation based on virtual asset type.
12-
func deleteAsset(ctx context.Context, ng resources.NerdGrapher, t Target) error {
12+
// LegacyConditionDeleter deletes a single non-NRQL alert condition via the
13+
// New Relic REST API v2. Implemented by nrRESTClient in cmd/.
14+
type LegacyConditionDeleter interface {
15+
DeleteLegacyCondition(ctx context.Context, conditionID int) error
16+
}
17+
18+
// deleteAsset dispatches to the correct operation based on virtual asset type.
19+
func deleteAsset(ctx context.Context, ng resources.NerdGrapher, rest LegacyConditionDeleter, t Target) error {
1320
switch t.AssetType {
1421
case "nrql_condition":
1522
// asset_id format: "accountId:conditionId"
@@ -33,11 +40,30 @@ func deleteAsset(ctx context.Context, ng resources.NerdGrapher, t Target) error
3340
// asset_id is the monitor entity GUID
3441
return deleteSyntheticsMonitor(ctx, ng, t.AssetID)
3542

43+
case "legacy_condition":
44+
// asset_id format: "policyId:conditionId"
45+
return deleteLegacyCondition(ctx, rest, t.AssetID)
46+
3647
default:
3748
return fmt.Errorf("unsupported asset type: %q", t.AssetType)
3849
}
3950
}
4051

52+
func deleteLegacyCondition(ctx context.Context, rest LegacyConditionDeleter, assetID string) error {
53+
if rest == nil {
54+
return fmt.Errorf("REST client not configured — cannot delete legacy conditions")
55+
}
56+
parts := strings.SplitN(assetID, ":", 2)
57+
if len(parts) != 2 {
58+
return fmt.Errorf("unexpected asset_id format for legacy_condition: %q", assetID)
59+
}
60+
conditionID, err := strconv.Atoi(parts[1])
61+
if err != nil {
62+
return fmt.Errorf("invalid condition ID in asset_id %q: %w", assetID, err)
63+
}
64+
return rest.DeleteLegacyCondition(ctx, conditionID)
65+
}
66+
4167
// ── Full deletions ────────────────────────────────────────────────────────────
4268

4369
func deleteNRQLCondition(ctx context.Context, ng resources.NerdGrapher, accountID int, conditionID string) error {

0 commit comments

Comments
 (0)