@pagopa/dx-savemoney 0.2.5 → 0.3.0

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 (103) hide show
  1. package/README.md +33 -27
  2. package/dist/azure/analyzer.d.ts +49 -21
  3. package/dist/azure/analyzer.d.ts.map +1 -1
  4. package/dist/azure/analyzer.js +369 -93
  5. package/dist/azure/analyzer.js.map +1 -1
  6. package/dist/azure/analyzers/advisor.d.ts +68 -0
  7. package/dist/azure/analyzers/advisor.d.ts.map +1 -0
  8. package/dist/azure/analyzers/advisor.js +234 -0
  9. package/dist/azure/analyzers/advisor.js.map +1 -0
  10. package/dist/azure/analyzers/index.d.ts +8 -0
  11. package/dist/azure/analyzers/index.d.ts.map +1 -0
  12. package/dist/azure/analyzers/index.js +6 -0
  13. package/dist/azure/analyzers/index.js.map +1 -0
  14. package/dist/azure/analyzers/registry.d.ts +29 -0
  15. package/dist/azure/analyzers/registry.d.ts.map +1 -0
  16. package/dist/azure/analyzers/registry.js +79 -0
  17. package/dist/azure/analyzers/registry.js.map +1 -0
  18. package/dist/azure/analyzers/subscription.d.ts +53 -0
  19. package/dist/azure/analyzers/subscription.d.ts.map +1 -0
  20. package/dist/azure/analyzers/subscription.js +18 -0
  21. package/dist/azure/analyzers/subscription.js.map +1 -0
  22. package/dist/azure/analyzers/types.d.ts +62 -0
  23. package/dist/azure/analyzers/types.d.ts.map +1 -0
  24. package/dist/azure/analyzers/types.js +15 -0
  25. package/dist/azure/analyzers/types.js.map +1 -0
  26. package/dist/azure/config.d.ts.map +1 -1
  27. package/dist/azure/config.js +2 -0
  28. package/dist/azure/config.js.map +1 -1
  29. package/dist/azure/index.d.ts +1 -0
  30. package/dist/azure/index.d.ts.map +1 -1
  31. package/dist/azure/index.js +1 -0
  32. package/dist/azure/index.js.map +1 -1
  33. package/dist/azure/report.d.ts.map +1 -1
  34. package/dist/azure/report.js +178 -29
  35. package/dist/azure/report.js.map +1 -1
  36. package/dist/azure/resources/app-service.d.ts +2 -1
  37. package/dist/azure/resources/app-service.d.ts.map +1 -1
  38. package/dist/azure/resources/app-service.js +3 -3
  39. package/dist/azure/resources/app-service.js.map +1 -1
  40. package/dist/azure/resources/container-app.d.ts +2 -1
  41. package/dist/azure/resources/container-app.d.ts.map +1 -1
  42. package/dist/azure/resources/container-app.js +9 -9
  43. package/dist/azure/resources/container-app.js.map +1 -1
  44. package/dist/azure/resources/public-ip.d.ts +2 -1
  45. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  46. package/dist/azure/resources/public-ip.js +2 -2
  47. package/dist/azure/resources/public-ip.js.map +1 -1
  48. package/dist/azure/resources/static-web-app.d.ts +2 -1
  49. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  50. package/dist/azure/resources/static-web-app.js +3 -3
  51. package/dist/azure/resources/static-web-app.js.map +1 -1
  52. package/dist/azure/resources/storage.d.ts +2 -1
  53. package/dist/azure/resources/storage.d.ts.map +1 -1
  54. package/dist/azure/resources/storage.js +2 -2
  55. package/dist/azure/resources/storage.js.map +1 -1
  56. package/dist/azure/resources/vm.d.ts +2 -1
  57. package/dist/azure/resources/vm.d.ts.map +1 -1
  58. package/dist/azure/resources/vm.js +3 -3
  59. package/dist/azure/resources/vm.js.map +1 -1
  60. package/dist/azure/types.d.ts +34 -1
  61. package/dist/azure/types.d.ts.map +1 -1
  62. package/dist/azure/utils.d.ts +35 -3
  63. package/dist/azure/utils.d.ts.map +1 -1
  64. package/dist/azure/utils.js +70 -29
  65. package/dist/azure/utils.js.map +1 -1
  66. package/dist/finding.d.ts +114 -0
  67. package/dist/finding.d.ts.map +1 -0
  68. package/dist/finding.js +51 -0
  69. package/dist/finding.js.map +1 -0
  70. package/dist/index.d.ts +4 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/schema.d.ts +5 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +14 -0
  77. package/dist/schema.js.map +1 -1
  78. package/package.json +4 -1
  79. package/src/__tests__/finding.test.ts +149 -0
  80. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  81. package/src/azure/__tests__/report.test.ts +27 -0
  82. package/src/azure/__tests__/utils.test.ts +164 -2
  83. package/src/azure/analyzer.ts +513 -182
  84. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  85. package/src/azure/analyzers/advisor.ts +324 -0
  86. package/src/azure/analyzers/index.ts +14 -0
  87. package/src/azure/analyzers/registry.ts +196 -0
  88. package/src/azure/analyzers/subscription.ts +56 -0
  89. package/src/azure/analyzers/types.ts +66 -0
  90. package/src/azure/config.ts +2 -0
  91. package/src/azure/index.ts +1 -0
  92. package/src/azure/report.ts +206 -35
  93. package/src/azure/resources/app-service.ts +4 -0
  94. package/src/azure/resources/container-app.ts +10 -0
  95. package/src/azure/resources/public-ip.ts +3 -0
  96. package/src/azure/resources/static-web-app.ts +4 -0
  97. package/src/azure/resources/storage.ts +3 -0
  98. package/src/azure/resources/vm.ts +4 -0
  99. package/src/azure/types.ts +35 -1
  100. package/src/azure/utils.ts +110 -39
  101. package/src/finding.ts +152 -0
  102. package/src/index.ts +19 -1
  103. package/src/schema.ts +14 -0
@@ -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.2.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Azure resource analyzer for finding unused or cost-inefficient resources.",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  "DX"
28
28
  ],
29
29
  "dependencies": {
30
+ "@azure/arm-advisor": "^3.2.0",
30
31
  "@azure/arm-appcontainers": "^3.0.0",
31
32
  "@azure/arm-appservice": "^17.0.0",
32
33
  "@azure/arm-compute": "^23.3.0",
@@ -35,7 +36,9 @@
35
36
  "@azure/arm-resources": "^7.0.0",
36
37
  "@azure/identity": "^4.13.1",
37
38
  "@logtape/logtape": "^1.3.8",
39
+ "cli-table3": "^0.6.5",
38
40
  "js-yaml": "^4.1.1",
41
+ "p-limit": "^7.3.0",
39
42
  "zod": "^4.4.2"
40
43
  },
41
44
  "devDependencies": {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tests for findingsFromAnalysisResult — verifies the adapter that converts
3
+ * a legacy `AnalysisResult.reason` string into a structured `Finding[]`.
4
+ *
5
+ * Behaviours covered:
6
+ * 1. Empty / whitespace-only reason → empty array.
7
+ * 2. Single sentence (with or without trailing period) → one finding.
8
+ * 3. Multi-sentence reason (". " separator) → one finding per sentence.
9
+ * 4. Trailing period on the whole string → not duplicated.
10
+ * 5. Custom `code` → propagated to every finding.
11
+ * 6. Omitted `code` → defaults to "custom.unknown".
12
+ * 7. Custom `source` → propagated.
13
+ * 8. Omitted `source` → defaults to "custom".
14
+ * 9. Every finding carries the correct `resourceId`, `severity`, `category`.
15
+ */
16
+
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import { findingsFromAnalysisResult } from "../finding.js";
20
+
21
+ const BASE_ARGS = {
22
+ reason: "Low CPU usage.",
23
+ resourceId:
24
+ "/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm",
25
+ severity: "high" as const,
26
+ };
27
+
28
+ describe("findingsFromAnalysisResult", () => {
29
+ describe("empty / whitespace input", () => {
30
+ it("returns [] for an empty string", () => {
31
+ expect(findingsFromAnalysisResult({ ...BASE_ARGS, reason: "" })).toEqual(
32
+ [],
33
+ );
34
+ });
35
+
36
+ it("returns [] for a whitespace-only string", () => {
37
+ expect(
38
+ findingsFromAnalysisResult({ ...BASE_ARGS, reason: " " }),
39
+ ).toEqual([]);
40
+ });
41
+
42
+ it("returns [] for a bare period", () => {
43
+ expect(findingsFromAnalysisResult({ ...BASE_ARGS, reason: "." })).toEqual(
44
+ [],
45
+ );
46
+ });
47
+ });
48
+
49
+ describe("single sentence", () => {
50
+ it("returns one finding with a trailing period", () => {
51
+ const result = findingsFromAnalysisResult({
52
+ ...BASE_ARGS,
53
+ reason: "VM is deallocated.",
54
+ });
55
+ expect(result).toHaveLength(1);
56
+ expect(result[0].reason).toBe("VM is deallocated.");
57
+ });
58
+
59
+ it("appends a trailing period when missing", () => {
60
+ const result = findingsFromAnalysisResult({
61
+ ...BASE_ARGS,
62
+ reason: "VM is deallocated",
63
+ });
64
+ expect(result).toHaveLength(1);
65
+ expect(result[0].reason).toBe("VM is deallocated.");
66
+ });
67
+ });
68
+
69
+ describe("multi-sentence reason", () => {
70
+ it("splits on '. ' into separate findings", () => {
71
+ const result = findingsFromAnalysisResult({
72
+ ...BASE_ARGS,
73
+ reason: "VM is deallocated. No tags found. Low CPU usage.",
74
+ });
75
+ expect(result).toHaveLength(3);
76
+ expect(result[0].reason).toBe("VM is deallocated.");
77
+ expect(result[1].reason).toBe("No tags found.");
78
+ expect(result[2].reason).toBe("Low CPU usage.");
79
+ });
80
+
81
+ it("does not create an extra empty finding for a trailing period", () => {
82
+ const result = findingsFromAnalysisResult({
83
+ ...BASE_ARGS,
84
+ reason: "Sentence one. Sentence two.",
85
+ });
86
+ expect(result).toHaveLength(2);
87
+ });
88
+ });
89
+
90
+ describe("code field", () => {
91
+ it("uses the provided code for every finding", () => {
92
+ const result = findingsFromAnalysisResult({
93
+ ...BASE_ARGS,
94
+ code: "vm.deallocated",
95
+ reason: "A. B.",
96
+ });
97
+ expect(result.every((f) => f.code === "vm.deallocated")).toBe(true);
98
+ });
99
+
100
+ it("defaults code to 'custom.unknown' when omitted", () => {
101
+ const result = findingsFromAnalysisResult(BASE_ARGS);
102
+ expect(result[0].code).toBe("custom.unknown");
103
+ });
104
+ });
105
+
106
+ describe("source field", () => {
107
+ it("uses the provided source for every finding", () => {
108
+ const result = findingsFromAnalysisResult({
109
+ ...BASE_ARGS,
110
+ source: "advisor",
111
+ });
112
+ expect(result.every((f) => f.source === "advisor")).toBe(true);
113
+ });
114
+
115
+ it("defaults source to 'custom' when omitted", () => {
116
+ const result = findingsFromAnalysisResult(BASE_ARGS);
117
+ expect(result[0].source).toBe("custom");
118
+ });
119
+ });
120
+
121
+ describe("static fields on every finding", () => {
122
+ it("propagates resourceId to every finding", () => {
123
+ const result = findingsFromAnalysisResult({
124
+ ...BASE_ARGS,
125
+ reason: "A. B.",
126
+ });
127
+ expect(result.every((f) => f.resourceId === BASE_ARGS.resourceId)).toBe(
128
+ true,
129
+ );
130
+ });
131
+
132
+ it("propagates severity to every finding", () => {
133
+ const result = findingsFromAnalysisResult({
134
+ ...BASE_ARGS,
135
+ reason: "A. B.",
136
+ severity: "medium",
137
+ });
138
+ expect(result.every((f) => f.severity === "medium")).toBe(true);
139
+ });
140
+
141
+ it("sets category to 'cost' on every finding", () => {
142
+ const result = findingsFromAnalysisResult({
143
+ ...BASE_ARGS,
144
+ reason: "A. B.",
145
+ });
146
+ expect(result.every((f) => f.category === "cost")).toBe(true);
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unit tests for tag-filter behavior on Advisor findings.
3
+ *
4
+ * These tests exercise the pure helper used by the Azure analyzer
5
+ * orchestrator to keep filtering semantics explicit and stable.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import type { Finding } from "../../finding.js";
11
+
12
+ import { shouldIncludeAdvisorFindingForTags } from "../analyzer.js";
13
+
14
+ function mkFinding(resourceId: string, source: Finding["source"]): Finding {
15
+ return {
16
+ category: "cost",
17
+ code: `${source}.test`,
18
+ reason: "Test finding.",
19
+ resourceId,
20
+ severity: "low",
21
+ source,
22
+ };
23
+ }
24
+
25
+ describe("shouldIncludeAdvisorFindingForTags", () => {
26
+ it("includes all findings when no tag filter is active", () => {
27
+ const finding = mkFinding(
28
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1",
29
+ "advisor",
30
+ );
31
+
32
+ expect(
33
+ shouldIncludeAdvisorFindingForTags(finding, new Set<string>(), false),
34
+ ).toBe(true);
35
+ });
36
+
37
+ it("keeps subscription-level Advisor findings global even with tag filters", () => {
38
+ const finding = mkFinding("/subscriptions/sub1", "advisor");
39
+
40
+ expect(
41
+ shouldIncludeAdvisorFindingForTags(finding, new Set<string>(), true),
42
+ ).toBe(true);
43
+ });
44
+
45
+ it("includes resource-level Advisor findings only when resource id is tag-matched", () => {
46
+ const finding = mkFinding(
47
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1",
48
+ "advisor",
49
+ );
50
+
51
+ const taggedResourceIds = new Set<string>([
52
+ "/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.compute/virtualmachines/vm1",
53
+ ]);
54
+
55
+ expect(
56
+ shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, true),
57
+ ).toBe(true);
58
+ });
59
+
60
+ it("excludes resource-level Advisor findings when resource id is not tag-matched", () => {
61
+ const finding = mkFinding(
62
+ "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2",
63
+ "advisor",
64
+ );
65
+
66
+ const taggedResourceIds = new Set<string>([
67
+ "/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.compute/virtualmachines/vm1",
68
+ ]);
69
+
70
+ expect(
71
+ shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, true),
72
+ ).toBe(false);
73
+ });
74
+ });
@@ -136,3 +136,30 @@ describe("generateReport — lint format", () => {
136
136
  expect(calls[0]).toContain("0 issues found");
137
137
  });
138
138
  });
139
+
140
+ describe("generateReport — table format", () => {
141
+ let logSpy: ReturnType<typeof vi.spyOn>;
142
+
143
+ beforeEach(() => {
144
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
145
+ });
146
+
147
+ afterEach(() => {
148
+ vi.restoreAllMocks();
149
+ });
150
+
151
+ it("renders without throwing for an empty report", async () => {
152
+ await expect(generateReport([], "table")).resolves.toBeUndefined();
153
+ // Table is always printed; the summary line ("0 issues found") follows.
154
+ expect(logSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
155
+ const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
156
+ expect(output).toContain("0 issues found");
157
+ });
158
+
159
+ it("includes resource name and reason in the rendered table", async () => {
160
+ await generateReport([HIGH_ENTRY], "table");
161
+ const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
162
+ expect(output).toContain("vm-high");
163
+ expect(output).toContain("VM is deallocated");
164
+ });
165
+ });
@@ -5,13 +5,24 @@
5
5
  * 3. Key missing on resource → exclude.
6
6
  * 4. Key present but wrong value → exclude.
7
7
  * 5. Multiple tags: all match → include; any mismatch → exclude.
8
+ *
9
+ * Tests for getMetric() cache behaviour:
10
+ * 6. resetMetricsCache() clears state between runs.
11
+ * 7. Concurrent calls for the same key coalesce into one network call.
12
+ * 8. A failed call is cached as a fulfilled null result (not retried silently).
8
13
  */
9
14
 
10
15
  import type { GenericResource } from "@azure/arm-resources";
11
16
 
12
- import { describe, expect, it } from "vitest";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
18
 
14
- import { matchesTags } from "../utils.js";
19
+ import {
20
+ _metricsCacheSize,
21
+ getMetric,
22
+ matchesTags,
23
+ type MonitorClientLike,
24
+ resetMetricsCache,
25
+ } from "../utils.js";
15
26
 
16
27
  function makeResource(tags?: Record<string, string>): GenericResource {
17
28
  return { id: "r1", name: "res", tags };
@@ -122,3 +133,154 @@ describe("matchesTags", () => {
122
133
  });
123
134
  });
124
135
  });
136
+
137
+ // ── metrics cache ──────────────────────────────────────────────────────────
138
+
139
+ function makeFailingMonitorClient(calls: number[] = []): MonitorClientLike {
140
+ return {
141
+ metrics: {
142
+ list: vi.fn().mockImplementation(async () => {
143
+ calls.push(1);
144
+ throw new Error("network error");
145
+ }),
146
+ },
147
+ };
148
+ }
149
+
150
+ function makeMonitorClient(
151
+ returnValue: null | number,
152
+ calls: number[] = [],
153
+ ): MonitorClientLike {
154
+ return {
155
+ metrics: {
156
+ list: vi.fn().mockImplementation(async () => {
157
+ calls.push(1);
158
+ if (returnValue === null) {
159
+ return { value: [] };
160
+ }
161
+ return {
162
+ value: [
163
+ {
164
+ timeseries: [{ data: [{ average: returnValue }] }],
165
+ },
166
+ ],
167
+ };
168
+ }),
169
+ },
170
+ };
171
+ }
172
+
173
+ describe("getMetric — in-memory cache", () => {
174
+ beforeEach(() => {
175
+ resetMetricsCache();
176
+ });
177
+
178
+ afterEach(() => {
179
+ vi.restoreAllMocks();
180
+ });
181
+
182
+ it("resetMetricsCache clears all cached entries", async () => {
183
+ const calls: number[] = [];
184
+ const client = makeMonitorClient(42, calls);
185
+
186
+ await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
187
+ expect(_metricsCacheSize()).toBe(1);
188
+
189
+ resetMetricsCache();
190
+ expect(_metricsCacheSize()).toBe(0);
191
+ });
192
+
193
+ it("does not call the API a second time for the same key", async () => {
194
+ const calls: number[] = [];
195
+ const client = makeMonitorClient(10, calls);
196
+
197
+ await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
198
+ await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
199
+
200
+ expect(calls.length).toBe(1);
201
+ });
202
+
203
+ it("concurrent calls for the same key coalesce into one network call", async () => {
204
+ const calls: number[] = [];
205
+ const client = makeMonitorClient(5, calls);
206
+
207
+ await Promise.all([
208
+ getMetric(client, "/res/2", "Network In Total", "Average", 30),
209
+ getMetric(client, "/res/2", "Network In Total", "Average", 30),
210
+ getMetric(client, "/res/2", "Network In Total", "Average", 30),
211
+ ]);
212
+
213
+ expect(calls.length).toBe(1);
214
+ });
215
+
216
+ it("different keys result in separate network calls", async () => {
217
+ const calls: number[] = [];
218
+ const client = makeMonitorClient(1, calls);
219
+
220
+ await getMetric(client, "/res/a", "Percentage CPU", "Average", 7);
221
+ await getMetric(client, "/res/b", "Percentage CPU", "Average", 7);
222
+
223
+ expect(calls.length).toBe(2);
224
+ });
225
+
226
+ it("returns null and caches the null result when no data points are available", async () => {
227
+ const calls: number[] = [];
228
+ const client = makeMonitorClient(null, calls);
229
+
230
+ const first = await getMetric(
231
+ client,
232
+ "/res/3",
233
+ "Percentage CPU",
234
+ "Average",
235
+ 7,
236
+ );
237
+ const second = await getMetric(
238
+ client,
239
+ "/res/3",
240
+ "Percentage CPU",
241
+ "Average",
242
+ 7,
243
+ );
244
+
245
+ expect(first).toBeNull();
246
+ expect(second).toBeNull();
247
+ expect(calls.length).toBe(1);
248
+ });
249
+
250
+ it("a failing call is cached and returns null on retry", async () => {
251
+ const calls: number[] = [];
252
+ const client = makeFailingMonitorClient(calls);
253
+
254
+ const first = await getMetric(
255
+ client,
256
+ "/res/4",
257
+ "Percentage CPU",
258
+ "Average",
259
+ 7,
260
+ );
261
+ const second = await getMetric(
262
+ client,
263
+ "/res/4",
264
+ "Percentage CPU",
265
+ "Average",
266
+ 7,
267
+ );
268
+
269
+ // Both calls return null (error is swallowed by getMetric)
270
+ expect(first).toBeNull();
271
+ expect(second).toBeNull();
272
+ // Only one actual network call despite two getMetric calls
273
+ expect(calls.length).toBe(1);
274
+ });
275
+
276
+ it("after resetMetricsCache the API is called again", async () => {
277
+ const calls: number[] = [];
278
+ const client = makeMonitorClient(7, calls);
279
+
280
+ await getMetric(client, "/res/5", "Percentage CPU", "Average", 7);
281
+ resetMetricsCache();
282
+ await getMetric(client, "/res/5", "Percentage CPU", "Average", 7);
283
+
284
+ expect(calls.length).toBe(2);
285
+ });
286
+ });