@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
|
@@ -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
|
+
};
|
package/src/azure/config.ts
CHANGED
|
@@ -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,
|
package/src/azure/index.ts
CHANGED
package/src/azure/report.ts
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
/**
|
package/src/azure/types.ts
CHANGED
|
@@ -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(
|