@pagopa/dx-savemoney 0.4.0 → 0.6.1

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.
Files changed (47) hide show
  1. package/README.md +48 -0
  2. package/dist/azure/__tests__/azqr.test.d.ts +13 -0
  3. package/dist/azure/__tests__/azqr.test.d.ts.map +1 -0
  4. package/dist/azure/__tests__/azqr.test.js +177 -0
  5. package/dist/azure/__tests__/azqr.test.js.map +1 -0
  6. package/dist/azure/__tests__/config.test.js +23 -1
  7. package/dist/azure/__tests__/config.test.js.map +1 -1
  8. package/dist/azure/analyzer.d.ts.map +1 -1
  9. package/dist/azure/analyzer.js +32 -4
  10. package/dist/azure/analyzer.js.map +1 -1
  11. package/dist/azure/analyzers/advisor.d.ts.map +1 -1
  12. package/dist/azure/analyzers/advisor.js.map +1 -1
  13. package/dist/azure/azqr.d.ts +136 -0
  14. package/dist/azure/azqr.d.ts.map +1 -0
  15. package/dist/azure/azqr.js +235 -0
  16. package/dist/azure/azqr.js.map +1 -0
  17. package/dist/azure/config.d.ts.map +1 -1
  18. package/dist/azure/config.js +2 -5
  19. package/dist/azure/config.js.map +1 -1
  20. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  21. package/dist/azure/resources/public-ip.js.map +1 -1
  22. package/dist/azure/types.d.ts +13 -3
  23. package/dist/azure/types.d.ts.map +1 -1
  24. package/dist/finding.d.ts +2 -1
  25. package/dist/finding.d.ts.map +1 -1
  26. package/dist/finding.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/schema.d.ts +6 -1
  32. package/dist/schema.d.ts.map +1 -1
  33. package/dist/schema.js +11 -3
  34. package/dist/schema.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/azure/__tests__/azqr.test.ts +238 -0
  37. package/src/azure/__tests__/config.test.ts +33 -1
  38. package/src/azure/__tests__/fixtures/full-override.yaml +3 -0
  39. package/src/azure/analyzer.ts +50 -9
  40. package/src/azure/analyzers/advisor.ts +1 -2
  41. package/src/azure/azqr.ts +270 -0
  42. package/src/azure/config.ts +2 -5
  43. package/src/azure/resources/public-ip.ts +1 -2
  44. package/src/azure/types.ts +13 -3
  45. package/src/finding.ts +3 -6
  46. package/src/index.ts +1 -0
  47. package/src/schema.ts +13 -3
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-savemoney",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Azure resource analyzer for finding unused or cost-inefficient resources.",
6
6
  "repository": {
@@ -38,19 +38,19 @@
38
38
  "@logtape/logtape": "^1.3.11",
39
39
  "cli-table3": "^0.6.5",
40
40
  "js-yaml": "^4.3.0",
41
- "p-limit": "^7.3.0",
41
+ "p-limit": "^7.3.1",
42
42
  "zod": "^4.4.3"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@tsconfig/node24": "24.0.4",
46
46
  "@types/js-yaml": "^4.0.9",
47
- "@types/node": "^22.20.0",
48
- "@vitest/coverage-v8": "^4.1.9",
49
- "eslint": "^10.6.0",
50
- "prettier": "3.8.3",
47
+ "@types/node": "^22.20.1",
48
+ "@vitest/coverage-v8": "^4.1.10",
49
+ "eslint": "^10.8.0",
50
+ "prettier": "3.9.6",
51
51
  "typescript": "~5.9.3",
52
- "vitest": "^4.1.9",
53
- "@pagopa/eslint-config": "^6.2.0"
52
+ "vitest": "^4.1.10",
53
+ "@pagopa/eslint-config": "^6.2.2"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "rm -rf dist && tsc",
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Tests for the AZQR ingestion module.
3
+ *
4
+ * Cover the behaviours CES-2192 requires:
5
+ * 1. `parseAzqrReport` validates the JSON shape (accept valid, reject invalid).
6
+ * 2. `isAzqrReportMasked` detects AZQR's default subscription-ID masking.
7
+ * 3. `classifyAzqrRow` / `azqrImpactedToFindings` promote billable waste as
8
+ * `cost`, orphaned free resources as `cleanup` candidates, and drop
9
+ * security / reliability / best-practice noise.
10
+ * 4. Impact → severity mapping and inventory enrichment.
11
+ */
12
+
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import {
16
+ type AzqrImpactedRow,
17
+ azqrImpactedToFindings,
18
+ classifyAzqrRow,
19
+ isAzqrReportMasked,
20
+ parseAzqrReport,
21
+ } from "../azqr.js";
22
+
23
+ const REAL_SUB = "ec285037-c673-4f58-b594-d7c480da4e8b";
24
+ const MASKED_SUB = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxx0da4e8b";
25
+
26
+ function impactedRow(
27
+ overrides: Partial<AzqrImpactedRow> = {},
28
+ ): AzqrImpactedRow {
29
+ return {
30
+ category: "Security",
31
+ impact: "Medium",
32
+ recommendation: "Some best practice",
33
+ resourceId: resourceId(
34
+ REAL_SUB,
35
+ "microsoft.network/networksecuritygroups",
36
+ "nsg",
37
+ ),
38
+ resourceType: "microsoft.network/networksecuritygroups",
39
+ source: "APRL",
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ /** An orphaned (AOR) resource row of the given type. */
45
+ function orphanRow(
46
+ resourceType: string,
47
+ overrides: Partial<AzqrImpactedRow> = {},
48
+ ): AzqrImpactedRow {
49
+ return impactedRow({
50
+ category: "Governance",
51
+ resourceId: resourceId(REAL_SUB, resourceType, "res"),
52
+ resourceType,
53
+ source: "AOR",
54
+ ...overrides,
55
+ });
56
+ }
57
+
58
+ function resourceId(sub: string, type: string, name: string): string {
59
+ return `/subscriptions/${sub}/resourcegroups/rg/providers/${type}/${name}`;
60
+ }
61
+
62
+ describe("parseAzqrReport", () => {
63
+ it("accepts a valid report and defaults missing arrays", () => {
64
+ const report = parseAzqrReport({ impacted: [impactedRow()] });
65
+ expect(report.impacted).toHaveLength(1);
66
+ expect(report.inventory).toEqual([]);
67
+ });
68
+
69
+ it("strips unknown top-level sections", () => {
70
+ const report = parseAzqrReport({
71
+ advisor: [{ anything: true }],
72
+ impacted: [],
73
+ recommendations: [{ foo: "bar" }],
74
+ });
75
+ expect(report.impacted).toEqual([]);
76
+ });
77
+
78
+ it("throws with a cause when an impacted row misses required fields", () => {
79
+ expect(() => parseAzqrReport({ impacted: [{ impact: "High" }] })).toThrow(
80
+ /Invalid AZQR report/,
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("isAzqrReportMasked", () => {
86
+ it("detects masked subscription IDs in resource IDs", () => {
87
+ const report = parseAzqrReport({
88
+ impacted: [
89
+ impactedRow({
90
+ resourceId: resourceId(
91
+ MASKED_SUB,
92
+ "microsoft.network/publicipaddresses",
93
+ "pip",
94
+ ),
95
+ }),
96
+ ],
97
+ });
98
+ expect(isAzqrReportMasked(report)).toBe(true);
99
+ });
100
+
101
+ it("returns false when IDs carry the real subscription GUID", () => {
102
+ const report = parseAzqrReport({ impacted: [impactedRow()] });
103
+ expect(isAzqrReportMasked(report)).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("classifyAzqrRow", () => {
108
+ it("classifies rows AZQR categorises as Cost as cost", () => {
109
+ expect(classifyAzqrRow(impactedRow({ category: "Cost" }))).toBe("cost");
110
+ });
111
+
112
+ it("classifies orphaned billable resources as cost", () => {
113
+ expect(
114
+ classifyAzqrRow(
115
+ orphanRow("microsoft.network/publicipaddresses", {
116
+ recommendation: "Public IPs not attached to any resource",
117
+ }),
118
+ ),
119
+ ).toBe("cost");
120
+ });
121
+
122
+ it("classifies orphaned free resources (NSG, subnet, …) as cleanup", () => {
123
+ expect(
124
+ classifyAzqrRow(
125
+ orphanRow("microsoft.network/networksecuritygroups", {
126
+ recommendation:
127
+ "Network Security Groups not attached to any network interface or subnet",
128
+ }),
129
+ ),
130
+ ).toBe("cleanup");
131
+ });
132
+
133
+ it("drops non-orphan security / reliability best-practice rows", () => {
134
+ expect(
135
+ classifyAzqrRow(
136
+ impactedRow({
137
+ category: "Security",
138
+ recommendation: "Enable diagnostic settings",
139
+ resourceType: "microsoft.web/serverfarms",
140
+ }),
141
+ ),
142
+ ).toBeNull();
143
+ });
144
+
145
+ it("does not promote orphan-sounding text unless the AOR source tags it", () => {
146
+ expect(
147
+ classifyAzqrRow(
148
+ impactedRow({
149
+ category: "Governance",
150
+ recommendation: "Public IPs not attached to any resource",
151
+ resourceType: "microsoft.network/publicipaddresses",
152
+ source: "APRL",
153
+ }),
154
+ ),
155
+ ).toBeNull();
156
+ });
157
+ });
158
+
159
+ describe("azqrImpactedToFindings", () => {
160
+ it("maps billable orphans to cost findings and drops non-orphan noise", () => {
161
+ const report = parseAzqrReport({
162
+ impacted: [
163
+ orphanRow("microsoft.web/serverfarms", {
164
+ impact: "High",
165
+ recommendation: "App Service plans without hosting Apps",
166
+ recommendationId: "app-service-empty",
167
+ resourceId: resourceId(REAL_SUB, "microsoft.web/serverfarms", "asp"),
168
+ }),
169
+ impactedRow(), // non-orphan APRL noise → dropped
170
+ ],
171
+ });
172
+
173
+ const findings = azqrImpactedToFindings(report);
174
+
175
+ expect(findings).toHaveLength(1);
176
+ expect(findings[0]).toMatchObject({
177
+ category: "cost",
178
+ code: "azqr.app-service-empty",
179
+ severity: "high",
180
+ source: "azqr",
181
+ });
182
+ expect(findings[0].estimatedMonthlySavings).toBeUndefined();
183
+ expect(findings[0].recommendedAction).toContain("Review the resource");
184
+ });
185
+
186
+ it("maps orphaned free resources to low-severity cleanup candidates", () => {
187
+ const report = parseAzqrReport({
188
+ impacted: [
189
+ orphanRow("microsoft.network/virtualnetworks", {
190
+ impact: "Medium",
191
+ recommendation: "Subnets without Connected Devices or Delegation",
192
+ recommendationId: "empty-subnet",
193
+ }),
194
+ ],
195
+ });
196
+
197
+ const [finding] = azqrImpactedToFindings(report);
198
+ expect(finding).toMatchObject({
199
+ category: "operationalExcellence",
200
+ code: "azqr.empty-subnet",
201
+ severity: "low",
202
+ source: "azqr",
203
+ });
204
+ expect(finding.recommendedAction).toContain("no direct cost");
205
+ });
206
+
207
+ it("enriches the reason with inventory SKU / location when available", () => {
208
+ const id = resourceId(REAL_SUB, "microsoft.network/natgateways", "nat");
209
+ const report = parseAzqrReport({
210
+ impacted: [
211
+ orphanRow("microsoft.network/natgateways", {
212
+ recommendation: "NAT Gateways not attached to any subnet",
213
+ resourceId: id,
214
+ }),
215
+ ],
216
+ inventory: [
217
+ { location: "italynorth", resourceId: id, skuName: "Standard" },
218
+ ],
219
+ });
220
+
221
+ const [finding] = azqrImpactedToFindings(report);
222
+ expect(finding.reason).toContain("Standard");
223
+ expect(finding.reason).toContain("italynorth");
224
+ });
225
+
226
+ it("defaults severity to low for unknown impact on cost rows", () => {
227
+ const report = parseAzqrReport({
228
+ impacted: [
229
+ impactedRow({
230
+ category: "Cost",
231
+ impact: undefined,
232
+ resourceType: "microsoft.compute/disks",
233
+ }),
234
+ ],
235
+ });
236
+ expect(azqrImpactedToFindings(report)[0].severity).toBe("low");
237
+ });
238
+ });
@@ -8,7 +8,9 @@
8
8
 
9
9
  import path from "path";
10
10
  import { fileURLToPath } from "url";
11
- import { describe, expect, it } from "vitest";
11
+ import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
12
+
13
+ import type { AzureConfig, AzureSource } from "../../index.js";
12
14
 
13
15
  import { loadConfig } from "../../index.js";
14
16
  import { ConfigSchema } from "../../schema.js";
@@ -24,6 +26,10 @@ const FIXTURE_PARTIAL = path.resolve(
24
26
  const FIXTURE_FULL = path.resolve(__dirname, "fixtures/full-override.yaml");
25
27
 
26
28
  describe("loadConfig", () => {
29
+ afterEach(() => {
30
+ vi.unstubAllEnvs();
31
+ });
32
+
27
33
  it("throws when explicit path does not exist", async () => {
28
34
  await expect(loadConfig("/nonexistent/config.yaml")).rejects.toThrow(
29
35
  "Config file not found",
@@ -33,11 +39,24 @@ describe("loadConfig", () => {
33
39
  it("loads subscriptionIds and defaults from a partial YAML file", async () => {
34
40
  const result = await loadConfig(FIXTURE_PARTIAL);
35
41
 
42
+ expectTypeOf<AzureConfig["sources"]>().toEqualTypeOf<
43
+ [AzureSource, ...AzureSource[]]
44
+ >();
36
45
  expect(result.subscriptionIds).toEqual([
37
46
  "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
38
47
  ]);
39
48
  expect(result.preferredLocation).toBe("italynorth");
49
+ expect(result.sources).toEqual(["advisor", "custom"]);
40
50
  expect(result.timespanDays).toBe(30);
51
+ expect(result.azqrReportPath).toBeUndefined();
52
+ });
53
+
54
+ it("applies sources defaults when loading from the environment", async () => {
55
+ vi.stubEnv("ARM_SUBSCRIPTION_ID", "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx");
56
+
57
+ const result = await loadConfig();
58
+
59
+ expect(result.sources).toEqual(["advisor", "custom"]);
41
60
  });
42
61
 
43
62
  it("loads partial threshold overrides and keeps defaults for missing fields", async () => {
@@ -70,7 +89,9 @@ describe("loadConfig", () => {
70
89
 
71
90
  expect(result.subscriptionIds).toHaveLength(2);
72
91
  expect(result.preferredLocation).toBe("westeurope");
92
+ expect(result.sources).toEqual(["custom"]);
73
93
  expect(result.timespanDays).toBe(60);
94
+ expect(result.azqrReportPath).toBe("./azqr_action_plan.json");
74
95
 
75
96
  expect(result.thresholds?.vm.cpuPercent).toBe(5);
76
97
  expect(result.thresholds?.vm.networkInBytesPerDay).toBe(10485760);
@@ -96,6 +117,17 @@ describe("loadConfig", () => {
96
117
  expect(result.thresholds).toHaveProperty("staticSite");
97
118
  });
98
119
 
120
+ it("rejects an empty sources list", () => {
121
+ const result = ConfigSchema.safeParse({
122
+ azure: {
123
+ sources: [],
124
+ subscriptionIds: ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"],
125
+ },
126
+ });
127
+
128
+ expect(result.success).toBe(false);
129
+ });
130
+
99
131
  it("does not expose pricing settings in YAML config", () => {
100
132
  const result = ConfigSchema.safeParse({
101
133
  azure: {
@@ -3,6 +3,9 @@ azure:
3
3
  - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
4
4
  - yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
5
5
  preferredLocation: westeurope
6
+ azqrReportPath: ./azqr_action_plan.json
7
+ sources:
8
+ - custom
6
9
  timespanDays: 60
7
10
  thresholds:
8
11
  vm:
@@ -32,11 +32,7 @@ import { getLogger } from "@logtape/logtape";
32
32
  import pLimit from "p-limit";
33
33
 
34
34
  import type { Finding } from "../finding.js";
35
- import type {
36
- AzureConfig,
37
- AzureDetailedResourceReport,
38
- AzureSource,
39
- } from "./types.js";
35
+ import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
40
36
 
41
37
  import { findingsFromAnalysisResult } from "../finding.js";
42
38
  import {
@@ -54,11 +50,15 @@ import {
54
50
  createDefaultSubscriptionAnalyzers,
55
51
  type SubscriptionAnalyzer,
56
52
  } from "./analyzers/index.js";
53
+ import {
54
+ azqrImpactedToFindings,
55
+ isAzqrReportMasked,
56
+ loadAzqrReport,
57
+ } from "./azqr.js";
57
58
  import { PricingClient, PricingService } from "./pricing/index.js";
58
59
  import { matchesTags, type MetricsCache } from "./utils.js";
59
60
 
60
61
  const DEFAULT_CONCURRENCY = 8;
61
- const DEFAULT_SOURCES: AzureSource[] = ["advisor", "custom"];
62
62
 
63
63
  const RISK_ORDER: Record<CostRisk, number> = { high: 0, low: 2, medium: 1 };
64
64
 
@@ -86,9 +86,8 @@ export async function analyzeAzureResources(
86
86
  const credential = new DefaultAzureCredential();
87
87
  const allReports: AzureDetailedResourceReport[] = [];
88
88
 
89
- const sources = config.sources ?? DEFAULT_SOURCES;
90
- const customEnabled = sources.includes("custom");
91
- const advisorEnabled = sources.includes("advisor");
89
+ const customEnabled = config.sources.includes("custom");
90
+ const advisorEnabled = config.sources.includes("advisor");
92
91
 
93
92
  const analyzers = createDefaultAnalyzers();
94
93
  const subscriptionAnalyzers = advisorEnabled
@@ -179,6 +178,12 @@ export async function analyzeAzureResources(
179
178
  }
180
179
  }
181
180
 
181
+ // Ingest an AZQR (`azqr scan --json`) report when provided, merging its
182
+ // FinOps-relevant `impacted` rows into the aggregated reports as findings.
183
+ if (config.azqrReportPath) {
184
+ await ingestAzqrReport(config.azqrReportPath, allReports, logger);
185
+ }
186
+
182
187
  // Sort to make the output more readable:
183
188
  // - Subscription-scoped findings (Reserved Instances, savings plans, ...)
184
189
  // sink to the bottom: they aggregate many recommendations into a single
@@ -439,6 +444,42 @@ function hasTagFilter(filterTags: Map<string, string> | undefined): boolean {
439
444
  return Boolean(filterTags && filterTags.size > 0);
440
445
  }
441
446
 
447
+ /**
448
+ * Loads an AZQR report, filters it down to FinOps opportunities and merges the
449
+ * resulting findings into the aggregated reports. Runs once per invocation
450
+ * (the report is subscription-agnostic), building a global index from every
451
+ * report collected during the live scan so AZQR findings attach to their
452
+ * resource when present or create a stub otherwise.
453
+ *
454
+ * When the report is still masked, findings cannot match live resource IDs, so
455
+ * a warning advises re-running AZQR with `--mask=false`.
456
+ */
457
+ async function ingestAzqrReport(
458
+ azqrReportPath: string,
459
+ reports: AzureDetailedResourceReport[],
460
+ logger: ReturnType<typeof getLogger>,
461
+ ): Promise<void> {
462
+ const report = await loadAzqrReport(azqrReportPath);
463
+ if (isAzqrReportMasked(report)) {
464
+ logger.warn(
465
+ "AZQR report has masked subscription IDs: findings may not match live" +
466
+ " resources and can appear as separate stub entries. Re-run with" +
467
+ " `azqr scan --mask=false` for accurate merging.",
468
+ );
469
+ }
470
+
471
+ const findings = azqrImpactedToFindings(report);
472
+ const reportsById = new Map<string, AzureDetailedResourceReport>(
473
+ reports.map((r) => [(r.resource.id ?? "").toLowerCase(), r]),
474
+ );
475
+ for (const finding of findings) {
476
+ mergeFinding(finding, reports, reportsById);
477
+ }
478
+ logger.info(
479
+ `AZQR: merged ${findings.length} finding(s) from ${azqrReportPath}`,
480
+ );
481
+ }
482
+
442
483
  function isResourceScopedFinding(resourceId: string): boolean {
443
484
  return /\/providers\//i.test(resourceId);
444
485
  }
@@ -111,8 +111,7 @@ export function createAdvisorAnalyzer(clientFactory?: {
111
111
  if (rec.category?.toLowerCase() !== "cost") continue;
112
112
  const rawResourceId = rec.resourceMetadata?.resourceId;
113
113
  const props = rec.extendedProperties as
114
- | Record<string, string>
115
- | undefined;
114
+ Record<string, string> | undefined;
116
115
  const savings = parseSavings(props);
117
116
  if (rawResourceId && /\/providers\//i.test(rawResourceId)) {
118
117
  resourceFindings.push(