@pagopa/dx-savemoney 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +33 -27
  2. package/dist/azure/analyzer.d.ts +49 -21
  3. package/dist/azure/analyzer.d.ts.map +1 -1
  4. package/dist/azure/analyzer.js +369 -93
  5. package/dist/azure/analyzer.js.map +1 -1
  6. package/dist/azure/analyzers/advisor.d.ts +68 -0
  7. package/dist/azure/analyzers/advisor.d.ts.map +1 -0
  8. package/dist/azure/analyzers/advisor.js +234 -0
  9. package/dist/azure/analyzers/advisor.js.map +1 -0
  10. package/dist/azure/analyzers/index.d.ts +8 -0
  11. package/dist/azure/analyzers/index.d.ts.map +1 -0
  12. package/dist/azure/analyzers/index.js +6 -0
  13. package/dist/azure/analyzers/index.js.map +1 -0
  14. package/dist/azure/analyzers/registry.d.ts +29 -0
  15. package/dist/azure/analyzers/registry.d.ts.map +1 -0
  16. package/dist/azure/analyzers/registry.js +79 -0
  17. package/dist/azure/analyzers/registry.js.map +1 -0
  18. package/dist/azure/analyzers/subscription.d.ts +53 -0
  19. package/dist/azure/analyzers/subscription.d.ts.map +1 -0
  20. package/dist/azure/analyzers/subscription.js +18 -0
  21. package/dist/azure/analyzers/subscription.js.map +1 -0
  22. package/dist/azure/analyzers/types.d.ts +62 -0
  23. package/dist/azure/analyzers/types.d.ts.map +1 -0
  24. package/dist/azure/analyzers/types.js +15 -0
  25. package/dist/azure/analyzers/types.js.map +1 -0
  26. package/dist/azure/config.d.ts.map +1 -1
  27. package/dist/azure/config.js +2 -0
  28. package/dist/azure/config.js.map +1 -1
  29. package/dist/azure/index.d.ts +1 -0
  30. package/dist/azure/index.d.ts.map +1 -1
  31. package/dist/azure/index.js +1 -0
  32. package/dist/azure/index.js.map +1 -1
  33. package/dist/azure/report.d.ts.map +1 -1
  34. package/dist/azure/report.js +178 -29
  35. package/dist/azure/report.js.map +1 -1
  36. package/dist/azure/resources/app-service.d.ts +2 -1
  37. package/dist/azure/resources/app-service.d.ts.map +1 -1
  38. package/dist/azure/resources/app-service.js +3 -3
  39. package/dist/azure/resources/app-service.js.map +1 -1
  40. package/dist/azure/resources/container-app.d.ts +2 -1
  41. package/dist/azure/resources/container-app.d.ts.map +1 -1
  42. package/dist/azure/resources/container-app.js +9 -9
  43. package/dist/azure/resources/container-app.js.map +1 -1
  44. package/dist/azure/resources/public-ip.d.ts +2 -1
  45. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  46. package/dist/azure/resources/public-ip.js +2 -2
  47. package/dist/azure/resources/public-ip.js.map +1 -1
  48. package/dist/azure/resources/static-web-app.d.ts +2 -1
  49. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  50. package/dist/azure/resources/static-web-app.js +3 -3
  51. package/dist/azure/resources/static-web-app.js.map +1 -1
  52. package/dist/azure/resources/storage.d.ts +2 -1
  53. package/dist/azure/resources/storage.d.ts.map +1 -1
  54. package/dist/azure/resources/storage.js +2 -2
  55. package/dist/azure/resources/storage.js.map +1 -1
  56. package/dist/azure/resources/vm.d.ts +2 -1
  57. package/dist/azure/resources/vm.d.ts.map +1 -1
  58. package/dist/azure/resources/vm.js +3 -3
  59. package/dist/azure/resources/vm.js.map +1 -1
  60. package/dist/azure/types.d.ts +34 -1
  61. package/dist/azure/types.d.ts.map +1 -1
  62. package/dist/azure/utils.d.ts +35 -3
  63. package/dist/azure/utils.d.ts.map +1 -1
  64. package/dist/azure/utils.js +70 -29
  65. package/dist/azure/utils.js.map +1 -1
  66. package/dist/finding.d.ts +114 -0
  67. package/dist/finding.d.ts.map +1 -0
  68. package/dist/finding.js +51 -0
  69. package/dist/finding.js.map +1 -0
  70. package/dist/index.d.ts +4 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/schema.d.ts +5 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +14 -0
  77. package/dist/schema.js.map +1 -1
  78. package/package.json +4 -1
  79. package/src/__tests__/finding.test.ts +149 -0
  80. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  81. package/src/azure/__tests__/report.test.ts +27 -0
  82. package/src/azure/__tests__/utils.test.ts +164 -2
  83. package/src/azure/analyzer.ts +513 -182
  84. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  85. package/src/azure/analyzers/advisor.ts +324 -0
  86. package/src/azure/analyzers/index.ts +14 -0
  87. package/src/azure/analyzers/registry.ts +196 -0
  88. package/src/azure/analyzers/subscription.ts +56 -0
  89. package/src/azure/analyzers/types.ts +66 -0
  90. package/src/azure/config.ts +2 -0
  91. package/src/azure/index.ts +1 -0
  92. package/src/azure/report.ts +206 -35
  93. package/src/azure/resources/app-service.ts +4 -0
  94. package/src/azure/resources/container-app.ts +10 -0
  95. package/src/azure/resources/public-ip.ts +3 -0
  96. package/src/azure/resources/static-web-app.ts +4 -0
  97. package/src/azure/resources/storage.ts +3 -0
  98. package/src/azure/resources/vm.ts +4 -0
  99. package/src/azure/types.ts +35 -1
  100. package/src/azure/utils.ts +110 -39
  101. package/src/finding.ts +152 -0
  102. package/src/index.ts +19 -1
  103. package/src/schema.ts +14 -0
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Re-exports for the Azure analyzer plugin layer.
3
+ */
4
+
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";
14
+ export type { Analyzer, AnalyzerContext, AzureClients } from "./types.js";