@pagopa/dx-savemoney 0.2.5 → 0.2.6
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/dist/azure/analyzer.d.ts +31 -16
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +110 -81
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/index.d.ts +6 -0
- package/dist/azure/analyzers/index.d.ts.map +1 -0
- package/dist/azure/analyzers/index.js +5 -0
- package/dist/azure/analyzers/index.js.map +1 -0
- package/dist/azure/analyzers/registry.d.ts +21 -0
- package/dist/azure/analyzers/registry.d.ts.map +1 -0
- package/dist/azure/analyzers/registry.js +69 -0
- package/dist/azure/analyzers/registry.js.map +1 -0
- package/dist/azure/analyzers/types.d.ts +62 -0
- package/dist/azure/analyzers/types.d.ts.map +1 -0
- package/dist/azure/analyzers/types.js +15 -0
- package/dist/azure/analyzers/types.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/resources/app-service.d.ts +2 -1
- package/dist/azure/resources/app-service.d.ts.map +1 -1
- package/dist/azure/resources/app-service.js +3 -3
- package/dist/azure/resources/app-service.js.map +1 -1
- package/dist/azure/resources/container-app.d.ts +2 -1
- package/dist/azure/resources/container-app.d.ts.map +1 -1
- package/dist/azure/resources/container-app.js +9 -9
- package/dist/azure/resources/container-app.js.map +1 -1
- package/dist/azure/resources/public-ip.d.ts +2 -1
- package/dist/azure/resources/public-ip.d.ts.map +1 -1
- package/dist/azure/resources/public-ip.js +2 -2
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/resources/static-web-app.d.ts +2 -1
- package/dist/azure/resources/static-web-app.d.ts.map +1 -1
- package/dist/azure/resources/static-web-app.js +3 -3
- package/dist/azure/resources/static-web-app.js.map +1 -1
- package/dist/azure/resources/storage.d.ts +2 -1
- package/dist/azure/resources/storage.d.ts.map +1 -1
- package/dist/azure/resources/storage.js +2 -2
- package/dist/azure/resources/storage.js.map +1 -1
- package/dist/azure/resources/vm.d.ts +2 -1
- package/dist/azure/resources/vm.d.ts.map +1 -1
- package/dist/azure/resources/vm.js +3 -3
- package/dist/azure/resources/vm.js.map +1 -1
- package/dist/azure/types.d.ts +6 -0
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/azure/utils.d.ts +35 -3
- package/dist/azure/utils.d.ts.map +1 -1
- package/dist/azure/utils.js +70 -29
- package/dist/azure/utils.js.map +1 -1
- package/dist/finding.d.ts +114 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +51 -0
- package/dist/finding.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +5 -0
- package/dist/schema.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/finding.test.ts +149 -0
- package/src/azure/__tests__/utils.test.ts +164 -2
- package/src/azure/analyzer.ts +140 -165
- package/src/azure/analyzers/index.ts +6 -0
- package/src/azure/analyzers/registry.ts +184 -0
- package/src/azure/analyzers/types.ts +66 -0
- package/src/azure/config.ts +1 -0
- package/src/azure/resources/app-service.ts +4 -0
- package/src/azure/resources/container-app.ts +10 -0
- package/src/azure/resources/public-ip.ts +3 -0
- package/src/azure/resources/static-web-app.ts +4 -0
- package/src/azure/resources/storage.ts +3 -0
- package/src/azure/resources/vm.ts +4 -0
- package/src/azure/types.ts +6 -0
- package/src/azure/utils.ts +110 -39
- package/src/finding.ts +152 -0
- package/src/index.ts +18 -0
- package/src/schema.ts +5 -0
|
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
|
13
13
|
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
14
14
|
import {
|
|
15
15
|
getMetric,
|
|
16
|
+
type MetricsCache,
|
|
16
17
|
verboseLog,
|
|
17
18
|
verboseLogAnalysisResult,
|
|
18
19
|
verboseLogResourceStart,
|
|
@@ -35,6 +36,7 @@ export async function analyzeContainerApp(
|
|
|
35
36
|
timespanDays: number,
|
|
36
37
|
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
37
38
|
verbose = false,
|
|
39
|
+
cache?: MetricsCache,
|
|
38
40
|
): Promise<AnalysisResult> {
|
|
39
41
|
verboseLogResourceStart(
|
|
40
42
|
verbose,
|
|
@@ -84,6 +86,7 @@ export async function analyzeContainerApp(
|
|
|
84
86
|
thresholds,
|
|
85
87
|
reason,
|
|
86
88
|
verbose,
|
|
89
|
+
cache,
|
|
87
90
|
);
|
|
88
91
|
reason = await checkNetworkMetrics(
|
|
89
92
|
resource,
|
|
@@ -92,6 +95,7 @@ export async function analyzeContainerApp(
|
|
|
92
95
|
thresholds,
|
|
93
96
|
reason,
|
|
94
97
|
verbose,
|
|
98
|
+
cache,
|
|
95
99
|
);
|
|
96
100
|
} catch (error) {
|
|
97
101
|
const logger = getLogger(["savemoney", "azure"]);
|
|
@@ -117,6 +121,7 @@ async function checkNetworkMetrics(
|
|
|
117
121
|
thresholds: Thresholds,
|
|
118
122
|
reason: string,
|
|
119
123
|
verbose: boolean,
|
|
124
|
+
cache?: MetricsCache,
|
|
120
125
|
): Promise<string> {
|
|
121
126
|
let newReason = reason;
|
|
122
127
|
|
|
@@ -132,6 +137,7 @@ async function checkNetworkMetrics(
|
|
|
132
137
|
"RxBytes",
|
|
133
138
|
"Average",
|
|
134
139
|
timespanDays,
|
|
140
|
+
cache,
|
|
135
141
|
);
|
|
136
142
|
|
|
137
143
|
const networkOut = await getMetric(
|
|
@@ -140,6 +146,7 @@ async function checkNetworkMetrics(
|
|
|
140
146
|
"TxBytes",
|
|
141
147
|
"Average",
|
|
142
148
|
timespanDays,
|
|
149
|
+
cache,
|
|
143
150
|
);
|
|
144
151
|
|
|
145
152
|
verboseLog(
|
|
@@ -172,6 +179,7 @@ async function checkResourceMetrics(
|
|
|
172
179
|
thresholds: Thresholds,
|
|
173
180
|
reason: string,
|
|
174
181
|
verbose: boolean,
|
|
182
|
+
cache?: MetricsCache,
|
|
175
183
|
): Promise<string> {
|
|
176
184
|
let newReason = reason;
|
|
177
185
|
|
|
@@ -187,6 +195,7 @@ async function checkResourceMetrics(
|
|
|
187
195
|
"UsageNanoCores",
|
|
188
196
|
"Average",
|
|
189
197
|
timespanDays,
|
|
198
|
+
cache,
|
|
190
199
|
);
|
|
191
200
|
|
|
192
201
|
const memoryUsage = await getMetric(
|
|
@@ -195,6 +204,7 @@ async function checkResourceMetrics(
|
|
|
195
204
|
"WorkingSetBytes",
|
|
196
205
|
"Average",
|
|
197
206
|
timespanDays,
|
|
207
|
+
cache,
|
|
198
208
|
);
|
|
199
209
|
|
|
200
210
|
verboseLog(
|
|
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
|
13
13
|
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
14
14
|
import {
|
|
15
15
|
getMetric,
|
|
16
|
+
type MetricsCache,
|
|
16
17
|
verboseLog,
|
|
17
18
|
verboseLogAnalysisResult,
|
|
18
19
|
verboseLogResourceStart,
|
|
@@ -34,6 +35,7 @@ export async function analyzePublicIp(
|
|
|
34
35
|
timespanDays: number,
|
|
35
36
|
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
36
37
|
verbose = false,
|
|
38
|
+
cache?: MetricsCache,
|
|
37
39
|
): Promise<AnalysisResult> {
|
|
38
40
|
verboseLogResourceStart(
|
|
39
41
|
verbose,
|
|
@@ -87,6 +89,7 @@ export async function analyzePublicIp(
|
|
|
87
89
|
"BytesInDDoS",
|
|
88
90
|
"Average",
|
|
89
91
|
timespanDays,
|
|
92
|
+
cache,
|
|
90
93
|
);
|
|
91
94
|
|
|
92
95
|
if (bytesInDDoS !== null && bytesInDDoS < thresholds.publicIp.bytesInDDoS) {
|
|
@@ -12,6 +12,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
|
12
12
|
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
13
13
|
import {
|
|
14
14
|
getMetric,
|
|
15
|
+
type MetricsCache,
|
|
15
16
|
verboseLog,
|
|
16
17
|
verboseLogAnalysisResult,
|
|
17
18
|
verboseLogResourceStart,
|
|
@@ -32,6 +33,7 @@ export async function analyzeStaticSite(
|
|
|
32
33
|
timespanDays: number,
|
|
33
34
|
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
34
35
|
verbose = false,
|
|
36
|
+
cache?: MetricsCache,
|
|
35
37
|
): Promise<AnalysisResult> {
|
|
36
38
|
verboseLogResourceStart(
|
|
37
39
|
verbose,
|
|
@@ -63,6 +65,7 @@ export async function analyzeStaticSite(
|
|
|
63
65
|
"SiteHits",
|
|
64
66
|
"Total",
|
|
65
67
|
timespanDays,
|
|
68
|
+
cache,
|
|
66
69
|
);
|
|
67
70
|
|
|
68
71
|
const bytesSent = await getMetric(
|
|
@@ -71,6 +74,7 @@ export async function analyzeStaticSite(
|
|
|
71
74
|
"BytesSent",
|
|
72
75
|
"Total",
|
|
73
76
|
timespanDays,
|
|
77
|
+
cache,
|
|
74
78
|
);
|
|
75
79
|
|
|
76
80
|
verboseLog(
|
|
@@ -11,6 +11,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
|
11
11
|
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
12
12
|
import {
|
|
13
13
|
getMetric,
|
|
14
|
+
type MetricsCache,
|
|
14
15
|
verboseLog,
|
|
15
16
|
verboseLogAnalysisResult,
|
|
16
17
|
verboseLogResourceStart,
|
|
@@ -30,6 +31,7 @@ export async function analyzeStorageAccount(
|
|
|
30
31
|
timespanDays: number,
|
|
31
32
|
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
32
33
|
verbose = false,
|
|
34
|
+
cache?: MetricsCache,
|
|
33
35
|
): Promise<AnalysisResult> {
|
|
34
36
|
verboseLogResourceStart(
|
|
35
37
|
verbose,
|
|
@@ -52,6 +54,7 @@ export async function analyzeStorageAccount(
|
|
|
52
54
|
"Transactions",
|
|
53
55
|
"Average",
|
|
54
56
|
timespanDays,
|
|
57
|
+
cache,
|
|
55
58
|
);
|
|
56
59
|
if (
|
|
57
60
|
transactions !== null &&
|
|
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
|
13
13
|
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
14
14
|
import {
|
|
15
15
|
getMetric,
|
|
16
|
+
type MetricsCache,
|
|
16
17
|
verboseLog,
|
|
17
18
|
verboseLogAnalysisResult,
|
|
18
19
|
verboseLogResourceStart,
|
|
@@ -35,6 +36,7 @@ export async function analyzeVM(
|
|
|
35
36
|
timespanDays: number,
|
|
36
37
|
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
37
38
|
verbose = false,
|
|
39
|
+
cache?: MetricsCache,
|
|
38
40
|
): Promise<AnalysisResult> {
|
|
39
41
|
verboseLogResourceStart(
|
|
40
42
|
verbose,
|
|
@@ -107,6 +109,7 @@ export async function analyzeVM(
|
|
|
107
109
|
"Percentage CPU",
|
|
108
110
|
"Average",
|
|
109
111
|
timespanDays,
|
|
112
|
+
cache,
|
|
110
113
|
);
|
|
111
114
|
const networkIn = await getMetric(
|
|
112
115
|
monitorClient,
|
|
@@ -114,6 +117,7 @@ export async function analyzeVM(
|
|
|
114
117
|
"Network In Total",
|
|
115
118
|
"Average",
|
|
116
119
|
timespanDays,
|
|
120
|
+
cache,
|
|
117
121
|
);
|
|
118
122
|
|
|
119
123
|
if (cpuUsage !== null && cpuUsage < thresholds.vm.cpuPercent) {
|
package/src/azure/types.ts
CHANGED
|
@@ -15,6 +15,12 @@ import type {
|
|
|
15
15
|
* Azure configuration extending base config
|
|
16
16
|
*/
|
|
17
17
|
export type AzureConfig = BaseConfig & {
|
|
18
|
+
/**
|
|
19
|
+
* Maximum number of resources analyzed in parallel within a single
|
|
20
|
+
* subscription. Defaults to 8 when not provided. Set to 1 for a fully
|
|
21
|
+
* sequential run (useful for debugging or to be gentler on quotas).
|
|
22
|
+
*/
|
|
23
|
+
concurrency?: number;
|
|
18
24
|
/**
|
|
19
25
|
* Only analyze resources that match ALL the given tag key-value pairs.
|
|
20
26
|
* If omitted, all resources are analyzed.
|
package/src/azure/utils.ts
CHANGED
|
@@ -9,6 +9,19 @@ import { getLogger } from "@logtape/logtape";
|
|
|
9
9
|
|
|
10
10
|
import type { AnalysisResult } from "../types.js";
|
|
11
11
|
|
|
12
|
+
/** Per-run in-memory cache for Azure Monitor metric responses. */
|
|
13
|
+
export type MetricsCache = Map<string, Promise<null | number>>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal interface required by `getMetric` — only the `metrics.list` shape.
|
|
17
|
+
* Using a structural type instead of the full `MonitorClient` keeps tests
|
|
18
|
+
* strongly typed without unsafe casts and lets non-Azure callers supply a
|
|
19
|
+
* compatible mock.
|
|
20
|
+
*/
|
|
21
|
+
export type MonitorClientLike = {
|
|
22
|
+
metrics: Pick<MonitorClient["metrics"], "list">;
|
|
23
|
+
};
|
|
24
|
+
|
|
12
25
|
type MetricDataPoint = {
|
|
13
26
|
average?: number;
|
|
14
27
|
count?: number;
|
|
@@ -104,59 +117,61 @@ export function extractAggregatedValue(
|
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
/**
|
|
107
|
-
*
|
|
120
|
+
* Module-scoped fallback cache. Used when callers of `getMetric` do not
|
|
121
|
+
* supply a run-scoped cache. Prefer passing an explicit `MetricsCache`
|
|
122
|
+
* instance through `AnalyzerContext` so concurrent runs stay isolated.
|
|
123
|
+
*/
|
|
124
|
+
const metricsCache: MetricsCache = new Map();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @internal — exposed for tests only.
|
|
128
|
+
*/
|
|
129
|
+
export function _metricsCacheSize(): number {
|
|
130
|
+
return metricsCache.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetches a specific metric for a resource from Azure Monitor, with an
|
|
135
|
+
* in-memory cache to deduplicate concurrent and repeated lookups within
|
|
136
|
+
* the same run.
|
|
137
|
+
*
|
|
138
|
+
* Concurrent callers for the same `(resourceId, metricName, aggregation,
|
|
139
|
+
* timespanDays)` tuple share the same underlying request.
|
|
108
140
|
*
|
|
109
|
-
*
|
|
141
|
+
* Pass an explicit `cache` (created per run in the orchestrator) to keep
|
|
142
|
+
* concurrent analysis runs isolated from each other. When omitted, the
|
|
143
|
+
* module-scoped fallback cache is used — safe for sequential runs.
|
|
144
|
+
*
|
|
145
|
+
* @param monitorClient - Azure Monitor client (or compatible mock)
|
|
110
146
|
* @param resourceId - The Azure resource ID
|
|
111
147
|
* @param metricName - The name of the metric to fetch (e.g., "Percentage CPU")
|
|
112
148
|
* @param aggregation - The aggregation type (e.g., "Average", "Total")
|
|
113
149
|
* @param timespanDays - Number of days to look back for metrics
|
|
150
|
+
* @param cache - Optional run-scoped cache; falls back to the module-scoped one
|
|
114
151
|
* @returns The metric value or null if unavailable
|
|
115
152
|
*/
|
|
116
153
|
export async function getMetric(
|
|
117
|
-
monitorClient:
|
|
154
|
+
monitorClient: MonitorClientLike,
|
|
118
155
|
resourceId: string,
|
|
119
156
|
metricName: string,
|
|
120
157
|
aggregation: string,
|
|
121
158
|
timespanDays: number,
|
|
159
|
+
cache: MetricsCache = metricsCache,
|
|
122
160
|
): Promise<null | number> {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
metricnames: metricName,
|
|
128
|
-
timespan,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
if (result.value.length === 0) {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const metric = result.value[0];
|
|
136
|
-
|
|
137
|
-
if (!metric.timeseries || metric.timeseries.length === 0) {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const timeserie = metric.timeseries[0];
|
|
142
|
-
|
|
143
|
-
if (!timeserie.data || timeserie.data.length === 0) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const aggregatedValue = aggregateDataPoints(
|
|
148
|
-
timeserie.data as MetricDataPoint[],
|
|
149
|
-
aggregation,
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
return aggregatedValue;
|
|
153
|
-
} catch (error) {
|
|
154
|
-
const logger = getLogger(["savemoney", "azure", "metrics"]);
|
|
155
|
-
logger.error(
|
|
156
|
-
`Failed to fetch metric ${metricName} for resource ${resourceId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
157
|
-
);
|
|
158
|
-
return null;
|
|
161
|
+
const key = `${resourceId}|${metricName}|${aggregation}|${timespanDays}`;
|
|
162
|
+
const cached = cache.get(key);
|
|
163
|
+
if (cached !== undefined) {
|
|
164
|
+
return cached;
|
|
159
165
|
}
|
|
166
|
+
const promise = fetchMetric(
|
|
167
|
+
monitorClient,
|
|
168
|
+
resourceId,
|
|
169
|
+
metricName,
|
|
170
|
+
aggregation,
|
|
171
|
+
timespanDays,
|
|
172
|
+
);
|
|
173
|
+
cache.set(key, promise);
|
|
174
|
+
return promise;
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
/**
|
|
@@ -179,6 +194,16 @@ export function matchesTags(
|
|
|
179
194
|
);
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Clears the module-scoped fallback metrics cache.
|
|
199
|
+
*
|
|
200
|
+
* Only needed when `getMetric` is called without an explicit `cache`
|
|
201
|
+
* argument. Prefer passing a run-scoped `MetricsCache` instead.
|
|
202
|
+
*/
|
|
203
|
+
export function resetMetricsCache(): void {
|
|
204
|
+
metricsCache.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
182
207
|
/**
|
|
183
208
|
* Logs a verbose message, optionally with an object.
|
|
184
209
|
*
|
|
@@ -243,3 +268,49 @@ export function verboseLogResourceStart(
|
|
|
243
268
|
logger.debug("=".repeat(80));
|
|
244
269
|
}
|
|
245
270
|
}
|
|
271
|
+
|
|
272
|
+
async function fetchMetric(
|
|
273
|
+
monitorClient: MonitorClientLike,
|
|
274
|
+
resourceId: string,
|
|
275
|
+
metricName: string,
|
|
276
|
+
aggregation: string,
|
|
277
|
+
timespanDays: number,
|
|
278
|
+
): Promise<null | number> {
|
|
279
|
+
try {
|
|
280
|
+
const timespan = `P${timespanDays}D`;
|
|
281
|
+
const result = await monitorClient.metrics.list(resourceId, {
|
|
282
|
+
aggregation,
|
|
283
|
+
metricnames: metricName,
|
|
284
|
+
timespan,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (result.value.length === 0) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const metric = result.value[0];
|
|
292
|
+
|
|
293
|
+
if (!metric.timeseries || metric.timeseries.length === 0) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const timeserie = metric.timeseries[0];
|
|
298
|
+
|
|
299
|
+
if (!timeserie.data || timeserie.data.length === 0) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const aggregatedValue = aggregateDataPoints(
|
|
304
|
+
timeserie.data as MetricDataPoint[],
|
|
305
|
+
aggregation,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return aggregatedValue;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const logger = getLogger(["savemoney", "azure", "metrics"]);
|
|
311
|
+
logger.error(
|
|
312
|
+
`Failed to fetch metric ${metricName} for resource ${resourceId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
313
|
+
);
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/finding.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Finding model — the single representation of a cost-related
|
|
3
|
+
* observation, regardless of its source (custom analyzers, Azure Advisor,
|
|
4
|
+
* future AWS Trusted Advisor, …).
|
|
5
|
+
*
|
|
6
|
+
* Introduced in Phase 0 of the savemoney evolution roadmap. Existing
|
|
7
|
+
* `AnalysisResult`-based analyzers keep working untouched: an adapter
|
|
8
|
+
* (`findingsFromAnalysisResult`) splits the concatenated `reason` string
|
|
9
|
+
* into one `Finding` per sentence, so downstream consumers can already
|
|
10
|
+
* reason in terms of `Finding[]`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CostRisk } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single, atomic observation about a resource.
|
|
17
|
+
*
|
|
18
|
+
* One resource can produce multiple findings (e.g. "no tags" + "low CPU").
|
|
19
|
+
* Findings are designed to be deduplicated by `(resourceId, source, code)`.
|
|
20
|
+
*/
|
|
21
|
+
export type Finding = {
|
|
22
|
+
/**
|
|
23
|
+
* Cloud category. Today every custom finding is "cost"; Advisor may
|
|
24
|
+
* surface other categories that we currently ignore.
|
|
25
|
+
*/
|
|
26
|
+
category: FindingCategory;
|
|
27
|
+
/**
|
|
28
|
+
* Stable machine-readable identifier for the kind of finding, e.g.
|
|
29
|
+
* `vm.deallocated`, `disk.unattached`, `advisor.right-size-vm`.
|
|
30
|
+
* Used for deduplication and grouping. Free-form for now to keep the
|
|
31
|
+
* adapter from existing analyzers cheap; can be tightened later.
|
|
32
|
+
*/
|
|
33
|
+
code: string;
|
|
34
|
+
/**
|
|
35
|
+
* Estimated monthly cost that could be recovered by acting on this
|
|
36
|
+
* finding. Populated by Advisor and (in later phases) by the Retail
|
|
37
|
+
* Prices integration. Absent when the analyzer cannot estimate it.
|
|
38
|
+
*/
|
|
39
|
+
estimatedMonthlySavings?: Money;
|
|
40
|
+
/**
|
|
41
|
+
* Free-text, human-readable description. Backward compatible with the
|
|
42
|
+
* legacy `AnalysisResult.reason` field.
|
|
43
|
+
*/
|
|
44
|
+
reason: string;
|
|
45
|
+
/**
|
|
46
|
+
* Optional, machine-friendly hint about how to remediate. For Advisor
|
|
47
|
+
* this typically maps to `shortDescription.solution`.
|
|
48
|
+
*/
|
|
49
|
+
recommendedAction?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Fully qualified Azure / cloud resource ID this finding refers to.
|
|
52
|
+
*/
|
|
53
|
+
resourceId: string;
|
|
54
|
+
/**
|
|
55
|
+
* Cost risk classification. Maps Advisor's `impact` (High/Medium/Low)
|
|
56
|
+
* 1:1 onto the savemoney scale.
|
|
57
|
+
*/
|
|
58
|
+
severity: CostRisk;
|
|
59
|
+
/**
|
|
60
|
+
* Provenance of the finding.
|
|
61
|
+
*/
|
|
62
|
+
source: FindingSource;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cloud category a finding belongs to. For now we focus on cost, but the
|
|
67
|
+
* model is open to future Advisor categories.
|
|
68
|
+
*/
|
|
69
|
+
export type FindingCategory =
|
|
70
|
+
| "cost"
|
|
71
|
+
| "operationalExcellence"
|
|
72
|
+
| "performance"
|
|
73
|
+
| "reliability"
|
|
74
|
+
| "security";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where the finding originated from.
|
|
78
|
+
*
|
|
79
|
+
* - `custom` → emitted by a savemoney analyzer plugin
|
|
80
|
+
* - `advisor` → fetched from Azure Advisor recommendations
|
|
81
|
+
* - `aws` → reserved for future AWS Trusted Advisor / Compute Optimizer
|
|
82
|
+
*/
|
|
83
|
+
export type FindingSource = "advisor" | "aws" | "custom";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Monetary value associated with a finding, when known.
|
|
87
|
+
* Amounts use ISO 4217 currency codes (e.g. "EUR", "USD").
|
|
88
|
+
*/
|
|
89
|
+
export type Money = {
|
|
90
|
+
amount: number;
|
|
91
|
+
currency: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Aggregate view: one resource with the list of findings emitted for it.
|
|
96
|
+
*
|
|
97
|
+
* This is the type future report generators should consume. The current
|
|
98
|
+
* report layer still works on `AzureDetailedResourceReport`; a helper
|
|
99
|
+
* (`legacyReportFromResourceReport`) bridges the two until the report
|
|
100
|
+
* layer is migrated.
|
|
101
|
+
*/
|
|
102
|
+
export type ResourceReport<TResource = unknown> = {
|
|
103
|
+
findings: Finding[];
|
|
104
|
+
resource: TResource;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adapter: converts a legacy `AnalysisResult` (single concatenated
|
|
109
|
+
* reason) into a list of `Finding`s, one per sentence.
|
|
110
|
+
*
|
|
111
|
+
* @param resourceId Fully qualified resource ID
|
|
112
|
+
* @param severity Cost risk classification produced by the analyzer
|
|
113
|
+
* @param reason Concatenated reason string (sentences joined by ". ")
|
|
114
|
+
* @param source Provenance (default: "custom")
|
|
115
|
+
* @param code Optional stable identifier for the finding kind
|
|
116
|
+
* (e.g. `"vm.deallocated"`). Defaults to
|
|
117
|
+
* `"custom.unknown"` when omitted.
|
|
118
|
+
*/
|
|
119
|
+
export function findingsFromAnalysisResult(args: {
|
|
120
|
+
code?: string;
|
|
121
|
+
reason: string;
|
|
122
|
+
resourceId: string;
|
|
123
|
+
severity: CostRisk;
|
|
124
|
+
source?: FindingSource;
|
|
125
|
+
}): Finding[] {
|
|
126
|
+
const { code, reason, resourceId, severity, source = "custom" } = args;
|
|
127
|
+
const sentences = splitReasonIntoSentences(reason);
|
|
128
|
+
if (sentences.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return sentences.map((sentence) => ({
|
|
132
|
+
category: "cost" as const,
|
|
133
|
+
code: code ?? "custom.unknown",
|
|
134
|
+
reason: sentence,
|
|
135
|
+
resourceId,
|
|
136
|
+
severity,
|
|
137
|
+
source,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Splits a concatenated reason string (sentences joined by ". ") into
|
|
143
|
+
* individual non-empty sentences. Mirrors the logic already used by the
|
|
144
|
+
* lint reporter so behaviour stays consistent.
|
|
145
|
+
*/
|
|
146
|
+
function splitReasonIntoSentences(reason: string): string[] {
|
|
147
|
+
return reason
|
|
148
|
+
.split(/\.\s+|\.$/)
|
|
149
|
+
.map((s) => s.trim())
|
|
150
|
+
.filter((s) => s.length > 0)
|
|
151
|
+
.map((s) => (s.endsWith(".") ? s : `${s}.`));
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* This tool does NOT modify, tag, or delete any resources.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
export {
|
|
17
|
+
type Analyzer,
|
|
18
|
+
type AnalyzerContext,
|
|
19
|
+
type AzureClients,
|
|
20
|
+
createDefaultAnalyzers,
|
|
21
|
+
} from "./azure/analyzers/index.js";
|
|
22
|
+
|
|
16
23
|
// Export common types
|
|
17
24
|
export type { AzureConfig } from "./azure/types.js";
|
|
18
25
|
|
|
@@ -20,6 +27,17 @@ export type { AzureConfig } from "./azure/types.js";
|
|
|
20
27
|
import * as azureModule from "./azure/index.js";
|
|
21
28
|
export const azure = azureModule;
|
|
22
29
|
|
|
30
|
+
export { type MetricsCache, type MonitorClientLike } from "./azure/utils.js";
|
|
31
|
+
|
|
32
|
+
// Phase 0: unified Finding model and analyzer plugin layer
|
|
33
|
+
export {
|
|
34
|
+
type Finding,
|
|
35
|
+
type FindingCategory,
|
|
36
|
+
findingsFromAnalysisResult,
|
|
37
|
+
type FindingSource,
|
|
38
|
+
type Money,
|
|
39
|
+
type ResourceReport,
|
|
40
|
+
} from "./finding.js";
|
|
23
41
|
export * from "./types.js";
|
|
24
42
|
|
|
25
43
|
import type { AzureConfig } from "./azure/types.js";
|
package/src/schema.ts
CHANGED
|
@@ -105,6 +105,11 @@ export const ThresholdsSchema = z
|
|
|
105
105
|
|
|
106
106
|
const AzureSectionSchema = z
|
|
107
107
|
.object({
|
|
108
|
+
/**
|
|
109
|
+
* Maximum number of resources analyzed in parallel within a single
|
|
110
|
+
* subscription. Defaults to 8 when not provided.
|
|
111
|
+
*/
|
|
112
|
+
concurrency: z.number().int().positive().optional(),
|
|
108
113
|
preferredLocation: z.string().default("italynorth"),
|
|
109
114
|
subscriptionIds: z
|
|
110
115
|
.array(z.string())
|