@pagopa/dx-savemoney 0.2.6 → 0.3.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 +33 -27
- package/dist/__tests__/finding.test.d.ts +17 -0
- package/dist/__tests__/finding.test.d.ts.map +1 -0
- package/dist/__tests__/finding.test.js +124 -0
- package/dist/__tests__/finding.test.js.map +1 -0
- package/dist/azure/__tests__/analyzer-tags.test.d.ts +8 -0
- package/dist/azure/__tests__/analyzer-tags.test.d.ts.map +1 -0
- package/dist/azure/__tests__/analyzer-tags.test.js +43 -0
- package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -0
- package/dist/azure/__tests__/config.test.d.ts +9 -0
- package/dist/azure/__tests__/config.test.d.ts.map +1 -0
- package/dist/azure/__tests__/config.test.js +70 -0
- package/dist/azure/__tests__/config.test.js.map +1 -0
- package/dist/azure/__tests__/report.test.d.ts +9 -0
- package/dist/azure/__tests__/report.test.d.ts.map +1 -0
- package/dist/azure/__tests__/report.test.js +120 -0
- package/dist/azure/__tests__/report.test.js.map +1 -0
- package/dist/azure/__tests__/utils.test.d.ts +15 -0
- package/dist/azure/__tests__/utils.test.d.ts.map +1 -0
- package/dist/azure/__tests__/utils.test.js +181 -0
- package/dist/azure/__tests__/utils.test.js.map +1 -0
- package/dist/azure/analyzer.d.ts +18 -5
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +295 -48
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts +9 -0
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts.map +1 -0
- package/dist/azure/analyzers/__tests__/advisor.test.js +314 -0
- package/dist/azure/analyzers/__tests__/advisor.test.js.map +1 -0
- package/dist/azure/analyzers/advisor.d.ts +68 -0
- package/dist/azure/analyzers/advisor.d.ts.map +1 -0
- package/dist/azure/analyzers/advisor.js +234 -0
- package/dist/azure/analyzers/advisor.js.map +1 -0
- package/dist/azure/analyzers/index.d.ts +3 -1
- package/dist/azure/analyzers/index.d.ts.map +1 -1
- package/dist/azure/analyzers/index.js +2 -1
- package/dist/azure/analyzers/index.js.map +1 -1
- package/dist/azure/analyzers/registry.d.ts +8 -0
- package/dist/azure/analyzers/registry.d.ts.map +1 -1
- package/dist/azure/analyzers/registry.js +10 -0
- package/dist/azure/analyzers/registry.js.map +1 -1
- package/dist/azure/analyzers/subscription.d.ts +53 -0
- package/dist/azure/analyzers/subscription.d.ts.map +1 -0
- package/dist/azure/analyzers/subscription.js +18 -0
- package/dist/azure/analyzers/subscription.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/index.d.ts +1 -0
- package/dist/azure/index.d.ts.map +1 -1
- package/dist/azure/index.js +1 -0
- package/dist/azure/index.js.map +1 -1
- package/dist/azure/report.d.ts.map +1 -1
- package/dist/azure/report.js +178 -29
- package/dist/azure/report.js.map +1 -1
- package/dist/azure/resources/__tests__/storage.test.d.ts +11 -0
- package/dist/azure/resources/__tests__/storage.test.d.ts.map +1 -0
- package/dist/azure/resources/__tests__/storage.test.js +99 -0
- package/dist/azure/resources/__tests__/storage.test.js.map +1 -0
- package/dist/azure/types.d.ts +28 -1
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +78 -0
- package/dist/index.test.js.map +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -1
- package/package.json +5 -3
- package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
- package/src/azure/__tests__/report.test.ts +35 -6
- package/src/azure/analyzer.ts +421 -65
- package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
- package/src/azure/analyzers/advisor.ts +324 -0
- package/src/azure/analyzers/index.ts +9 -1
- package/src/azure/analyzers/registry.ts +12 -0
- package/src/azure/analyzers/subscription.ts +56 -0
- package/src/azure/config.ts +1 -0
- package/src/azure/index.ts +1 -0
- package/src/azure/report.ts +206 -35
- package/src/azure/types.ts +29 -1
- package/src/index.ts +1 -1
- package/src/schema.ts +9 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Azure Advisor subscription-level analyzer.
|
|
3
|
+
*
|
|
4
|
+
* We never touch a real Azure subscription: the analyzer is constructed
|
|
5
|
+
* with an injected client factory that returns a fake exposing only the
|
|
6
|
+
* `recommendations.list()` async iterator the analyzer relies on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TokenCredential } from "@azure/identity";
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import type { SubscriptionContext } from "../subscription.js";
|
|
14
|
+
|
|
15
|
+
import { createAdvisorAnalyzer } from "../advisor.js";
|
|
16
|
+
|
|
17
|
+
// The analyzer only consumes the fields below; using a structural mock
|
|
18
|
+
// keeps the test independent from the (rather verbose) SDK shape.
|
|
19
|
+
type RecLike = {
|
|
20
|
+
category?: string;
|
|
21
|
+
extendedProperties?: Record<string, string>;
|
|
22
|
+
id?: string;
|
|
23
|
+
impact?: string;
|
|
24
|
+
recommendationTypeId?: string;
|
|
25
|
+
resourceMetadata?: { resourceId?: string };
|
|
26
|
+
shortDescription?: { problem?: string; solution?: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function makeCtx(): SubscriptionContext {
|
|
30
|
+
const credential: TokenCredential = {
|
|
31
|
+
getToken: async () => null,
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
credential,
|
|
35
|
+
subscriptionId: "00000000-0000-0000-0000-000000000000",
|
|
36
|
+
verbose: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Reusable ARM-style resource IDs used across tests
|
|
41
|
+
const RID1 =
|
|
42
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-1";
|
|
43
|
+
const RID2 =
|
|
44
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-2";
|
|
45
|
+
|
|
46
|
+
function makeFakeClient(recs: RecLike[]) {
|
|
47
|
+
return {
|
|
48
|
+
recommendations: {
|
|
49
|
+
async *list() {
|
|
50
|
+
for (const r of recs) yield r;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("createAdvisorAnalyzer", () => {
|
|
57
|
+
it("exposes a stable id", () => {
|
|
58
|
+
const analyzer = createAdvisorAnalyzer({
|
|
59
|
+
build: () => makeFakeClient([]),
|
|
60
|
+
});
|
|
61
|
+
expect(analyzer.id).toBe("azure.advisor");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns no findings for an empty list", async () => {
|
|
65
|
+
const analyzer = createAdvisorAnalyzer({
|
|
66
|
+
build: () => makeFakeClient([]),
|
|
67
|
+
});
|
|
68
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
69
|
+
expect(findings).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("createAdvisorAnalyzer — recommendation mapping", () => {
|
|
74
|
+
it("maps a Cost recommendation to a Finding with savings", async () => {
|
|
75
|
+
const analyzer = createAdvisorAnalyzer({
|
|
76
|
+
build: () =>
|
|
77
|
+
makeFakeClient([
|
|
78
|
+
{
|
|
79
|
+
category: "Cost",
|
|
80
|
+
extendedProperties: {
|
|
81
|
+
savingsAmount: "42.50",
|
|
82
|
+
savingsCurrency: "EUR",
|
|
83
|
+
},
|
|
84
|
+
impact: "High",
|
|
85
|
+
recommendationTypeId: "right-size-vm",
|
|
86
|
+
resourceMetadata: {
|
|
87
|
+
resourceId:
|
|
88
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm",
|
|
89
|
+
},
|
|
90
|
+
shortDescription: {
|
|
91
|
+
problem: "Right-size your VM",
|
|
92
|
+
solution: "Switch to a smaller SKU",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
99
|
+
|
|
100
|
+
expect(findings).toHaveLength(1);
|
|
101
|
+
const finding = findings[0];
|
|
102
|
+
expect(finding.source).toBe("advisor");
|
|
103
|
+
expect(finding.code).toBe("advisor.right-size-vm");
|
|
104
|
+
expect(finding.severity).toBe("high");
|
|
105
|
+
expect(finding.category).toBe("cost");
|
|
106
|
+
expect(finding.estimatedMonthlySavings).toEqual({
|
|
107
|
+
amount: 42.5,
|
|
108
|
+
currency: "EUR",
|
|
109
|
+
});
|
|
110
|
+
expect(finding.recommendedAction).toBe("Switch to a smaller SKU");
|
|
111
|
+
expect(finding.reason).toBe("Right-size your VM.");
|
|
112
|
+
expect(finding.resourceId).toBe(
|
|
113
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("maps `Medium` impact to severity `medium`", async () => {
|
|
118
|
+
const analyzer = createAdvisorAnalyzer({
|
|
119
|
+
build: () =>
|
|
120
|
+
makeFakeClient([
|
|
121
|
+
{
|
|
122
|
+
category: "Cost",
|
|
123
|
+
impact: "Medium",
|
|
124
|
+
recommendationTypeId: "x",
|
|
125
|
+
resourceMetadata: { resourceId: RID1 },
|
|
126
|
+
shortDescription: { problem: "p" },
|
|
127
|
+
},
|
|
128
|
+
]),
|
|
129
|
+
});
|
|
130
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
131
|
+
expect(findings[0].severity).toBe("medium");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("falls back to `low` for unknown impact values", async () => {
|
|
135
|
+
const analyzer = createAdvisorAnalyzer({
|
|
136
|
+
build: () =>
|
|
137
|
+
makeFakeClient([
|
|
138
|
+
{
|
|
139
|
+
category: "Cost",
|
|
140
|
+
impact: "Bogus",
|
|
141
|
+
recommendationTypeId: "x",
|
|
142
|
+
resourceMetadata: { resourceId: RID1 },
|
|
143
|
+
shortDescription: { problem: "p" },
|
|
144
|
+
},
|
|
145
|
+
]),
|
|
146
|
+
});
|
|
147
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
148
|
+
expect(findings[0].severity).toBe("low");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("createAdvisorAnalyzer — savings parsing", () => {
|
|
153
|
+
it("omits estimatedMonthlySavings when savingsAmount is missing", async () => {
|
|
154
|
+
const analyzer = createAdvisorAnalyzer({
|
|
155
|
+
build: () =>
|
|
156
|
+
makeFakeClient([
|
|
157
|
+
{
|
|
158
|
+
category: "Cost",
|
|
159
|
+
impact: "Low",
|
|
160
|
+
recommendationTypeId: "x",
|
|
161
|
+
resourceMetadata: { resourceId: RID1 },
|
|
162
|
+
shortDescription: { problem: "p" },
|
|
163
|
+
},
|
|
164
|
+
]),
|
|
165
|
+
});
|
|
166
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
167
|
+
expect(findings[0].estimatedMonthlySavings).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("omits estimatedMonthlySavings when savingsAmount is not a number", async () => {
|
|
171
|
+
const analyzer = createAdvisorAnalyzer({
|
|
172
|
+
build: () =>
|
|
173
|
+
makeFakeClient([
|
|
174
|
+
{
|
|
175
|
+
category: "Cost",
|
|
176
|
+
extendedProperties: { savingsAmount: "n/a" },
|
|
177
|
+
impact: "Low",
|
|
178
|
+
recommendationTypeId: "x",
|
|
179
|
+
resourceMetadata: { resourceId: RID1 },
|
|
180
|
+
shortDescription: { problem: "p" },
|
|
181
|
+
},
|
|
182
|
+
]),
|
|
183
|
+
});
|
|
184
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
185
|
+
expect(findings[0].estimatedMonthlySavings).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("defaults the currency to USD when only savingsAmount is present", async () => {
|
|
189
|
+
const analyzer = createAdvisorAnalyzer({
|
|
190
|
+
build: () =>
|
|
191
|
+
makeFakeClient([
|
|
192
|
+
{
|
|
193
|
+
category: "Cost",
|
|
194
|
+
extendedProperties: { savingsAmount: "10" },
|
|
195
|
+
impact: "Low",
|
|
196
|
+
recommendationTypeId: "x",
|
|
197
|
+
resourceMetadata: { resourceId: RID1 },
|
|
198
|
+
shortDescription: { problem: "p" },
|
|
199
|
+
},
|
|
200
|
+
]),
|
|
201
|
+
});
|
|
202
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
203
|
+
expect(findings[0].estimatedMonthlySavings).toEqual({
|
|
204
|
+
amount: 10,
|
|
205
|
+
currency: "USD",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("createAdvisorAnalyzer — filtering", () => {
|
|
211
|
+
it("skips non-Cost recommendations", async () => {
|
|
212
|
+
const analyzer = createAdvisorAnalyzer({
|
|
213
|
+
build: () =>
|
|
214
|
+
makeFakeClient([
|
|
215
|
+
{
|
|
216
|
+
category: "Security",
|
|
217
|
+
impact: "High",
|
|
218
|
+
recommendationTypeId: "x",
|
|
219
|
+
resourceMetadata: { resourceId: RID1 },
|
|
220
|
+
shortDescription: { problem: "p" },
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
category: "Cost",
|
|
224
|
+
impact: "Low",
|
|
225
|
+
recommendationTypeId: "y",
|
|
226
|
+
resourceMetadata: { resourceId: RID2 },
|
|
227
|
+
shortDescription: { problem: "p" },
|
|
228
|
+
},
|
|
229
|
+
]),
|
|
230
|
+
});
|
|
231
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
232
|
+
expect(findings).toHaveLength(1);
|
|
233
|
+
expect(findings[0].code).toBe("advisor.y");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("attributes subscription as fallback resource when resourceId is absent", async () => {
|
|
237
|
+
const ctx = makeCtx();
|
|
238
|
+
const analyzer = createAdvisorAnalyzer({
|
|
239
|
+
build: () =>
|
|
240
|
+
makeFakeClient([
|
|
241
|
+
{
|
|
242
|
+
category: "Cost",
|
|
243
|
+
impact: "Low",
|
|
244
|
+
recommendationTypeId: "x",
|
|
245
|
+
resourceMetadata: {},
|
|
246
|
+
shortDescription: { problem: "p" },
|
|
247
|
+
},
|
|
248
|
+
]),
|
|
249
|
+
});
|
|
250
|
+
const findings = await analyzer.analyze(ctx);
|
|
251
|
+
expect(findings).toHaveLength(1);
|
|
252
|
+
expect(findings[0].resourceId).toBe(`/subscriptions/${ctx.subscriptionId}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("includes subscription-scoped recommendations using their own resource ID", async () => {
|
|
256
|
+
const SUB_URI = "/subscriptions/00000000-0000-0000-0000-000000000000";
|
|
257
|
+
const analyzer = createAdvisorAnalyzer({
|
|
258
|
+
build: () =>
|
|
259
|
+
makeFakeClient([
|
|
260
|
+
{
|
|
261
|
+
category: "Cost",
|
|
262
|
+
impact: "High",
|
|
263
|
+
recommendationTypeId: "reserved-instance",
|
|
264
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
265
|
+
shortDescription: { problem: "Consider reserved instances" },
|
|
266
|
+
},
|
|
267
|
+
]),
|
|
268
|
+
});
|
|
269
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
270
|
+
expect(findings).toHaveLength(1);
|
|
271
|
+
expect(findings[0].resourceId).toBe(SUB_URI);
|
|
272
|
+
expect(findings[0].severity).toBe("high");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("aggregates subscription-scoped findings with the same recommendationTypeId", async () => {
|
|
276
|
+
const SUB_URI = "/subscriptions/00000000-0000-0000-0000-000000000000";
|
|
277
|
+
const analyzer = createAdvisorAnalyzer({
|
|
278
|
+
build: () =>
|
|
279
|
+
makeFakeClient([
|
|
280
|
+
{
|
|
281
|
+
category: "Cost",
|
|
282
|
+
extendedProperties: {
|
|
283
|
+
savingsAmount: "121",
|
|
284
|
+
savingsCurrency: "EUR",
|
|
285
|
+
},
|
|
286
|
+
id: "/subscriptions/sub1/advisorRecommendations/r1",
|
|
287
|
+
impact: "High",
|
|
288
|
+
recommendationTypeId: "postgresql-ri",
|
|
289
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
290
|
+
shortDescription: {
|
|
291
|
+
problem: "Consider PostgreSQL reserved instance",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
category: "Cost",
|
|
296
|
+
extendedProperties: { savingsAmount: "71", savingsCurrency: "EUR" },
|
|
297
|
+
id: "/subscriptions/sub1/advisorRecommendations/r2",
|
|
298
|
+
impact: "High",
|
|
299
|
+
recommendationTypeId: "postgresql-ri",
|
|
300
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
301
|
+
shortDescription: {
|
|
302
|
+
problem: "Consider PostgreSQL reserved instance",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
]),
|
|
306
|
+
});
|
|
307
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
308
|
+
// Two API entries with the same type → one aggregated finding.
|
|
309
|
+
expect(findings).toHaveLength(1);
|
|
310
|
+
expect(findings[0].code).toBe("advisor.postgresql-ri");
|
|
311
|
+
expect(findings[0].estimatedMonthlySavings).toEqual({
|
|
312
|
+
amount: 121,
|
|
313
|
+
currency: "EUR",
|
|
314
|
+
});
|
|
315
|
+
expect(findings[0].reason).toContain("2 options");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("deduplicates subscription-scoped entries with the same recommendation ARM ID", async () => {
|
|
319
|
+
const SUB_URI = "/subscriptions/00000000-0000-0000-0000-000000000000";
|
|
320
|
+
const dupId =
|
|
321
|
+
"/subscriptions/sub1/providers/microsoft.advisor/recommendations/dup";
|
|
322
|
+
const analyzer = createAdvisorAnalyzer({
|
|
323
|
+
build: () =>
|
|
324
|
+
makeFakeClient([
|
|
325
|
+
{
|
|
326
|
+
category: "Cost",
|
|
327
|
+
extendedProperties: { savingsAmount: "50", savingsCurrency: "EUR" },
|
|
328
|
+
id: dupId,
|
|
329
|
+
impact: "High",
|
|
330
|
+
recommendationTypeId: "vm-ri",
|
|
331
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
332
|
+
shortDescription: { problem: "Buy VM reserved instance" },
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
// Same ARM ID returned again by the API — should be counted only once.
|
|
336
|
+
category: "Cost",
|
|
337
|
+
extendedProperties: { savingsAmount: "50", savingsCurrency: "EUR" },
|
|
338
|
+
id: dupId,
|
|
339
|
+
impact: "High",
|
|
340
|
+
recommendationTypeId: "vm-ri",
|
|
341
|
+
resourceMetadata: { resourceId: SUB_URI },
|
|
342
|
+
shortDescription: { problem: "Buy VM reserved instance" },
|
|
343
|
+
},
|
|
344
|
+
]),
|
|
345
|
+
});
|
|
346
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
347
|
+
expect(findings).toHaveLength(1);
|
|
348
|
+
// Savings must NOT be doubled.
|
|
349
|
+
expect(findings[0].estimatedMonthlySavings?.amount).toBe(50);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("uses a fallback reason when shortDescription is missing", async () => {
|
|
353
|
+
const analyzer = createAdvisorAnalyzer({
|
|
354
|
+
build: () =>
|
|
355
|
+
makeFakeClient([
|
|
356
|
+
{
|
|
357
|
+
category: "Cost",
|
|
358
|
+
impact: "Low",
|
|
359
|
+
recommendationTypeId: "x",
|
|
360
|
+
resourceMetadata: { resourceId: RID1 },
|
|
361
|
+
},
|
|
362
|
+
]),
|
|
363
|
+
});
|
|
364
|
+
const findings = await analyzer.analyze(makeCtx());
|
|
365
|
+
expect(findings[0].reason).toBe("Azure Advisor cost recommendation.");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Advisor analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Fetches all `Cost` recommendations for the target subscription via the
|
|
5
|
+
* `@azure/arm-advisor` SDK and normalises them into the unified `Finding`
|
|
6
|
+
* model.
|
|
7
|
+
*
|
|
8
|
+
* ## Two kinds of Advisor cost recommendations
|
|
9
|
+
*
|
|
10
|
+
* ### Resource-specific (resourceMetadata.resourceId contains /providers/)
|
|
11
|
+
* Recommendations about a particular Azure resource (e.g. "right-size this
|
|
12
|
+
* VM"). Each is emitted as a separate Finding so it appears next to the
|
|
13
|
+
* resource in the per-resource section of the report. `enrichReason` adds
|
|
14
|
+
* SKU / region / term context from `extendedProperties` so findings for
|
|
15
|
+
* the same recommendation type on different resources are distinguishable.
|
|
16
|
+
*
|
|
17
|
+
* ### Subscription-scoped (no resourceId, or resourceId without /providers/)
|
|
18
|
+
* Reserved Instance and Savings Plan recommendations. The Advisor API
|
|
19
|
+
* returns **one entry per qualifying combination** (term, scope, quantity,
|
|
20
|
+
* etc.) but the Azure Portal **groups them by `recommendationTypeId`** and
|
|
21
|
+
* shows a single entry for the best option — matching the mental model
|
|
22
|
+
* a user has when deciding whether to buy a commitment.
|
|
23
|
+
*
|
|
24
|
+
* Different amounts within the same `recommendationTypeId` represent
|
|
25
|
+
* **mutually exclusive purchase options** (e.g. different DB sizes or
|
|
26
|
+
* quantities); the user would choose one, not buy all. We therefore report
|
|
27
|
+
* the **maximum** savings amount across all unique configurations, matching
|
|
28
|
+
* the portal's behaviour. Scope variants (Shared / Single / ResourceGroup)
|
|
29
|
+
* that carry the same amount are deduplicated so they do not count more
|
|
30
|
+
* than once. True duplicates — recommendations with the same ARM ID —
|
|
31
|
+
* are also removed before taking the maximum.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { AdvisorManagementClient } from "@azure/arm-advisor";
|
|
35
|
+
import { getLogger } from "@logtape/logtape";
|
|
36
|
+
|
|
37
|
+
import type { Finding } from "../../finding.js";
|
|
38
|
+
import type { CostRisk } from "../../types.js";
|
|
39
|
+
import type {
|
|
40
|
+
SubscriptionAnalyzer,
|
|
41
|
+
SubscriptionContext,
|
|
42
|
+
} from "./subscription.js";
|
|
43
|
+
|
|
44
|
+
/** Minimal client shape used by the analyzer (and injectable in tests). */
|
|
45
|
+
type AdvisorClientLike = {
|
|
46
|
+
recommendations: {
|
|
47
|
+
list(options?: { filter?: string }): AsyncIterable<RecommendationInfo>;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Minimal shape of a recommendation entry needed by the helpers. */
|
|
52
|
+
type RecommendationInfo = {
|
|
53
|
+
category?: string;
|
|
54
|
+
extendedProperties?: Record<string, unknown>;
|
|
55
|
+
id?: string;
|
|
56
|
+
impact?: string;
|
|
57
|
+
recommendationTypeId?: string;
|
|
58
|
+
resourceMetadata?: { resourceId?: string };
|
|
59
|
+
shortDescription?: { problem?: string; solution?: string };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Accumulator for subscription-scoped recommendations grouped by type. */
|
|
63
|
+
type SubGroup = {
|
|
64
|
+
/** Best (maximum) monthly savings among all deduplicated options in this group. */
|
|
65
|
+
bestAmount: number;
|
|
66
|
+
/** Number of distinct savings configurations aggregated (unique amounts). */
|
|
67
|
+
count: number;
|
|
68
|
+
/** ISO 4217 currency code of the first recommendation in this group. */
|
|
69
|
+
currency: string;
|
|
70
|
+
/** Prototype Finding shared by all recommendations in this group. */
|
|
71
|
+
proto: Omit<Finding, "estimatedMonthlySavings">;
|
|
72
|
+
/** ARM IDs of recommendations already counted — deduplicates true API duplicates. */
|
|
73
|
+
recIds: Set<string>;
|
|
74
|
+
/**
|
|
75
|
+
* `"${amount}"` keys for already-seen savings — deduplicates scope variants.
|
|
76
|
+
* Advisor returns the same configuration for Shared, Single, and ResourceGroup
|
|
77
|
+
* scopes all with the same savings amount; we track each amount only once so
|
|
78
|
+
* we can take the max without double-counting identical scope variants.
|
|
79
|
+
*/
|
|
80
|
+
uniqueSavingsKeys: Set<string>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Builds the Advisor subscription-level analyzer.
|
|
85
|
+
*
|
|
86
|
+
* @param clientFactory Optional override to inject a mock client in tests.
|
|
87
|
+
* In production the default factory builds a real
|
|
88
|
+
* `AdvisorManagementClient` from the credential.
|
|
89
|
+
*/
|
|
90
|
+
export function createAdvisorAnalyzer(clientFactory?: {
|
|
91
|
+
build(
|
|
92
|
+
credential: SubscriptionContext["credential"],
|
|
93
|
+
subscriptionId: string,
|
|
94
|
+
): AdvisorClientLike;
|
|
95
|
+
}): SubscriptionAnalyzer {
|
|
96
|
+
const build =
|
|
97
|
+
clientFactory?.build ??
|
|
98
|
+
((credential, subscriptionId) =>
|
|
99
|
+
new AdvisorManagementClient(credential, subscriptionId));
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async analyze(ctx: SubscriptionContext): Promise<Finding[]> {
|
|
103
|
+
const logger = getLogger(["savemoney", "azure", "advisor"]);
|
|
104
|
+
const client = build(ctx.credential, ctx.subscriptionId);
|
|
105
|
+
const resourceFindings: Finding[] = [];
|
|
106
|
+
const subGroups = new Map<string, SubGroup>();
|
|
107
|
+
|
|
108
|
+
for await (const rec of client.recommendations.list({
|
|
109
|
+
filter: "Category eq 'Cost'",
|
|
110
|
+
})) {
|
|
111
|
+
if (rec.category?.toLowerCase() !== "cost") continue;
|
|
112
|
+
const rawResourceId = rec.resourceMetadata?.resourceId;
|
|
113
|
+
const props = rec.extendedProperties as
|
|
114
|
+
| Record<string, string>
|
|
115
|
+
| undefined;
|
|
116
|
+
const savings = parseSavings(props);
|
|
117
|
+
if (rawResourceId && /\/providers\//i.test(rawResourceId)) {
|
|
118
|
+
resourceFindings.push(
|
|
119
|
+
buildResourceFinding(rec, savings, props, rawResourceId),
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
addToSubGroups(subGroups, rec, savings, ctx, logger);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const findings = [...resourceFindings, ...flushSubGroups(subGroups)];
|
|
127
|
+
logger.info(
|
|
128
|
+
`Advisor: ${findings.length} cost finding(s) for ${ctx.subscriptionId}` +
|
|
129
|
+
` (${resourceFindings.length} resource-specific, ${subGroups.size} subscription-level)`,
|
|
130
|
+
);
|
|
131
|
+
return findings;
|
|
132
|
+
},
|
|
133
|
+
id: "azure.advisor",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── helpers — extracted to keep `analyze` complexity within linter limits —
|
|
138
|
+
|
|
139
|
+
function addToSubGroups(
|
|
140
|
+
subGroups: Map<string, SubGroup>,
|
|
141
|
+
rec: RecommendationInfo,
|
|
142
|
+
savings: undefined | { amount: number; currency: string },
|
|
143
|
+
ctx: SubscriptionContext,
|
|
144
|
+
logger: ReturnType<typeof getLogger>,
|
|
145
|
+
): void {
|
|
146
|
+
const typeKey =
|
|
147
|
+
rec.recommendationTypeId ??
|
|
148
|
+
`unknown.${rec.shortDescription?.problem ?? ""}`;
|
|
149
|
+
const existing = subGroups.get(typeKey);
|
|
150
|
+
if (existing) {
|
|
151
|
+
updateSubGroup(existing, rec.id ?? "", savings);
|
|
152
|
+
} else {
|
|
153
|
+
subGroups.set(typeKey, createSubGroup(rec, typeKey, savings, ctx, logger));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildResourceFinding(
|
|
158
|
+
rec: RecommendationInfo,
|
|
159
|
+
savings: undefined | { amount: number; currency: string },
|
|
160
|
+
props: Record<string, string> | undefined,
|
|
161
|
+
rawResourceId: string,
|
|
162
|
+
): Finding {
|
|
163
|
+
const problem =
|
|
164
|
+
rec.shortDescription?.problem ??
|
|
165
|
+
rec.shortDescription?.solution ??
|
|
166
|
+
"Azure Advisor cost recommendation";
|
|
167
|
+
return {
|
|
168
|
+
category: "cost",
|
|
169
|
+
code: `advisor.${rec.recommendationTypeId ?? "unknown"}`,
|
|
170
|
+
estimatedMonthlySavings: savings,
|
|
171
|
+
reason: enrichReason(problem, props),
|
|
172
|
+
recommendedAction: rec.shortDescription?.solution,
|
|
173
|
+
resourceId: rawResourceId,
|
|
174
|
+
severity: mapImpact(rec.impact),
|
|
175
|
+
source: "advisor",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function createSubGroup(
|
|
180
|
+
rec: RecommendationInfo,
|
|
181
|
+
typeKey: string,
|
|
182
|
+
savings: undefined | { amount: number; currency: string },
|
|
183
|
+
ctx: SubscriptionContext,
|
|
184
|
+
logger: ReturnType<typeof getLogger>,
|
|
185
|
+
): SubGroup {
|
|
186
|
+
const problem =
|
|
187
|
+
rec.shortDescription?.problem ??
|
|
188
|
+
rec.shortDescription?.solution ??
|
|
189
|
+
"Azure Advisor cost recommendation";
|
|
190
|
+
const reason = problem.endsWith(".") ? problem : `${problem}.`;
|
|
191
|
+
const rawResourceId = rec.resourceMetadata?.resourceId;
|
|
192
|
+
const resourceId = rawResourceId ?? `/subscriptions/${ctx.subscriptionId}`;
|
|
193
|
+
if (!rawResourceId && ctx.verbose) {
|
|
194
|
+
logger.debug(
|
|
195
|
+
`Advisor recommendation has no resourceId, attributed to subscription: ${typeKey}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
bestAmount: savings?.amount ?? 0,
|
|
200
|
+
count: savings ? 1 : 0,
|
|
201
|
+
currency: savings?.currency ?? "USD",
|
|
202
|
+
proto: {
|
|
203
|
+
category: "cost",
|
|
204
|
+
code: `advisor.${typeKey}`,
|
|
205
|
+
reason,
|
|
206
|
+
recommendedAction: rec.shortDescription?.solution,
|
|
207
|
+
resourceId,
|
|
208
|
+
severity: mapImpact(rec.impact),
|
|
209
|
+
source: "advisor",
|
|
210
|
+
},
|
|
211
|
+
recIds: new Set(rec.id ? [rec.id] : []),
|
|
212
|
+
uniqueSavingsKeys: new Set(savings ? [`${savings.amount}`] : []),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Appends discriminating context (SKU, region, term, scope, …) to the
|
|
218
|
+
* Advisor short description so visually-duplicated reasons can be told
|
|
219
|
+
* apart. RI / Savings Plan recommendations in particular return the same
|
|
220
|
+
* `problem` string for every SKU+region+term combination, which made the
|
|
221
|
+
* report look like it carried duplicated rows. Each call stays a single
|
|
222
|
+
* Finding — we do NOT deduplicate, because every recommendation refers
|
|
223
|
+
* to a different commitment with its own savings figure.
|
|
224
|
+
*/
|
|
225
|
+
function enrichReason(
|
|
226
|
+
problem: string,
|
|
227
|
+
props: Record<string, string> | undefined,
|
|
228
|
+
): string {
|
|
229
|
+
const base = problem.endsWith(".") ? problem : `${problem}.`;
|
|
230
|
+
if (!props) return base;
|
|
231
|
+
const parts: string[] = [];
|
|
232
|
+
// armSkuName covers VM RIs; productName/serviceType cover SQL/Cosmos/etc.
|
|
233
|
+
const sku =
|
|
234
|
+
props.armSkuName ?? props.productName ?? props.serviceType ?? props.sku;
|
|
235
|
+
if (sku) parts.push(sku);
|
|
236
|
+
const region = props.region ?? props.location;
|
|
237
|
+
if (region) parts.push(region);
|
|
238
|
+
// Normalise term values (P1Y / P3Y / 1_Year / 3_Year) into a compact label.
|
|
239
|
+
const rawTerm = props.term;
|
|
240
|
+
if (rawTerm) {
|
|
241
|
+
const term = /3/.test(rawTerm) ? "3y" : /1/.test(rawTerm) ? "1y" : rawTerm;
|
|
242
|
+
parts.push(term);
|
|
243
|
+
}
|
|
244
|
+
const scope = props.scope;
|
|
245
|
+
if (scope) parts.push(scope.toLowerCase());
|
|
246
|
+
const qty = props.displayQuantity ?? props.quantity;
|
|
247
|
+
if (qty) parts.push(`x${qty}`);
|
|
248
|
+
if (parts.length === 0) return base;
|
|
249
|
+
return `${base} (${parts.join(", ")})`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function flushSubGroups(subGroups: Map<string, SubGroup>): Finding[] {
|
|
253
|
+
const findings: Finding[] = [];
|
|
254
|
+
for (const { bestAmount, count, currency, proto } of subGroups.values()) {
|
|
255
|
+
const estimatedMonthlySavings =
|
|
256
|
+
bestAmount > 0 ? { amount: bestAmount, currency } : undefined;
|
|
257
|
+
const reason =
|
|
258
|
+
count > 1
|
|
259
|
+
? proto.reason.replace(/\.$/, ` (${count} options).`)
|
|
260
|
+
: proto.reason;
|
|
261
|
+
findings.push({ ...proto, estimatedMonthlySavings, reason });
|
|
262
|
+
}
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Maps Advisor's `impact` (`High` | `Medium` | `Low`) onto the savemoney
|
|
268
|
+
* `CostRisk` scale. Falls back to `low` for any unexpected value to keep
|
|
269
|
+
* the analyzer resilient to future Advisor enum extensions.
|
|
270
|
+
*/
|
|
271
|
+
function mapImpact(impact: string | undefined): CostRisk {
|
|
272
|
+
switch (impact?.toLowerCase()) {
|
|
273
|
+
case "high":
|
|
274
|
+
return "high";
|
|
275
|
+
case "medium":
|
|
276
|
+
return "medium";
|
|
277
|
+
default:
|
|
278
|
+
return "low";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parses the savings amount from Advisor's `extendedProperties`. Advisor
|
|
284
|
+
* returns it as a string; treat anything non-numeric as "no estimate".
|
|
285
|
+
*/
|
|
286
|
+
function parseSavings(
|
|
287
|
+
extendedProperties: Record<string, string> | undefined,
|
|
288
|
+
): undefined | { amount: number; currency: string } {
|
|
289
|
+
if (!extendedProperties) return undefined;
|
|
290
|
+
const raw = extendedProperties.savingsAmount ?? extendedProperties.savings;
|
|
291
|
+
if (!raw) return undefined;
|
|
292
|
+
const amount = Number(raw);
|
|
293
|
+
if (!Number.isFinite(amount)) return undefined;
|
|
294
|
+
const currency =
|
|
295
|
+
extendedProperties.savingsCurrency ?? extendedProperties.currency ?? "USD";
|
|
296
|
+
return { amount, currency };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function updateSubGroup(
|
|
300
|
+
group: SubGroup,
|
|
301
|
+
recId: string,
|
|
302
|
+
savings: undefined | { amount: number; currency: string },
|
|
303
|
+
): void {
|
|
304
|
+
if (recId && group.recIds.has(recId)) return; // true API duplicate
|
|
305
|
+
if (recId) group.recIds.add(recId);
|
|
306
|
+
if (!savings) return;
|
|
307
|
+
// Bootstrap currency from the first recommendation that carries savings.
|
|
308
|
+
// createSubGroup() defaults currency to "USD" when the first entry has no
|
|
309
|
+
// savings; without this check, later entries with a different currency (e.g.
|
|
310
|
+
// "EUR") would be silently dropped and no savings would ever be surfaced.
|
|
311
|
+
if (group.count === 0) {
|
|
312
|
+
group.currency = savings.currency;
|
|
313
|
+
group.uniqueSavingsKeys.add(`${savings.amount}`);
|
|
314
|
+
group.bestAmount = savings.amount;
|
|
315
|
+
group.count = 1;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (savings.currency !== group.currency) return;
|
|
319
|
+
const key = `${savings.amount}`;
|
|
320
|
+
if (group.uniqueSavingsKeys.has(key)) return; // scope variant, already seen
|
|
321
|
+
group.uniqueSavingsKeys.add(key);
|
|
322
|
+
group.bestAmount = Math.max(group.bestAmount, savings.amount);
|
|
323
|
+
group.count++;
|
|
324
|
+
}
|
|
@@ -2,5 +2,13 @@
|
|
|
2
2
|
* Re-exports for the Azure analyzer plugin layer.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export {
|
|
5
|
+
export { createAdvisorAnalyzer } from "./advisor.js";
|
|
6
|
+
export {
|
|
7
|
+
createDefaultAnalyzers,
|
|
8
|
+
createDefaultSubscriptionAnalyzers,
|
|
9
|
+
} from "./registry.js";
|
|
10
|
+
export type {
|
|
11
|
+
SubscriptionAnalyzer,
|
|
12
|
+
SubscriptionContext,
|
|
13
|
+
} from "./subscription.js";
|
|
6
14
|
export type { Analyzer, AnalyzerContext, AzureClients } from "./types.js";
|