@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.
- package/README.md +48 -0
- package/dist/azure/__tests__/azqr.test.d.ts +13 -0
- package/dist/azure/__tests__/azqr.test.d.ts.map +1 -0
- package/dist/azure/__tests__/azqr.test.js +177 -0
- package/dist/azure/__tests__/azqr.test.js.map +1 -0
- package/dist/azure/__tests__/config.test.js +23 -1
- package/dist/azure/__tests__/config.test.js.map +1 -1
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +32 -4
- 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 +2 -5
- package/dist/azure/config.js.map +1 -1
- package/dist/azure/resources/public-ip.d.ts.map +1 -1
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/types.d.ts +13 -3
- package/dist/azure/types.d.ts.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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +6 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +11 -3
- package/dist/schema.js.map +1 -1
- package/package.json +8 -8
- package/src/azure/__tests__/azqr.test.ts +238 -0
- package/src/azure/__tests__/config.test.ts +33 -1
- package/src/azure/__tests__/fixtures/full-override.yaml +3 -0
- package/src/azure/analyzer.ts +50 -9
- package/src/azure/analyzers/advisor.ts +1 -2
- package/src/azure/azqr.ts +270 -0
- package/src/azure/config.ts +2 -5
- package/src/azure/resources/public-ip.ts +1 -2
- package/src/azure/types.ts +13 -3
- package/src/finding.ts +3 -6
- package/src/index.ts +1 -0
- package/src/schema.ts +13 -3
package/dist/schema.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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: {
|
package/src/azure/analyzer.ts
CHANGED
|
@@ -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
|
|
90
|
-
const
|
|
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
|
-
|
|
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(
|