@pagopa/dx-savemoney 0.6.4 → 0.6.6
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.
- package/README.md +38 -0
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +42 -16
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/advisor.js +11 -0
- package/dist/azure/analyzers/advisor.js.map +1 -1
- package/dist/azure/azqr.d.ts +3 -3
- package/dist/azure/azqr.d.ts.map +1 -1
- package/dist/azure/azqr.js +26 -4
- package/dist/azure/azqr.js.map +1 -1
- package/dist/azure/finding-category.d.ts +45 -0
- package/dist/azure/finding-category.d.ts.map +1 -0
- package/dist/azure/finding-category.js +65 -0
- package/dist/azure/finding-category.js.map +1 -0
- package/dist/azure/finding-code.d.ts +46 -0
- package/dist/azure/finding-code.d.ts.map +1 -0
- package/dist/azure/finding-code.js +127 -0
- package/dist/azure/finding-code.js.map +1 -0
- package/dist/azure/finding-dedup.d.ts +44 -0
- package/dist/azure/finding-dedup.d.ts.map +1 -0
- package/dist/azure/finding-dedup.js +132 -0
- package/dist/azure/finding-dedup.js.map +1 -0
- package/dist/finding.d.ts +19 -7
- package/dist/finding.d.ts.map +1 -1
- package/dist/finding.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/azure/__tests__/azqr.test.ts +45 -1
- package/src/azure/__tests__/finding-category.test.ts +68 -0
- package/src/azure/__tests__/finding-code.test.ts +128 -0
- package/src/azure/__tests__/finding-dedup.test.ts +269 -0
- package/src/azure/analyzer.ts +54 -18
- package/src/azure/analyzers/__tests__/advisor.test.ts +60 -0
- package/src/azure/analyzers/advisor.ts +14 -0
- package/src/azure/azqr.ts +28 -4
- package/src/azure/finding-category.ts +75 -0
- package/src/azure/finding-code.ts +148 -0
- package/src/azure/finding-dedup.ts +157 -0
- package/src/finding.ts +19 -7
- package/src/types.ts +16 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for stable per-sentence custom finding codes.
|
|
3
|
+
*
|
|
4
|
+
* These codes are what lets `foldDuplicateFinding` collapse two custom
|
|
5
|
+
* findings that describe the same problem on the same resource, so the tests
|
|
6
|
+
* focus on: known sentences get a stable identity, identical wording shared
|
|
7
|
+
* across resource types collapses onto one code, and anything the table
|
|
8
|
+
* doesn't recognise (or that already has an identity, or isn't "custom") is
|
|
9
|
+
* left untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import type { Finding } from "../../finding.js";
|
|
15
|
+
|
|
16
|
+
import { assignCustomFindingCodes } from "../finding-code.js";
|
|
17
|
+
|
|
18
|
+
const RESOURCE_ID =
|
|
19
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
|
|
20
|
+
|
|
21
|
+
function customFinding(
|
|
22
|
+
reason: string,
|
|
23
|
+
overrides: Partial<Finding> = {},
|
|
24
|
+
): Finding {
|
|
25
|
+
return {
|
|
26
|
+
category: "cost",
|
|
27
|
+
code: "custom.unknown",
|
|
28
|
+
reason,
|
|
29
|
+
resourceId: RESOURCE_ID,
|
|
30
|
+
severity: "low",
|
|
31
|
+
source: "custom",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("assignCustomFindingCodes", () => {
|
|
37
|
+
it.each([
|
|
38
|
+
["Disk is unattached.", "custom.disk.unattached"],
|
|
39
|
+
[
|
|
40
|
+
"NIC not attached to any VM or private endpoint.",
|
|
41
|
+
"custom.nic.unattached",
|
|
42
|
+
],
|
|
43
|
+
["No public IP assigned.", "custom.nic.no-public-ip"],
|
|
44
|
+
[
|
|
45
|
+
"Private Endpoint has no private link service connections configured.",
|
|
46
|
+
"custom.private-endpoint.no-connections",
|
|
47
|
+
],
|
|
48
|
+
["Container App is not running.", "custom.container-app.not-running"],
|
|
49
|
+
["App Service Plan has no apps deployed.", "custom.app-service.no-apps"],
|
|
50
|
+
[
|
|
51
|
+
"Public IP not associated with any resource.",
|
|
52
|
+
"custom.public-ip.unassociated",
|
|
53
|
+
],
|
|
54
|
+
["Static IP not in use.", "custom.public-ip.static-unused"],
|
|
55
|
+
[
|
|
56
|
+
"Very low transaction count (2.00 avg/day).",
|
|
57
|
+
"custom.storage.low-transactions",
|
|
58
|
+
],
|
|
59
|
+
["VM is deallocated.", "custom.vm.deallocated"],
|
|
60
|
+
["VM is stopped.", "custom.vm.stopped"],
|
|
61
|
+
["Low CPU usage (avg 1.23%).", "custom.vm.low-cpu-usage"],
|
|
62
|
+
["Low network traffic (0.50 MB/day avg).", "custom.vm.low-network-traffic"],
|
|
63
|
+
[
|
|
64
|
+
"No traffic data available in 30 days.",
|
|
65
|
+
"custom.static-site.no-traffic-data",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"Very low site traffic (3 requests in 30 days).",
|
|
69
|
+
"custom.static-site.low-traffic",
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
"Very low data transfer (0.01 MB in 30 days).",
|
|
73
|
+
"custom.static-site.low-data-transfer",
|
|
74
|
+
],
|
|
75
|
+
["Resource ID is missing.", "custom.missing-resource-id"],
|
|
76
|
+
["Could not retrieve detailed NIC information.", "custom.lookup-failed"],
|
|
77
|
+
["No tags found.", "custom.missing-tags"],
|
|
78
|
+
])("assigns %j the stable code %j", (reason, expectedCode) => {
|
|
79
|
+
const [finding] = assignCustomFindingCodes([customFinding(reason)]);
|
|
80
|
+
expect(finding.code).toBe(expectedCode);
|
|
81
|
+
expect(finding.recommendationId).toBe(expectedCode);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each([
|
|
85
|
+
["Very low CPU usage (12.34%).", "App Service Plan"],
|
|
86
|
+
["Very low CPU usage (0.5000 cores).", "Container App"],
|
|
87
|
+
])(
|
|
88
|
+
"shares one code across resource types for identical wording: %j (%s)",
|
|
89
|
+
(reason) => {
|
|
90
|
+
const [finding] = assignCustomFindingCodes([customFinding(reason)]);
|
|
91
|
+
expect(finding.recommendationId).toBe("custom.low-cpu-usage");
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it("gives two different resources' identical sentences the same recommendationId, enabling a later fold within a resource but not across resources", () => {
|
|
96
|
+
const [a, b] = assignCustomFindingCodes([
|
|
97
|
+
customFinding("Very low network traffic (1.00 MB/day avg)."),
|
|
98
|
+
customFinding("Very low network traffic (2.00 MB/day avg)."),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(a.recommendationId).toBe("custom.low-network-traffic");
|
|
102
|
+
expect(b.recommendationId).toBe("custom.low-network-traffic");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("leaves unrecognised sentences unchanged", () => {
|
|
106
|
+
const finding = customFinding("Something entirely unexpected happened.");
|
|
107
|
+
|
|
108
|
+
expect(assignCustomFindingCodes([finding])).toEqual([finding]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("leaves non-custom findings unchanged", () => {
|
|
112
|
+
const finding = customFinding("Disk is unattached.", {
|
|
113
|
+
code: "advisor.some-id",
|
|
114
|
+
recommendationId: "advisor.some-id",
|
|
115
|
+
source: "advisor",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(assignCustomFindingCodes([finding])).toEqual([finding]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not overwrite a finding that already has a recommendationId", () => {
|
|
122
|
+
const finding = customFinding("Disk is unattached.", {
|
|
123
|
+
recommendationId: "custom.already-set",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(assignCustomFindingCodes([finding])).toEqual([finding]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for cross-source finding deduplication.
|
|
3
|
+
*
|
|
4
|
+
* The same waste can be reported by AZQR, Azure Advisor and the custom
|
|
5
|
+
* analyzers at once. These tests pin down when the incoming finding collapses
|
|
6
|
+
* onto an existing one, and what the surviving finding is expected to carry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { Finding } from "../../finding.js";
|
|
12
|
+
import type { AzureDetailedResourceReport } from "../types.js";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
foldDuplicateFinding,
|
|
16
|
+
orphanRecommendationId,
|
|
17
|
+
} from "../finding-dedup.js";
|
|
18
|
+
|
|
19
|
+
const RESOURCE_ID =
|
|
20
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
|
|
21
|
+
|
|
22
|
+
const ORPHAN_DISK = orphanRecommendationId("Microsoft.Compute/disks");
|
|
23
|
+
|
|
24
|
+
function mkFinding(overrides: Partial<Finding> = {}): Finding {
|
|
25
|
+
return {
|
|
26
|
+
category: "cost",
|
|
27
|
+
code: "custom.unknown",
|
|
28
|
+
reason: "Disk is unattached.",
|
|
29
|
+
resourceId: RESOURCE_ID,
|
|
30
|
+
severity: "low",
|
|
31
|
+
source: "custom",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mkReport(findings: Finding[]): AzureDetailedResourceReport {
|
|
37
|
+
return {
|
|
38
|
+
analysis: {
|
|
39
|
+
costRisk: "low",
|
|
40
|
+
reason: findings.map((finding) => finding.reason).join(" "),
|
|
41
|
+
suspectedUnused: true,
|
|
42
|
+
},
|
|
43
|
+
findings,
|
|
44
|
+
resource: {
|
|
45
|
+
id: RESOURCE_ID,
|
|
46
|
+
name: "disk1",
|
|
47
|
+
type: "Microsoft.Compute/disks",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("orphanRecommendationId", () => {
|
|
53
|
+
it("normalises the resource type so sources share one identity", () => {
|
|
54
|
+
expect(ORPHAN_DISK).toBe("orphan.microsoft.compute/disks");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("foldDuplicateFinding", () => {
|
|
59
|
+
it("keeps findings without a recommendation id separate", () => {
|
|
60
|
+
const report = mkReport([mkFinding({ reason: "No tags found." })]);
|
|
61
|
+
|
|
62
|
+
expect(foldDuplicateFinding(mkFinding(), report)).toBe(false);
|
|
63
|
+
expect(report.findings).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("folds a finding sharing the same recommendation id", () => {
|
|
67
|
+
const report = mkReport([
|
|
68
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "advisor" }),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const folded = foldDuplicateFinding(
|
|
72
|
+
mkFinding({
|
|
73
|
+
recommendationId: ORPHAN_DISK.toUpperCase(),
|
|
74
|
+
source: "azqr",
|
|
75
|
+
}),
|
|
76
|
+
report,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(folded).toBe(true);
|
|
80
|
+
expect(report.findings).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("folds an AZQR orphan row onto the live cost finding for the same resource", () => {
|
|
84
|
+
const report = mkReport([mkFinding()]);
|
|
85
|
+
|
|
86
|
+
const folded = foldDuplicateFinding(
|
|
87
|
+
mkFinding({
|
|
88
|
+
recommendationId: ORPHAN_DISK,
|
|
89
|
+
severity: "high",
|
|
90
|
+
source: "azqr",
|
|
91
|
+
}),
|
|
92
|
+
report,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(folded).toBe(true);
|
|
96
|
+
expect(report.findings?.[0]).toMatchObject({
|
|
97
|
+
reason:
|
|
98
|
+
"Disk is unattached. Also flagged by azqr as an orphaned resource.",
|
|
99
|
+
severity: "high",
|
|
100
|
+
source: "custom",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("adopts the canonical identity of the finding it absorbs", () => {
|
|
105
|
+
const report = mkReport([mkFinding()]);
|
|
106
|
+
|
|
107
|
+
foldDuplicateFinding(
|
|
108
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
109
|
+
report,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(report.findings?.[0].recommendationId).toBe(ORPHAN_DISK);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("records the corroborating source in the per-resource summary too", () => {
|
|
116
|
+
const report = mkReport([mkFinding()]);
|
|
117
|
+
|
|
118
|
+
foldDuplicateFinding(
|
|
119
|
+
mkFinding({
|
|
120
|
+
recommendationId: ORPHAN_DISK,
|
|
121
|
+
severity: "high",
|
|
122
|
+
source: "azqr",
|
|
123
|
+
}),
|
|
124
|
+
report,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(report.analysis).toMatchObject({
|
|
128
|
+
costRisk: "high",
|
|
129
|
+
reason:
|
|
130
|
+
"Disk is unattached. Also flagged by azqr as an orphaned resource.",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the monetary estimate of whichever finding carries one", () => {
|
|
135
|
+
const report = mkReport([mkFinding({ recommendationId: ORPHAN_DISK })]);
|
|
136
|
+
|
|
137
|
+
foldDuplicateFinding(
|
|
138
|
+
mkFinding({
|
|
139
|
+
estimatedMonthlySavings: { amount: 12.5, currency: "EUR" },
|
|
140
|
+
recommendationId: ORPHAN_DISK,
|
|
141
|
+
source: "azqr",
|
|
142
|
+
}),
|
|
143
|
+
report,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(report.findings?.[0].estimatedMonthlySavings).toEqual({
|
|
147
|
+
amount: 12.5,
|
|
148
|
+
currency: "EUR",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not fold an orphan row onto a governance-only finding", () => {
|
|
153
|
+
const report = mkReport([
|
|
154
|
+
mkFinding({
|
|
155
|
+
category: "operationalExcellence",
|
|
156
|
+
reason: "No tags found.",
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const folded = foldDuplicateFinding(
|
|
161
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
162
|
+
report,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(folded).toBe(false);
|
|
166
|
+
expect(report.analysis.reason).toBe("No tags found.");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not fold an orphan row onto another static-report finding", () => {
|
|
170
|
+
const report = mkReport([
|
|
171
|
+
mkFinding({ recommendationId: "azqr.other", source: "azqr" }),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
foldDuplicateFinding(
|
|
176
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
177
|
+
report,
|
|
178
|
+
),
|
|
179
|
+
).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not fold an orphan row onto a live finding with its own distinct identity", () => {
|
|
183
|
+
const report = mkReport([
|
|
184
|
+
mkFinding({
|
|
185
|
+
recommendationId: "advisor.right-size-vm",
|
|
186
|
+
source: "advisor",
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
foldDuplicateFinding(
|
|
192
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
193
|
+
report,
|
|
194
|
+
),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
expect(report.findings).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("foldDuplicateFinding - repeated and idempotent folds", () => {
|
|
201
|
+
it("does not annotate same-source duplicates", () => {
|
|
202
|
+
const report = mkReport([
|
|
203
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
foldDuplicateFinding(
|
|
207
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
208
|
+
report,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(report.findings?.[0].reason).toBe("Disk is unattached.");
|
|
212
|
+
expect(report.analysis.reason).toBe("Disk is unattached.");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("does not annotate the same corroboration twice", () => {
|
|
216
|
+
const report = mkReport([mkFinding()]);
|
|
217
|
+
const azqrRow = () =>
|
|
218
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" });
|
|
219
|
+
|
|
220
|
+
foldDuplicateFinding(azqrRow(), report);
|
|
221
|
+
const foldedAgain = foldDuplicateFinding(azqrRow(), report);
|
|
222
|
+
|
|
223
|
+
expect(foldedAgain).toBe(true);
|
|
224
|
+
expect(report.findings).toHaveLength(1);
|
|
225
|
+
expect(report.findings?.[0].reason).toBe(
|
|
226
|
+
"Disk is unattached. Also flagged by azqr as an orphaned resource.",
|
|
227
|
+
);
|
|
228
|
+
expect(report.analysis.reason).toBe(
|
|
229
|
+
"Disk is unattached. Also flagged by azqr as an orphaned resource.",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("leaves reports without collected findings untouched", () => {
|
|
234
|
+
const report = mkReport([]);
|
|
235
|
+
delete report.findings;
|
|
236
|
+
|
|
237
|
+
expect(
|
|
238
|
+
foldDuplicateFinding(
|
|
239
|
+
mkFinding({ recommendationId: ORPHAN_DISK, source: "azqr" }),
|
|
240
|
+
report,
|
|
241
|
+
),
|
|
242
|
+
).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Custom analyzer sentences with a known template carry a stable
|
|
246
|
+
// recommendationId too (see finding-code.ts), so two custom findings for
|
|
247
|
+
// the same problem must collapse just like a custom/advisor pair does.
|
|
248
|
+
it("folds two custom findings sharing a stable recommendation id", () => {
|
|
249
|
+
const report = mkReport([
|
|
250
|
+
mkFinding({
|
|
251
|
+
code: "custom.disk.unattached",
|
|
252
|
+
recommendationId: "custom.disk.unattached",
|
|
253
|
+
}),
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const folded = foldDuplicateFinding(
|
|
257
|
+
mkFinding({
|
|
258
|
+
code: "custom.disk.unattached",
|
|
259
|
+
recommendationId: "custom.disk.unattached",
|
|
260
|
+
}),
|
|
261
|
+
report,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(folded).toBe(true);
|
|
265
|
+
expect(report.findings).toHaveLength(1);
|
|
266
|
+
// Same-source fold: no corroboration note is appended.
|
|
267
|
+
expect(report.findings?.[0].reason).toBe("Disk is unattached.");
|
|
268
|
+
});
|
|
269
|
+
});
|
package/src/azure/analyzer.ts
CHANGED
|
@@ -37,8 +37,9 @@ import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
|
|
|
37
37
|
import { findingsFromAnalysisResult } from "../finding.js";
|
|
38
38
|
import {
|
|
39
39
|
type AnalysisResult,
|
|
40
|
-
|
|
40
|
+
COST_RISK_ORDER,
|
|
41
41
|
DEFAULT_THRESHOLDS,
|
|
42
|
+
maxCostRisk,
|
|
42
43
|
mergeResults,
|
|
43
44
|
type Thresholds,
|
|
44
45
|
} from "../types.js";
|
|
@@ -55,13 +56,19 @@ import {
|
|
|
55
56
|
isAzqrReportMasked,
|
|
56
57
|
loadAzqrReport,
|
|
57
58
|
} from "./azqr.js";
|
|
59
|
+
import {
|
|
60
|
+
categorizeCustomFindings,
|
|
61
|
+
MISSING_TAGS_REASON,
|
|
62
|
+
NO_ANALYZER_REASON,
|
|
63
|
+
unpreferredLocationReason,
|
|
64
|
+
} from "./finding-category.js";
|
|
65
|
+
import { assignCustomFindingCodes } from "./finding-code.js";
|
|
66
|
+
import { foldDuplicateFinding } from "./finding-dedup.js";
|
|
58
67
|
import { PricingClient, PricingService } from "./pricing/index.js";
|
|
59
68
|
import { matchesTags, type MetricsCache } from "./utils.js";
|
|
60
69
|
|
|
61
70
|
const DEFAULT_CONCURRENCY = 8;
|
|
62
71
|
|
|
63
|
-
const RISK_ORDER: Record<CostRisk, number> = { high: 0, low: 2, medium: 1 };
|
|
64
|
-
|
|
65
72
|
/**
|
|
66
73
|
* Analyzes resources in every configured Azure subscription and returns
|
|
67
74
|
* the structured report.
|
|
@@ -195,7 +202,10 @@ export async function analyzeAzureResources(
|
|
|
195
202
|
if (aSub !== bSub) return aSub ? 1 : -1;
|
|
196
203
|
if (a.analysis.costRisk === b.analysis.costRisk)
|
|
197
204
|
return (a.resource.name ?? "").localeCompare(b.resource.name ?? "");
|
|
198
|
-
return
|
|
205
|
+
return (
|
|
206
|
+
COST_RISK_ORDER[a.analysis.costRisk] -
|
|
207
|
+
COST_RISK_ORDER[b.analysis.costRisk]
|
|
208
|
+
);
|
|
199
209
|
});
|
|
200
210
|
|
|
201
211
|
return allReports;
|
|
@@ -236,7 +246,7 @@ export async function analyzeResource(
|
|
|
236
246
|
// Generic check: lack of tags is a common sign of unmanaged resources.
|
|
237
247
|
if (!resource.tags || Object.keys(resource.tags).length === 0) {
|
|
238
248
|
result.suspectedUnused = true;
|
|
239
|
-
result.reason +=
|
|
249
|
+
result.reason += `${MISSING_TAGS_REASON} `;
|
|
240
250
|
}
|
|
241
251
|
|
|
242
252
|
const ctx: AnalyzerContext = {
|
|
@@ -261,7 +271,7 @@ export async function analyzeResource(
|
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
if (!matched) {
|
|
264
|
-
result.reason +=
|
|
274
|
+
result.reason += `${NO_ANALYZER_REASON} `;
|
|
265
275
|
}
|
|
266
276
|
|
|
267
277
|
// Generic check for location
|
|
@@ -269,7 +279,7 @@ export async function analyzeResource(
|
|
|
269
279
|
resource.location &&
|
|
270
280
|
!resource.location.toLowerCase().includes(preferredLocation.toLowerCase())
|
|
271
281
|
) {
|
|
272
|
-
result.reason +=
|
|
282
|
+
result.reason += `${unpreferredLocationReason(preferredLocation)} `;
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
return { ...result, reason: result.reason.trim() };
|
|
@@ -294,10 +304,7 @@ export function mergeFinding(
|
|
|
294
304
|
// don't produce "Sentence one.Sentence two." when the existing reason is
|
|
295
305
|
// already trimmed (i.e. has no trailing separator space).
|
|
296
306
|
existing.analysis = {
|
|
297
|
-
costRisk:
|
|
298
|
-
RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
|
|
299
|
-
? existing.analysis.costRisk
|
|
300
|
-
: added.costRisk,
|
|
307
|
+
costRisk: maxCostRisk(existing.analysis.costRisk, added.costRisk),
|
|
301
308
|
estimatedMonthlySavings: existing.analysis.estimatedMonthlySavings,
|
|
302
309
|
reason:
|
|
303
310
|
existing.analysis.reason && added.reason
|
|
@@ -451,6 +458,11 @@ function hasTagFilter(filterTags: Map<string, string> | undefined): boolean {
|
|
|
451
458
|
* report collected during the live scan so AZQR findings attach to their
|
|
452
459
|
* resource when present or create a stub otherwise.
|
|
453
460
|
*
|
|
461
|
+
* Findings that duplicate what a live source already reported for the same
|
|
462
|
+
* resource are folded into the existing finding instead of being appended, so
|
|
463
|
+
* an orphaned resource seen by both AZQR and Advisor/custom analyzers shows up
|
|
464
|
+
* once — on the entry that carries the monetary estimate.
|
|
465
|
+
*
|
|
454
466
|
* When the report is still masked, findings cannot match live resource IDs, so
|
|
455
467
|
* a warning advises re-running AZQR with `--mask=false`.
|
|
456
468
|
*/
|
|
@@ -472,11 +484,22 @@ async function ingestAzqrReport(
|
|
|
472
484
|
const reportsById = new Map<string, AzureDetailedResourceReport>(
|
|
473
485
|
reports.map((r) => [(r.resource.id ?? "").toLowerCase(), r]),
|
|
474
486
|
);
|
|
487
|
+
let merged = 0;
|
|
488
|
+
let deduplicated = 0;
|
|
475
489
|
for (const finding of findings) {
|
|
490
|
+
const existing = reportsById.get(finding.resourceId.toLowerCase());
|
|
491
|
+
if (existing && foldDuplicateFinding(finding, existing)) {
|
|
492
|
+
deduplicated++;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
476
495
|
mergeFinding(finding, reports, reportsById);
|
|
496
|
+
merged++;
|
|
477
497
|
}
|
|
478
498
|
logger.info(
|
|
479
|
-
`AZQR: merged ${
|
|
499
|
+
`AZQR: merged ${merged} finding(s) from ${azqrReportPath}` +
|
|
500
|
+
(deduplicated > 0
|
|
501
|
+
? `, ${deduplicated} deduplicated onto existing findings`
|
|
502
|
+
: ""),
|
|
480
503
|
);
|
|
481
504
|
}
|
|
482
505
|
|
|
@@ -569,16 +592,29 @@ async function runPerResourceAnalysis(args: {
|
|
|
569
592
|
|
|
570
593
|
if (analysis.suspectedUnused) {
|
|
571
594
|
const reason = analysis.reason || "No specific findings.";
|
|
595
|
+
const findings = assignCustomFindingCodes(
|
|
596
|
+
categorizeCustomFindings(
|
|
597
|
+
findingsFromAnalysisResult({
|
|
598
|
+
reason,
|
|
599
|
+
resourceId: resource.id ?? "",
|
|
600
|
+
severity: analysis.costRisk,
|
|
601
|
+
source: "custom",
|
|
602
|
+
}),
|
|
603
|
+
),
|
|
604
|
+
);
|
|
572
605
|
const report: AzureDetailedResourceReport = {
|
|
573
606
|
analysis: { ...analysis, reason },
|
|
574
|
-
findings:
|
|
575
|
-
reason,
|
|
576
|
-
resourceId: resource.id ?? "",
|
|
577
|
-
severity: analysis.costRisk,
|
|
578
|
-
source: "custom",
|
|
579
|
-
}),
|
|
607
|
+
findings: [],
|
|
580
608
|
resource,
|
|
581
609
|
};
|
|
610
|
+
// Fold sentences that share a stable recommendationId so e.g. two
|
|
611
|
+
// "Very low CPU usage" hits on the same resource collapse into one
|
|
612
|
+
// row instead of appearing as separate findings.
|
|
613
|
+
for (const finding of findings) {
|
|
614
|
+
if (!foldDuplicateFinding(finding, report)) {
|
|
615
|
+
report.findings?.push(finding);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
582
618
|
reports.push(report);
|
|
583
619
|
const idKey = (resource.id ?? "").toLowerCase();
|
|
584
620
|
if (idKey) reportsById.set(idKey, report);
|
|
@@ -365,3 +365,63 @@ describe("createAdvisorAnalyzer — filtering", () => {
|
|
|
365
365
|
expect(findings[0].reason).toBe("Azure Advisor cost recommendation.");
|
|
366
366
|
});
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
describe("createAdvisorAnalyzer — cross-source identity", () => {
|
|
370
|
+
it("namespaces the recommendation type id of resource-level findings", async () => {
|
|
371
|
+
const analyzer = createAdvisorAnalyzer({
|
|
372
|
+
build: () =>
|
|
373
|
+
makeFakeClient([
|
|
374
|
+
{
|
|
375
|
+
category: "Cost",
|
|
376
|
+
impact: "High",
|
|
377
|
+
recommendationTypeId: "right-size-vm",
|
|
378
|
+
resourceMetadata: { resourceId: RID1 },
|
|
379
|
+
shortDescription: { problem: "p" },
|
|
380
|
+
},
|
|
381
|
+
]),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
385
|
+
|
|
386
|
+
expect(findings[0].recommendationId).toBe("advisor.right-size-vm");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("namespaces the recommendation type id of subscription-level findings", async () => {
|
|
390
|
+
const SUB_URI = "/subscriptions/00000000-0000-0000-0000-000000000000";
|
|
391
|
+
const analyzer = createAdvisorAnalyzer({
|
|
392
|
+
build: () =>
|
|
393
|
+
makeFakeClient([
|
|
394
|
+
{
|
|
395
|
+
category: "Cost",
|
|
396
|
+
extendedProperties: { savingsAmount: "50", savingsCurrency: "EUR" },
|
|
397
|
+
impact: "High",
|
|
398
|
+
recommendationTypeId: "vm-ri",
|
|
399
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
400
|
+
shortDescription: { problem: "Buy VM reserved instance" },
|
|
401
|
+
},
|
|
402
|
+
]),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
406
|
+
|
|
407
|
+
expect(findings[0].recommendationId).toBe("advisor.vm-ri");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("leaves the recommendation id unset when Advisor reports no type", async () => {
|
|
411
|
+
const analyzer = createAdvisorAnalyzer({
|
|
412
|
+
build: () =>
|
|
413
|
+
makeFakeClient([
|
|
414
|
+
{
|
|
415
|
+
category: "Cost",
|
|
416
|
+
impact: "Low",
|
|
417
|
+
resourceMetadata: { resourceId: RID1 },
|
|
418
|
+
shortDescription: { problem: "p" },
|
|
419
|
+
},
|
|
420
|
+
]),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
424
|
+
|
|
425
|
+
expect(findings[0].recommendationId).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -153,6 +153,18 @@ function addToSubGroups(
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Namespaced canonical identity of an Advisor recommendation. Combined with the
|
|
158
|
+
* resource ID it is the deduplication key that lets the same problem reported
|
|
159
|
+
* by another source collapse onto this finding. Left unset when Advisor does
|
|
160
|
+
* not report a type ID, so unrelated recommendations are never merged.
|
|
161
|
+
*/
|
|
162
|
+
function advisorRecommendationId(
|
|
163
|
+
recommendationTypeId: string | undefined,
|
|
164
|
+
): string | undefined {
|
|
165
|
+
return recommendationTypeId ? `advisor.${recommendationTypeId}` : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
function buildResourceFinding(
|
|
157
169
|
rec: RecommendationInfo,
|
|
158
170
|
savings: undefined | { amount: number; currency: string },
|
|
@@ -168,6 +180,7 @@ function buildResourceFinding(
|
|
|
168
180
|
code: `advisor.${rec.recommendationTypeId ?? "unknown"}`,
|
|
169
181
|
estimatedMonthlySavings: savings,
|
|
170
182
|
reason: enrichReason(problem, props),
|
|
183
|
+
recommendationId: advisorRecommendationId(rec.recommendationTypeId),
|
|
171
184
|
recommendedAction: rec.shortDescription?.solution,
|
|
172
185
|
resourceId: rawResourceId,
|
|
173
186
|
severity: mapImpact(rec.impact),
|
|
@@ -202,6 +215,7 @@ function createSubGroup(
|
|
|
202
215
|
category: "cost",
|
|
203
216
|
code: `advisor.${typeKey}`,
|
|
204
217
|
reason,
|
|
218
|
+
recommendationId: advisorRecommendationId(rec.recommendationTypeId),
|
|
205
219
|
recommendedAction: rec.shortDescription?.solution,
|
|
206
220
|
resourceId,
|
|
207
221
|
severity: mapImpact(rec.impact),
|