@pagopa/dx-savemoney 0.5.0 → 0.6.2
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 +48 -0
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +30 -0
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/advisor.d.ts.map +1 -1
- package/dist/azure/analyzers/advisor.js.map +1 -1
- package/dist/azure/azqr.d.ts +136 -0
- package/dist/azure/azqr.d.ts.map +1 -0
- package/dist/azure/azqr.js +235 -0
- package/dist/azure/azqr.js.map +1 -0
- package/dist/azure/config.d.ts.map +1 -1
- package/dist/azure/config.js +1 -0
- package/dist/azure/config.js.map +1 -1
- package/dist/azure/pricing/pricing-service.d.ts.map +1 -1
- package/dist/azure/pricing/pricing-service.js +6 -1
- package/dist/azure/pricing/pricing-service.js.map +1 -1
- package/dist/azure/pricing/schema.d.ts.map +1 -1
- package/dist/azure/pricing/schema.js +22 -14
- package/dist/azure/pricing/schema.js.map +1 -1
- package/dist/azure/resources/app-service.d.ts.map +1 -1
- package/dist/azure/resources/app-service.js +2 -2
- package/dist/azure/resources/app-service.js.map +1 -1
- package/dist/azure/resources/container-app.d.ts.map +1 -1
- package/dist/azure/resources/container-app.js +1 -1
- package/dist/azure/resources/container-app.js.map +1 -1
- package/dist/azure/resources/disk.js +3 -1
- package/dist/azure/resources/disk.js.map +1 -1
- package/dist/azure/resources/nic.js +3 -1
- package/dist/azure/resources/nic.js.map +1 -1
- package/dist/azure/resources/private-endpoint.d.ts.map +1 -1
- package/dist/azure/resources/private-endpoint.js +1 -1
- package/dist/azure/resources/private-endpoint.js.map +1 -1
- package/dist/azure/resources/public-ip.js +4 -2
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/resources/static-web-app.d.ts.map +1 -1
- package/dist/azure/resources/static-web-app.js +1 -1
- package/dist/azure/resources/static-web-app.js.map +1 -1
- package/dist/azure/resources/vm.js +6 -2
- package/dist/azure/resources/vm.js.map +1 -1
- package/dist/azure/types.d.ts +8 -0
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/azure/utils.d.ts.map +1 -1
- package/dist/azure/utils.js +7 -2
- package/dist/azure/utils.js.map +1 -1
- package/dist/finding.d.ts +2 -1
- package/dist/finding.d.ts.map +1 -1
- package/dist/finding.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +6 -0
- package/dist/schema.js.map +1 -1
- package/package.json +8 -15
- package/src/azure/__tests__/azqr.test.ts +238 -0
- package/src/azure/__tests__/config.test.ts +2 -0
- package/src/azure/__tests__/fixtures/full-override.yaml +1 -0
- package/src/azure/analyzer.ts +47 -0
- package/src/azure/analyzers/advisor.ts +1 -2
- package/src/azure/azqr.ts +270 -0
- package/src/azure/config.ts +1 -0
- package/src/azure/pricing/__tests__/schema.test.ts +30 -0
- package/src/azure/pricing/pricing-service.ts +6 -3
- package/src/azure/pricing/schema.ts +22 -14
- package/src/azure/resources/app-service.ts +4 -2
- package/src/azure/resources/container-app.ts +2 -1
- package/src/azure/resources/disk.ts +3 -3
- package/src/azure/resources/nic.ts +3 -3
- package/src/azure/resources/private-endpoint.ts +2 -1
- package/src/azure/resources/public-ip.ts +6 -6
- package/src/azure/resources/static-web-app.ts +2 -1
- package/src/azure/resources/vm.ts +6 -6
- package/src/azure/types.ts +8 -0
- package/src/azure/utils.ts +8 -2
- package/src/finding.ts +3 -6
- package/src/schema.ts +6 -0
- package/dist/__tests__/finding.test.d.ts +0 -17
- package/dist/__tests__/finding.test.d.ts.map +0 -1
- package/dist/__tests__/finding.test.js +0 -146
- package/dist/__tests__/finding.test.js.map +0 -1
- package/dist/azure/__tests__/analyzer-tags.test.d.ts +0 -8
- package/dist/azure/__tests__/analyzer-tags.test.d.ts.map +0 -1
- package/dist/azure/__tests__/analyzer-tags.test.js +0 -70
- package/dist/azure/__tests__/analyzer-tags.test.js.map +0 -1
- package/dist/azure/__tests__/config.test.d.ts +0 -9
- package/dist/azure/__tests__/config.test.d.ts.map +0 -1
- package/dist/azure/__tests__/config.test.js +0 -100
- package/dist/azure/__tests__/config.test.js.map +0 -1
- package/dist/azure/__tests__/report.test.d.ts +0 -9
- package/dist/azure/__tests__/report.test.d.ts.map +0 -1
- package/dist/azure/__tests__/report.test.js +0 -191
- package/dist/azure/__tests__/report.test.js.map +0 -1
- package/dist/azure/__tests__/utils.test.d.ts +0 -15
- package/dist/azure/__tests__/utils.test.d.ts.map +0 -1
- package/dist/azure/__tests__/utils.test.js +0 -181
- package/dist/azure/__tests__/utils.test.js.map +0 -1
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts +0 -9
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts.map +0 -1
- package/dist/azure/analyzers/__tests__/advisor.test.js +0 -314
- package/dist/azure/analyzers/__tests__/advisor.test.js.map +0 -1
- package/dist/azure/pricing/__tests__/cache.test.d.ts +0 -8
- package/dist/azure/pricing/__tests__/cache.test.d.ts.map +0 -1
- package/dist/azure/pricing/__tests__/cache.test.js +0 -56
- package/dist/azure/pricing/__tests__/cache.test.js.map +0 -1
- package/dist/azure/pricing/__tests__/client.test.d.ts +0 -9
- package/dist/azure/pricing/__tests__/client.test.d.ts.map +0 -1
- package/dist/azure/pricing/__tests__/client.test.js +0 -181
- package/dist/azure/pricing/__tests__/client.test.js.map +0 -1
- package/dist/azure/pricing/__tests__/pricing-service.test.d.ts +0 -6
- package/dist/azure/pricing/__tests__/pricing-service.test.d.ts.map +0 -1
- package/dist/azure/pricing/__tests__/pricing-service.test.js +0 -283
- package/dist/azure/pricing/__tests__/pricing-service.test.js.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts +0 -5
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.js +0 -137
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.js.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts +0 -5
- package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/disk.test.js +0 -160
- package/dist/azure/pricing/resolvers/__tests__/disk.test.js.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts +0 -5
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js +0 -113
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts +0 -9
- package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts.map +0 -1
- package/dist/azure/pricing/resolvers/__tests__/vm.test.js +0 -204
- package/dist/azure/pricing/resolvers/__tests__/vm.test.js.map +0 -1
- package/dist/azure/resources/__tests__/app-service.test.d.ts +0 -5
- package/dist/azure/resources/__tests__/app-service.test.d.ts.map +0 -1
- package/dist/azure/resources/__tests__/app-service.test.js +0 -105
- package/dist/azure/resources/__tests__/app-service.test.js.map +0 -1
- package/dist/azure/resources/__tests__/storage.test.d.ts +0 -11
- package/dist/azure/resources/__tests__/storage.test.d.ts.map +0 -1
- package/dist/azure/resources/__tests__/storage.test.js +0 -99
- package/dist/azure/resources/__tests__/storage.test.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagopa/dx-savemoney",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Azure resource analyzer for finding unused or cost-inefficient resources.",
|
|
6
6
|
"repository": {
|
|
@@ -38,25 +38,18 @@
|
|
|
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.
|
|
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.
|
|
48
|
-
"@vitest/coverage-v8": "^4.1.
|
|
49
|
-
"eslint": "^10.
|
|
50
|
-
"prettier": "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.
|
|
53
|
-
"@pagopa/eslint-config": "^6.2.
|
|
54
|
-
},
|
|
55
|
-
"scripts": {
|
|
56
|
-
"build": "rm -rf dist && tsc",
|
|
57
|
-
"dev": "tsc --watch",
|
|
58
|
-
"format": "prettier --write .",
|
|
59
|
-
"format:check": "prettier --check .",
|
|
60
|
-
"test:coverage": "vitest run --coverage"
|
|
52
|
+
"vitest": "^4.1.10",
|
|
53
|
+
"@pagopa/eslint-config": "^6.2.2"
|
|
61
54
|
}
|
|
62
55
|
}
|
|
@@ -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
|
+
});
|
|
@@ -48,6 +48,7 @@ describe("loadConfig", () => {
|
|
|
48
48
|
expect(result.preferredLocation).toBe("italynorth");
|
|
49
49
|
expect(result.sources).toEqual(["advisor", "custom"]);
|
|
50
50
|
expect(result.timespanDays).toBe(30);
|
|
51
|
+
expect(result.azqrReportPath).toBeUndefined();
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
it("applies sources defaults when loading from the environment", async () => {
|
|
@@ -90,6 +91,7 @@ describe("loadConfig", () => {
|
|
|
90
91
|
expect(result.preferredLocation).toBe("westeurope");
|
|
91
92
|
expect(result.sources).toEqual(["custom"]);
|
|
92
93
|
expect(result.timespanDays).toBe(60);
|
|
94
|
+
expect(result.azqrReportPath).toBe("./azqr_action_plan.json");
|
|
93
95
|
|
|
94
96
|
expect(result.thresholds?.vm.cpuPercent).toBe(5);
|
|
95
97
|
expect(result.thresholds?.vm.networkInBytesPerDay).toBe(10485760);
|
package/src/azure/analyzer.ts
CHANGED
|
@@ -50,6 +50,11 @@ import {
|
|
|
50
50
|
createDefaultSubscriptionAnalyzers,
|
|
51
51
|
type SubscriptionAnalyzer,
|
|
52
52
|
} from "./analyzers/index.js";
|
|
53
|
+
import {
|
|
54
|
+
azqrImpactedToFindings,
|
|
55
|
+
isAzqrReportMasked,
|
|
56
|
+
loadAzqrReport,
|
|
57
|
+
} from "./azqr.js";
|
|
53
58
|
import { PricingClient, PricingService } from "./pricing/index.js";
|
|
54
59
|
import { matchesTags, type MetricsCache } from "./utils.js";
|
|
55
60
|
|
|
@@ -173,6 +178,12 @@ export async function analyzeAzureResources(
|
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
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
|
+
|
|
176
187
|
// Sort to make the output more readable:
|
|
177
188
|
// - Subscription-scoped findings (Reserved Instances, savings plans, ...)
|
|
178
189
|
// sink to the bottom: they aggregate many recommendations into a single
|
|
@@ -433,6 +444,42 @@ function hasTagFilter(filterTags: Map<string, string> | undefined): boolean {
|
|
|
433
444
|
return Boolean(filterTags && filterTags.size > 0);
|
|
434
445
|
}
|
|
435
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
|
+
|
|
436
483
|
function isResourceScopedFinding(resourceId: string): boolean {
|
|
437
484
|
return /\/providers\//i.test(resourceId);
|
|
438
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
|
-
|
|
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(
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AZQR report ingestion.
|
|
3
|
+
*
|
|
4
|
+
* Parses an AZQR (`azqr scan --json`) report and turns its `impacted`
|
|
5
|
+
* resources into the unified `Finding` model (`source: "azqr"`), keeping only
|
|
6
|
+
* the rows that carry FinOps signal so the SaveMoney output is not flooded with
|
|
7
|
+
* security / reliability / high-availability best-practice noise. Promoted rows
|
|
8
|
+
* fall into two classes (see {@link classifyAzqrRow}):
|
|
9
|
+
*
|
|
10
|
+
* - **cost** (`category: "cost"`): billable waste — rows AZQR categorises as
|
|
11
|
+
* `Cost`, or orphaned resources whose type actually incurs cost (public IPs,
|
|
12
|
+
* NAT gateways, application gateways, …).
|
|
13
|
+
* - **cleanup** (`category: "operationalExcellence"`): orphaned *free* resources
|
|
14
|
+
* (empty subnets, unattached NSGs, orphan API connections, …). They carry no
|
|
15
|
+
* direct cost but are cleanup candidates worth investigating.
|
|
16
|
+
*
|
|
17
|
+
* Orphans are detected via AZQR's `AOR` (Azure Orphan Resources) check source
|
|
18
|
+
* rather than fragile recommendation-text matching.
|
|
19
|
+
*
|
|
20
|
+
* The AZQR CLI masks subscription IDs by default (e.g.
|
|
21
|
+
* `/subscriptions/xxxxxxxx-…/`). Masked IDs do not match the real resource IDs
|
|
22
|
+
* produced by a live scan, so masked findings cannot be merged onto their
|
|
23
|
+
* resources — {@link isAzqrReportMasked} lets the orchestrator warn and advise
|
|
24
|
+
* re-running with `azqr scan --mask=false`.
|
|
25
|
+
*
|
|
26
|
+
* Scope note (CES-2192 / Fase 1): only `impacted` rows are ingested, enriched
|
|
27
|
+
* with `inventory` metadata when available. AZQR `advisor` rows are ignored to
|
|
28
|
+
* avoid duplicating SaveMoney's own Azure Advisor cost query.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFile } from "node:fs/promises";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
|
|
34
|
+
import type { CostRisk } from "../types.js";
|
|
35
|
+
|
|
36
|
+
import { type Finding } from "../finding.js";
|
|
37
|
+
|
|
38
|
+
/** A single `impacted` row from an AZQR JSON report (fields we consume). */
|
|
39
|
+
const azqrImpactedRowSchema = z.object({
|
|
40
|
+
category: z.string().optional(),
|
|
41
|
+
impact: z.string().optional(),
|
|
42
|
+
learn: z.string().optional(),
|
|
43
|
+
recommendation: z.string(),
|
|
44
|
+
recommendationId: z.string().optional(),
|
|
45
|
+
resourceGroup: z.string().optional(),
|
|
46
|
+
resourceId: z.string(),
|
|
47
|
+
resourceName: z.string().optional(),
|
|
48
|
+
resourceType: z.string().optional(),
|
|
49
|
+
source: z.string().optional(),
|
|
50
|
+
subscriptionId: z.string().optional(),
|
|
51
|
+
subscriptionName: z.string().optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** A single `inventory` row from an AZQR JSON report (used for enrichment). */
|
|
55
|
+
const azqrInventoryRowSchema = z.object({
|
|
56
|
+
location: z.string().optional(),
|
|
57
|
+
resourceId: z.string(),
|
|
58
|
+
resourceName: z.string().optional(),
|
|
59
|
+
resourceType: z.string().optional(),
|
|
60
|
+
skuName: z.string().optional(),
|
|
61
|
+
skuTier: z.string().optional(),
|
|
62
|
+
subscriptionId: z.string().optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lenient top-level schema. AZQR reports carry many sections we do not use
|
|
67
|
+
* (`advisor`, `defender`, `recommendations`, …); unknown keys are stripped and
|
|
68
|
+
* missing arrays default to empty so a partial/older report still parses.
|
|
69
|
+
*/
|
|
70
|
+
const azqrReportSchema = z.object({
|
|
71
|
+
impacted: z.array(azqrImpactedRowSchema).default([]),
|
|
72
|
+
inventory: z.array(azqrInventoryRowSchema).default([]),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export type AzqrImpactedRow = z.infer<typeof azqrImpactedRowSchema>;
|
|
76
|
+
export type AzqrInventoryRow = z.infer<typeof azqrInventoryRowSchema>;
|
|
77
|
+
export type AzqrReport = z.infer<typeof azqrReportSchema>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Azure resource types whose orphaned/unassociated instances actually incur
|
|
81
|
+
* cost. AZQR's `AOR` (Azure Orphan Resources) check mixes these billable
|
|
82
|
+
* resources with free config-hygiene ones (NSGs, subnets, orphan API
|
|
83
|
+
* connections, private endpoints, …): the former are classified as `cost`
|
|
84
|
+
* opportunities, the latter as `cleanup` candidates.
|
|
85
|
+
*/
|
|
86
|
+
const BILLABLE_ORPHAN_RESOURCE_TYPES: ReadonlySet<string> = new Set([
|
|
87
|
+
"microsoft.compute/disks",
|
|
88
|
+
"microsoft.compute/snapshots",
|
|
89
|
+
"microsoft.network/applicationgateways",
|
|
90
|
+
"microsoft.network/ddosprotectionplans",
|
|
91
|
+
"microsoft.network/frontdoorwebapplicationfirewallpolicies",
|
|
92
|
+
"microsoft.network/loadbalancers",
|
|
93
|
+
"microsoft.network/natgateways",
|
|
94
|
+
"microsoft.network/publicipaddresses",
|
|
95
|
+
"microsoft.network/virtualnetworkgateways",
|
|
96
|
+
"microsoft.sql/servers/elasticpools",
|
|
97
|
+
"microsoft.web/serverfarms",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* AZQR's check source for orphaned resources (the Azure Orphan Resources
|
|
102
|
+
* project). Every `impacted` row tagged with it is a provisioned-but-unused
|
|
103
|
+
* resource, so it is a reliable, text-independent orphan marker.
|
|
104
|
+
*/
|
|
105
|
+
const ORPHAN_CHECK_SOURCE = "aor";
|
|
106
|
+
|
|
107
|
+
/** AZQR masks subscription GUID segments with runs of `x`. */
|
|
108
|
+
const MASK_MARKER = "xxxxxxxx";
|
|
109
|
+
|
|
110
|
+
/** How a promoted AZQR row is classified for reporting. */
|
|
111
|
+
export type AzqrOpportunityKind = "cleanup" | "cost";
|
|
112
|
+
|
|
113
|
+
const COST_REMEDIATION =
|
|
114
|
+
"Review the resource and remove or right-size it if it is no longer needed to stop incurring cost.";
|
|
115
|
+
|
|
116
|
+
const CLEANUP_REMEDIATION =
|
|
117
|
+
"Orphaned resource with no direct cost. Verify it is unused, then remove it to reduce clutter and management overhead.";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Converts the promoted `impacted` rows of an AZQR report into findings.
|
|
121
|
+
* AZQR carries no monetary estimate, so `estimatedMonthlySavings` is left
|
|
122
|
+
* unset. Billable rows render as `cost` opportunities (keeping AZQR's impact as
|
|
123
|
+
* severity); orphaned free resources render as low-severity
|
|
124
|
+
* `operationalExcellence` cleanup candidates. All carry an `[azqr]` badge.
|
|
125
|
+
*/
|
|
126
|
+
export function azqrImpactedToFindings(report: AzqrReport): Finding[] {
|
|
127
|
+
const inventoryById = new Map(
|
|
128
|
+
report.inventory.map((row) => [row.resourceId.toLowerCase(), row]),
|
|
129
|
+
);
|
|
130
|
+
const findings: Finding[] = [];
|
|
131
|
+
for (const row of report.impacted) {
|
|
132
|
+
const kind = classifyAzqrRow(row);
|
|
133
|
+
if (kind === null) continue;
|
|
134
|
+
const inventory = inventoryById.get(row.resourceId.toLowerCase());
|
|
135
|
+
const cleanup = kind === "cleanup";
|
|
136
|
+
const remediation = cleanup ? CLEANUP_REMEDIATION : COST_REMEDIATION;
|
|
137
|
+
findings.push({
|
|
138
|
+
category: cleanup ? "operationalExcellence" : "cost",
|
|
139
|
+
code: row.recommendationId
|
|
140
|
+
? `azqr.${row.recommendationId}`
|
|
141
|
+
: "azqr.impacted",
|
|
142
|
+
reason: buildReason(row, inventory),
|
|
143
|
+
recommendedAction: row.learn
|
|
144
|
+
? `${remediation} Learn more: ${row.learn}`
|
|
145
|
+
: remediation,
|
|
146
|
+
resourceId: row.resourceId,
|
|
147
|
+
severity: cleanup ? "low" : mapAzqrImpact(row.impact),
|
|
148
|
+
source: "azqr",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return findings;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Classifies an `impacted` row for FinOps reporting, or returns `null` to drop
|
|
156
|
+
* it as noise.
|
|
157
|
+
*
|
|
158
|
+
* - `"cost"` — AZQR categorises the row as a cost item, or it is an orphaned
|
|
159
|
+
* resource of a billable type (real savings potential).
|
|
160
|
+
* - `"cleanup"` — an orphaned resource of a *free* type (empty subnets,
|
|
161
|
+
* unattached NSGs, orphan API connections, …): no direct cost, but a cleanup
|
|
162
|
+
* candidate worth investigating.
|
|
163
|
+
* - `null` — everything else (security, reliability, high-availability and
|
|
164
|
+
* other best-practice rows) is treated as noise and dropped.
|
|
165
|
+
*
|
|
166
|
+
* Orphans are identified by AZQR's `AOR` check source, not by matching the
|
|
167
|
+
* recommendation text.
|
|
168
|
+
*/
|
|
169
|
+
export function classifyAzqrRow(
|
|
170
|
+
row: AzqrImpactedRow,
|
|
171
|
+
): AzqrOpportunityKind | null {
|
|
172
|
+
if ((row.category ?? "").toLowerCase().includes("cost")) {
|
|
173
|
+
return "cost";
|
|
174
|
+
}
|
|
175
|
+
if ((row.source ?? "").toLowerCase() !== ORPHAN_CHECK_SOURCE) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const resourceType = (row.resourceType ?? "").toLowerCase();
|
|
179
|
+
return BILLABLE_ORPHAN_RESOURCE_TYPES.has(resourceType) ? "cost" : "cleanup";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Detects whether the report still carries AZQR's default subscription-ID
|
|
184
|
+
* masking. Masked resource IDs cannot be matched against a live scan, so the
|
|
185
|
+
* caller should advise re-running with `azqr scan --mask=false`.
|
|
186
|
+
*/
|
|
187
|
+
export function isAzqrReportMasked(report: AzqrReport): boolean {
|
|
188
|
+
return report.impacted.some(
|
|
189
|
+
(row) =>
|
|
190
|
+
row.resourceId.includes(MASK_MARKER) ||
|
|
191
|
+
(row.subscriptionId?.includes(MASK_MARKER) ?? false),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reads and parses an AZQR JSON report from disk.
|
|
197
|
+
*
|
|
198
|
+
* @throws Error when the file cannot be read or is not valid AZQR JSON; the
|
|
199
|
+
* underlying failure is preserved as `cause`.
|
|
200
|
+
*/
|
|
201
|
+
export async function loadAzqrReport(filePath: string): Promise<AzqrReport> {
|
|
202
|
+
let content: string;
|
|
203
|
+
try {
|
|
204
|
+
content = await readFile(filePath, "utf8");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`Cannot read AZQR report at "${filePath}"`, {
|
|
207
|
+
cause: error,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let json: unknown;
|
|
212
|
+
try {
|
|
213
|
+
json = JSON.parse(content);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw new Error(`AZQR report at "${filePath}" is not valid JSON`, {
|
|
216
|
+
cause: error,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return parseAzqrReport(json);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Validates and parses an unknown value as an AZQR report.
|
|
225
|
+
*
|
|
226
|
+
* @throws Error (with the zod issue as `cause`) when the shape is invalid.
|
|
227
|
+
*/
|
|
228
|
+
export function parseAzqrReport(raw: unknown): AzqrReport {
|
|
229
|
+
const result = azqrReportSchema.safeParse(raw);
|
|
230
|
+
if (!result.success) {
|
|
231
|
+
throw new Error(`Invalid AZQR report: ${result.error.message}`, {
|
|
232
|
+
cause: result.error,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return result.data;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Builds the finding reason from the AZQR recommendation, appending SKU / tier
|
|
240
|
+
* / location context from the matching `inventory` row when available.
|
|
241
|
+
*/
|
|
242
|
+
function buildReason(
|
|
243
|
+
row: AzqrImpactedRow,
|
|
244
|
+
inventory: AzqrInventoryRow | undefined,
|
|
245
|
+
): string {
|
|
246
|
+
const base = row.recommendation.trim();
|
|
247
|
+
const sentence = base.endsWith(".") ? base : `${base}.`;
|
|
248
|
+
const context: string[] = [];
|
|
249
|
+
if (inventory?.skuName) context.push(inventory.skuName);
|
|
250
|
+
if (inventory?.skuTier && inventory.skuTier !== inventory.skuName) {
|
|
251
|
+
context.push(inventory.skuTier);
|
|
252
|
+
}
|
|
253
|
+
if (inventory?.location) context.push(inventory.location);
|
|
254
|
+
return context.length > 0 ? `${sentence} (${context.join(", ")})` : sentence;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Maps AZQR/APRL `impact` (`High` | `Medium` | `Low`) onto the SaveMoney
|
|
259
|
+
* `CostRisk` scale, defaulting to `low` for unknown values.
|
|
260
|
+
*/
|
|
261
|
+
function mapAzqrImpact(impact: string | undefined): CostRisk {
|
|
262
|
+
switch (impact?.toLowerCase()) {
|
|
263
|
+
case "high":
|
|
264
|
+
return "high";
|
|
265
|
+
case "medium":
|
|
266
|
+
return "medium";
|
|
267
|
+
default:
|
|
268
|
+
return "low";
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/azure/config.ts
CHANGED
|
@@ -42,6 +42,7 @@ export async function loadAzureConfig(
|
|
|
42
42
|
const rawYaml = yaml.load(raw);
|
|
43
43
|
const parsed = ConfigSchema.parse(rawYaml);
|
|
44
44
|
return {
|
|
45
|
+
azqrReportPath: parsed.azure.azqrReportPath,
|
|
45
46
|
concurrency: parsed.azure.concurrency,
|
|
46
47
|
preferredLocation: parsed.azure.preferredLocation,
|
|
47
48
|
sources: parsed.azure.sources,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Retail Prices API zod schemas, focused on the API's real
|
|
3
|
+
* (and easy to overlook) quirks rather than the happy path already covered
|
|
4
|
+
* indirectly by client.test.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { PriceItemSchema } from "../schema.js";
|
|
10
|
+
|
|
11
|
+
describe("PriceItemSchema", () => {
|
|
12
|
+
it("accepts empty strings for optional metadata fields", () => {
|
|
13
|
+
// The Retail Prices API returns "" (not the field omitted) for meters
|
|
14
|
+
// that don't have a given attribute, e.g. `armSkuName` on
|
|
15
|
+
// Bandwidth/Support meters. Resolvers already treat "" and `undefined`
|
|
16
|
+
// the same way (`?.toLowerCase() ?? ""`), so the schema must tolerate it.
|
|
17
|
+
const item = {
|
|
18
|
+
armSkuName: "",
|
|
19
|
+
currencyCode: "EUR",
|
|
20
|
+
retailPrice: 1,
|
|
21
|
+
type: "Consumption",
|
|
22
|
+
unitOfMeasure: "1 Hour",
|
|
23
|
+
unitPrice: 1,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const result = PriceItemSchema.parse(item);
|
|
27
|
+
|
|
28
|
+
expect(result.armSkuName).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|