@pagopa/dx-savemoney 0.2.6 → 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 (54) hide show
  1. package/README.md +33 -27
  2. package/dist/azure/analyzer.d.ts +18 -5
  3. package/dist/azure/analyzer.d.ts.map +1 -1
  4. package/dist/azure/analyzer.js +295 -48
  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 +3 -1
  11. package/dist/azure/analyzers/index.d.ts.map +1 -1
  12. package/dist/azure/analyzers/index.js +2 -1
  13. package/dist/azure/analyzers/index.js.map +1 -1
  14. package/dist/azure/analyzers/registry.d.ts +8 -0
  15. package/dist/azure/analyzers/registry.d.ts.map +1 -1
  16. package/dist/azure/analyzers/registry.js +10 -0
  17. package/dist/azure/analyzers/registry.js.map +1 -1
  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/config.d.ts.map +1 -1
  23. package/dist/azure/config.js +1 -0
  24. package/dist/azure/config.js.map +1 -1
  25. package/dist/azure/index.d.ts +1 -0
  26. package/dist/azure/index.d.ts.map +1 -1
  27. package/dist/azure/index.js +1 -0
  28. package/dist/azure/index.js.map +1 -1
  29. package/dist/azure/report.d.ts.map +1 -1
  30. package/dist/azure/report.js +178 -29
  31. package/dist/azure/report.js.map +1 -1
  32. package/dist/azure/types.d.ts +28 -1
  33. package/dist/azure/types.d.ts.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/schema.d.ts +4 -0
  37. package/dist/schema.d.ts.map +1 -1
  38. package/dist/schema.js +9 -0
  39. package/dist/schema.js.map +1 -1
  40. package/package.json +3 -1
  41. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  42. package/src/azure/__tests__/report.test.ts +27 -0
  43. package/src/azure/analyzer.ts +421 -65
  44. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  45. package/src/azure/analyzers/advisor.ts +324 -0
  46. package/src/azure/analyzers/index.ts +9 -1
  47. package/src/azure/analyzers/registry.ts +12 -0
  48. package/src/azure/analyzers/subscription.ts +56 -0
  49. package/src/azure/config.ts +1 -0
  50. package/src/azure/index.ts +1 -0
  51. package/src/azure/report.ts +206 -35
  52. package/src/azure/types.ts +29 -1
  53. package/src/index.ts +1 -1
  54. package/src/schema.ts +9 -0
@@ -10,6 +10,7 @@
10
10
  * Adding a new analyzer is a single insertion here.
11
11
  */
12
12
 
13
+ import type { SubscriptionAnalyzer } from "./subscription.js";
13
14
  import type { Analyzer } from "./types.js";
14
15
 
15
16
  import {
@@ -23,6 +24,7 @@ import {
23
24
  analyzeStorageAccount,
24
25
  analyzeVM,
25
26
  } from "../resources/index.js";
27
+ import { createAdvisorAnalyzer } from "./advisor.js";
26
28
 
27
29
  /**
28
30
  * Builds the default set of analyzers in the same order they were
@@ -182,3 +184,13 @@ export function createDefaultAnalyzers(): Analyzer[] {
182
184
  },
183
185
  ];
184
186
  }
187
+
188
+ /**
189
+ * Builds the default set of subscription-level analyzers.
190
+ *
191
+ * Today this is just Azure Advisor; Phase 4 will add a quota / usages
192
+ * analyzer here. Adding new sources is a single insertion.
193
+ */
194
+ export function createDefaultSubscriptionAnalyzers(): SubscriptionAnalyzer[] {
195
+ return [createAdvisorAnalyzer()];
196
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Subscription-level analyzer plugin contract.
3
+ *
4
+ * Some data sources are not naturally per-resource. Azure Advisor, for
5
+ * example, returns a flat list of recommendations for an entire
6
+ * subscription in a single call; future quota / `Microsoft.Capacity/usages`
7
+ * analyzers will follow the same shape.
8
+ *
9
+ * A `SubscriptionAnalyzer` is invoked once per subscription. It returns a
10
+ * flat list of `Finding`s carrying the `resourceId` they refer to; the
11
+ * orchestrator merges those findings into the per-resource reports.
12
+ *
13
+ * This interface is intentionally additive: per-resource `Analyzer`s
14
+ * (see `./types.ts`) keep working untouched. Sources written against the
15
+ * two interfaces can coexist in the same run.
16
+ */
17
+
18
+ import type { TokenCredential } from "@azure/identity";
19
+
20
+ import type { Finding } from "../../finding.js";
21
+
22
+ /**
23
+ * Contract every subscription-level analyzer must satisfy.
24
+ */
25
+ export type SubscriptionAnalyzer = {
26
+ /**
27
+ * Runs the analyzer for the given subscription. Implementations should
28
+ * be resilient: a failure here must not abort the whole run, the
29
+ * orchestrator logs and continues with the remaining analyzers.
30
+ */
31
+ analyze(ctx: SubscriptionContext): Promise<Finding[]>;
32
+ /**
33
+ * Stable identifier of the analyzer (e.g. `azure.advisor`,
34
+ * `azure.quota`). Used for logging and future deduplication logic.
35
+ */
36
+ readonly id: string;
37
+ };
38
+
39
+ /**
40
+ * Context passed to every subscription-level analyzer.
41
+ */
42
+ export type SubscriptionContext = {
43
+ /**
44
+ * Azure credential to instantiate management clients. The orchestrator
45
+ * builds it once and reuses it across analyzers.
46
+ */
47
+ credential: TokenCredential;
48
+ /**
49
+ * Subscription ID to analyze.
50
+ */
51
+ subscriptionId: string;
52
+ /**
53
+ * Whether verbose logging is enabled.
54
+ */
55
+ verbose: boolean;
56
+ };
@@ -44,6 +44,7 @@ export async function loadAzureConfig(
44
44
  return {
45
45
  concurrency: parsed.azure.concurrency,
46
46
  preferredLocation: parsed.azure.preferredLocation,
47
+ sources: parsed.azure.sources,
47
48
  subscriptionIds: parsed.azure.subscriptionIds,
48
49
  thresholds: parsed.azure.thresholds,
49
50
  timespanDays: parsed.azure.timespanDays,
@@ -6,4 +6,5 @@
6
6
 
7
7
  export * from "./analyzer.js";
8
8
  export * from "./config.js";
9
+ export * from "./report.js";
9
10
  export * from "./types.js";
@@ -2,16 +2,25 @@
2
2
  * Azure report generation
3
3
  */
4
4
 
5
+ import Table from "cli-table3";
6
+
7
+ import type { Money } from "../finding.js";
5
8
  import type {
6
9
  AzureDetailedResourceReport,
7
10
  AzureResourceReport,
8
11
  } from "./types.js";
9
12
 
13
+ // Fixed column widths — keeps the Reason column within a readable width
14
+ // while allowing cli-table3 to word-wrap long content across multiple lines.
15
+ const COL_WIDTHS = [30, 35, 25, 8, 55] as const;
16
+
10
17
  // ANSI color codes — only applied when stdout is a TTY to avoid cluttering redirected output
11
18
  const isTTY = process.stdout.isTTY ?? false;
12
19
  const RED = isTTY ? "\x1b[31m" : "";
13
20
  const YELLOW = isTTY ? "\x1b[33m" : "";
14
21
  const BLUE = isTTY ? "\x1b[34m" : "";
22
+ const GREEN = isTTY ? "\x1b[32m" : "";
23
+ const CYAN = isTTY ? "\x1b[36m" : "";
15
24
  const BOLD = isTTY ? "\x1b[1m" : "";
16
25
  const RESET = isTTY ? "\x1b[0m" : "";
17
26
  const DIM = isTTY ? "\x1b[2m" : "";
@@ -28,6 +37,12 @@ const RISK_COLOR = {
28
37
  medium: YELLOW,
29
38
  } as const;
30
39
 
40
+ type Summary = {
41
+ counts: { high: number; low: number; medium: number };
42
+ savingsByCurrency: Map<string, number>;
43
+ sourceCounts: Record<string, number>;
44
+ };
45
+
31
46
  /**
32
47
  * Generates a report from Azure resource analysis in the specified format.
33
48
  *
@@ -43,77 +58,233 @@ export async function generateReport(
43
58
  return;
44
59
  }
45
60
 
46
- // For other formats, we extract the summary data.
47
- const summaryReport: AzureResourceReport[] = report.map((r) => ({
48
- costRisk: r.analysis.costRisk,
49
- location: r.resource.location ?? "",
50
- name: r.resource.name ?? "unknown",
51
- reason: r.analysis.reason,
52
- resourceGroup: r.resource.id?.split("/")[4],
53
- subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
54
- suspectedUnused: r.analysis.suspectedUnused,
55
- type: r.resource.type ?? "unknown",
56
- }));
57
-
58
61
  if (format === "json") {
62
+ const summaryReport: AzureResourceReport[] = report.map((r) => ({
63
+ costRisk: r.analysis.costRisk,
64
+ location: r.resource.location ?? "",
65
+ name: r.resource.name ?? "unknown",
66
+ reason: r.analysis.reason,
67
+ resourceGroup: r.resource.id?.split("/")[4],
68
+ subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
69
+ suspectedUnused: r.analysis.suspectedUnused,
70
+ type: r.resource.type ?? "unknown",
71
+ }));
59
72
  console.log(JSON.stringify(summaryReport, null, 2));
60
73
  } else if (format === "lint") {
61
74
  generateLintReport(report);
62
75
  } else {
63
- console.table(
64
- summaryReport.map((r) => ({
65
- Name: r.name,
66
- Reason: r.reason,
67
- "Resource Group": r.resourceGroup || "N/A",
68
- Risk: r.costRisk,
69
- Type: r.type,
70
- })),
71
- ["Name", "Type", "Resource Group", "Risk", "Reason"],
72
- );
76
+ const summaryReport: AzureResourceReport[] = report.map((r) => ({
77
+ costRisk: r.analysis.costRisk,
78
+ location: r.resource.location ?? "",
79
+ name: r.resource.name ?? "unknown",
80
+ reason: r.analysis.reason,
81
+ resourceGroup: r.resource.id?.split("/")[4],
82
+ subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
83
+ suspectedUnused: r.analysis.suspectedUnused,
84
+ type: r.resource.type ?? "unknown",
85
+ }));
86
+ const table = new Table({
87
+ colWidths: [...COL_WIDTHS],
88
+ head: ["Name", "Type", "Resource Group", "Risk", "Reason"],
89
+ wordWrap: true,
90
+ });
91
+ for (const r of summaryReport) {
92
+ table.push([
93
+ r.name,
94
+ r.type,
95
+ r.resourceGroup || "N/A",
96
+ r.costRisk,
97
+ r.reason,
98
+ ]);
99
+ }
100
+ console.log(table.toString());
101
+ // Same summary block the lint format prints, so the table view also
102
+ // surfaces totals, source breakdown and estimated savings at a glance.
103
+ printSummary(computeSummary(report));
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Walks the report once to compute totals, source breakdown and savings
109
+ * per currency without mutating it. Used by the table format, which has
110
+ * no per-line walk of its own.
111
+ */
112
+ function computeSummary(report: AzureDetailedResourceReport[]): Summary {
113
+ const summary: Summary = {
114
+ counts: { high: 0, low: 0, medium: 0 },
115
+ savingsByCurrency: new Map(),
116
+ sourceCounts: {},
117
+ };
118
+ for (const entry of report) {
119
+ const lines = entry.findings?.length
120
+ ? entry.findings.map((f) => ({
121
+ savings: f.estimatedMonthlySavings,
122
+ severity: f.severity,
123
+ source: f.source,
124
+ }))
125
+ : splitReasons(entry.analysis.reason).map(() => ({
126
+ savings: undefined,
127
+ severity: entry.analysis.costRisk,
128
+ source: "custom" as const,
129
+ }));
130
+ for (const line of lines) {
131
+ summary.counts[line.severity]++;
132
+ summary.sourceCounts[line.source] =
133
+ (summary.sourceCounts[line.source] ?? 0) + 1;
134
+ if (line.savings) {
135
+ summary.savingsByCurrency.set(
136
+ line.savings.currency,
137
+ (summary.savingsByCurrency.get(line.savings.currency) ?? 0) +
138
+ line.savings.amount,
139
+ );
140
+ }
141
+ }
142
+ }
143
+ return summary;
144
+ }
145
+
146
+ /**
147
+ * Formats a monetary amount with an ISO 4217 currency code, falling back
148
+ * to the raw code prefix when the runtime locale data lacks the currency
149
+ * symbol.
150
+ */
151
+ function formatAmount(amount: number, currency: string): string {
152
+ try {
153
+ return new Intl.NumberFormat("en-US", {
154
+ currency,
155
+ maximumFractionDigits: 2,
156
+ minimumFractionDigits: 2,
157
+ style: "currency",
158
+ }).format(amount);
159
+ } catch {
160
+ return `${currency} ${amount.toFixed(2)}`;
73
161
  }
74
162
  }
75
163
 
164
+ /**
165
+ * Renders an estimated monthly savings value as the short "(€ 12.50/mo)"
166
+ * label that ships next to each lint line. Returns an empty string when
167
+ * the analyzer didn't provide an estimate.
168
+ */
169
+ function formatSavings(savings: Money | undefined): string {
170
+ if (!savings) return "";
171
+ return `(${formatAmount(savings.amount, savings.currency)}/mo)`;
172
+ }
173
+
76
174
  /**
77
175
  * Renders a linter-style report to stdout, grouping findings by resource.
78
176
  *
177
+ * When `Finding[]` is attached to a report (Phase 1+), each finding is
178
+ * rendered with its `source` badge (e.g. `[advisor]`) and the estimated
179
+ * monthly savings, when known. For older payloads without `findings` we
180
+ * fall back to splitting `analysis.reason` like before so the format stays
181
+ * backward compatible.
182
+ *
79
183
  * Example output:
80
184
  *
81
185
  * /subscriptions/.../virtualMachines/my-vm
82
- * ✖ HIGH VM is deallocated.
83
- * ✖ HIGH No tags found.
186
+ * ✖ HIGH [advisor] Right-size your VM. (€ 12.50/mo)
187
+ * ✖ HIGH [custom] No tags found.
84
188
  *
85
189
  * Summary: 3 issues found (2 high, 0 medium, 1 low)
190
+ * Estimated monthly savings: € 12.50
86
191
  */
87
192
  function generateLintReport(report: AzureDetailedResourceReport[]): void {
88
- const counts = { high: 0, low: 0, medium: 0 };
193
+ const summary = {
194
+ counts: { high: 0, low: 0, medium: 0 },
195
+ savingsByCurrency: new Map<string, number>(),
196
+ sourceCounts: {} as Record<string, number>,
197
+ };
89
198
 
90
199
  for (const entry of report) {
91
200
  const resourceId =
92
201
  entry.resource.id ?? `unknown/${entry.resource.name ?? "unknown"}`;
93
- const risk = entry.analysis.costRisk;
94
- const findings = splitReasons(entry.analysis.reason);
95
-
96
202
  console.log(`${BOLD}${resourceId}${RESET}`);
97
203
 
98
- for (const finding of findings) {
99
- const icon = RISK_ICON[risk];
100
- const color = RISK_COLOR[risk];
101
- const label = `${color}${risk.toUpperCase().padEnd(6)}${RESET}`;
102
- console.log(` ${icon} ${label} ${DIM}${finding}${RESET}`);
103
- counts[risk]++;
204
+ const lines = entry.findings?.length
205
+ ? entry.findings.map((f) => ({
206
+ extra: formatSavings(f.estimatedMonthlySavings),
207
+ severity: f.severity,
208
+ source: f.source,
209
+ text: f.reason,
210
+ }))
211
+ : splitReasons(entry.analysis.reason).map((text) => ({
212
+ extra: "",
213
+ severity: entry.analysis.costRisk,
214
+ source: "custom" as const,
215
+ text,
216
+ }));
217
+
218
+ for (const line of lines) {
219
+ const icon = RISK_ICON[line.severity];
220
+ const color = RISK_COLOR[line.severity];
221
+ const label = `${color}${line.severity.toUpperCase().padEnd(6)}${RESET}`;
222
+ const sourceBadge = `${CYAN}[${line.source}]${RESET}`;
223
+ const extra = line.extra ? ` ${GREEN}${line.extra}${RESET}` : "";
224
+ console.log(
225
+ ` ${icon} ${label} ${sourceBadge} ${DIM}${line.text}${RESET}${extra}`,
226
+ );
227
+ summary.counts[line.severity]++;
228
+ summary.sourceCounts[line.source] =
229
+ (summary.sourceCounts[line.source] ?? 0) + 1;
230
+ }
231
+
232
+ if (entry.findings) {
233
+ for (const f of entry.findings) {
234
+ if (f.estimatedMonthlySavings) {
235
+ const { amount, currency } = f.estimatedMonthlySavings;
236
+ summary.savingsByCurrency.set(
237
+ currency,
238
+ (summary.savingsByCurrency.get(currency) ?? 0) + amount,
239
+ );
240
+ }
241
+ }
104
242
  }
105
243
 
106
244
  console.log();
107
245
  }
108
246
 
247
+ printSummary(summary);
248
+ }
249
+
250
+ /**
251
+ * Prints the shared trailer (issues, sources, estimated savings) used by
252
+ * both the lint and the table format.
253
+ *
254
+ * Savings are kept grouped by currency intentionally: Azure Advisor
255
+ * returns each recommendation in the subscription's native billing
256
+ * currency, so the same report can carry EUR and USD figures at the
257
+ * same time when subscriptions are billed in different regions. We do
258
+ * NOT convert across currencies — the rates would be both volatile and
259
+ * out of scope for this tool.
260
+ */
261
+ function printSummary(summary: Summary): void {
262
+ const { counts, savingsByCurrency, sourceCounts } = summary;
109
263
  const total = counts.high + counts.medium + counts.low;
110
264
  const summaryLine =
111
265
  `${BOLD}Summary:${RESET} ${total} issue${total !== 1 ? "s" : ""} found` +
112
266
  ` ${RED}(${counts.high} high${RESET}` +
113
267
  `, ${YELLOW}${counts.medium} medium${RESET}` +
114
268
  `, ${BLUE}${counts.low} low${RESET})`;
115
-
116
269
  console.log(summaryLine);
270
+
271
+ const sourceBreakdown = Object.entries(sourceCounts)
272
+ .sort(([a], [b]) => a.localeCompare(b))
273
+ .map(([source, n]) => `${n} ${source}`)
274
+ .join(", ");
275
+ if (sourceBreakdown) {
276
+ console.log(`${BOLD}Sources:${RESET} ${sourceBreakdown}`);
277
+ }
278
+
279
+ if (savingsByCurrency.size > 0) {
280
+ const parts = [...savingsByCurrency.entries()].map(
281
+ ([currency, amount]) =>
282
+ `${GREEN}${formatAmount(amount, currency)}${RESET}`,
283
+ );
284
+ console.log(
285
+ `${BOLD}Estimated monthly savings:${RESET} ${parts.join(", ")}`,
286
+ );
287
+ }
117
288
  }
118
289
 
119
290
  /**
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type * as armResources from "@azure/arm-resources";
6
6
 
7
+ import type { Finding } from "../finding.js";
7
8
  import type {
8
9
  AnalysisResult,
9
10
  BaseConfig,
@@ -26,6 +27,14 @@ export type AzureConfig = BaseConfig & {
26
27
  * If omitted, all resources are analyzed.
27
28
  */
28
29
  filterTags?: Map<string, string>;
30
+ /**
31
+ * Which finding sources to include in the run.
32
+ * - `"custom"` → enables the per-resource analyzer plugins
33
+ * - `"advisor"` → enables the Azure Advisor subscription-level analyzer
34
+ *
35
+ * Defaults to `["advisor", "custom"]` when omitted (i.e. all sources).
36
+ */
37
+ sources?: AzureSource[];
29
38
  subscriptionIds: string[];
30
39
  /**
31
40
  * Analysis thresholds. Defaults from DEFAULT_THRESHOLDS are used when not provided.
@@ -35,10 +44,22 @@ export type AzureConfig = BaseConfig & {
35
44
  };
36
45
 
37
46
  /**
38
- * Detailed report for a single Azure resource with full resource object
47
+ * Detailed report for a single Azure resource with full resource object.
48
+ *
49
+ * Phase 1 introduces the optional `findings` field carrying the unified
50
+ * `Finding[]` model alongside the legacy `analysis` summary. The two are
51
+ * kept in sync by the orchestrator so existing report formats keep
52
+ * working untouched while new consumers (GUI, JSON exports, Phase 2
53
+ * pricing aggregation) can read structured findings directly.
39
54
  */
40
55
  export type AzureDetailedResourceReport = {
41
56
  analysis: AnalysisResult;
57
+ /**
58
+ * Structured findings attached to the resource. Always populated by the
59
+ * orchestrator (possibly empty). Optional only for backward compatibility
60
+ * with serialised payloads produced before Phase 1.
61
+ */
62
+ findings?: Finding[];
42
63
  resource: armResources.GenericResource;
43
64
  };
44
65
 
@@ -55,3 +76,10 @@ export type AzureResourceReport = {
55
76
  suspectedUnused: boolean;
56
77
  type: string;
57
78
  };
79
+
80
+ /**
81
+ * Finding sources that are valid for Azure analysis.
82
+ * Narrowed from `FindingSource` to exclude "aws", which is not a valid
83
+ * filter for Azure runs and would silently produce an empty report.
84
+ */
85
+ export type AzureSource = "advisor" | "custom";
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export {
21
21
  } from "./azure/analyzers/index.js";
22
22
 
23
23
  // Export common types
24
- export type { AzureConfig } from "./azure/types.js";
24
+ export type { AzureConfig, AzureSource } from "./azure/types.js";
25
25
 
26
26
  // Export Azure module
27
27
  import * as azureModule from "./azure/index.js";
package/src/schema.ts CHANGED
@@ -111,6 +111,15 @@ const AzureSectionSchema = z
111
111
  */
112
112
  concurrency: z.number().int().positive().optional(),
113
113
  preferredLocation: z.string().default("italynorth"),
114
+ /**
115
+ * Which finding sources to include. Defaults to all known sources.
116
+ * Authors can narrow the run to e.g. `["advisor"]` to fetch only
117
+ * Azure Advisor recommendations, or `["custom"]` to skip Advisor.
118
+ */
119
+ sources: z
120
+ .array(z.enum(["advisor", "custom"]))
121
+ .nonempty()
122
+ .default(["advisor", "custom"]),
114
123
  subscriptionIds: z
115
124
  .array(z.string())
116
125
  .min(