@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.
- package/README.md +33 -27
- package/dist/azure/analyzer.d.ts +49 -21
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +369 -93
- package/dist/azure/analyzer.js.map +1 -1
- 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 +8 -0
- package/dist/azure/analyzers/index.d.ts.map +1 -0
- package/dist/azure/analyzers/index.js +6 -0
- package/dist/azure/analyzers/index.js.map +1 -0
- package/dist/azure/analyzers/registry.d.ts +29 -0
- package/dist/azure/analyzers/registry.d.ts.map +1 -0
- package/dist/azure/analyzers/registry.js +79 -0
- package/dist/azure/analyzers/registry.js.map +1 -0
- 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/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 +2 -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/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 +34 -1
- 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 +4 -1
- 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 +5 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +14 -0
- package/dist/schema.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/finding.test.ts +149 -0
- package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
- package/src/azure/__tests__/report.test.ts +27 -0
- package/src/azure/__tests__/utils.test.ts +164 -2
- package/src/azure/analyzer.ts +513 -182
- package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
- package/src/azure/analyzers/advisor.ts +324 -0
- package/src/azure/analyzers/index.ts +14 -0
- package/src/azure/analyzers/registry.ts +196 -0
- package/src/azure/analyzers/subscription.ts +56 -0
- package/src/azure/analyzers/types.ts +66 -0
- package/src/azure/config.ts +2 -0
- package/src/azure/index.ts +1 -0
- package/src/azure/report.ts +206 -35
- 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 +35 -1
- package/src/azure/utils.ts +110 -39
- package/src/finding.ts +152 -0
- package/src/index.ts +19 -1
- package/src/schema.ts +14 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default registry of Azure analyzers.
|
|
3
|
+
*
|
|
4
|
+
* Each entry wraps an existing per-type analyzer function (kept in
|
|
5
|
+
* `../resources/`) and exposes it through the unified `Analyzer`
|
|
6
|
+
* interface. The orchestrator simply iterates the registry — no big
|
|
7
|
+
* `switch` statement, no risk of forgetting to wire a new analyzer
|
|
8
|
+
* into the orchestrator when the catalog grows.
|
|
9
|
+
*
|
|
10
|
+
* Adding a new analyzer is a single insertion here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SubscriptionAnalyzer } from "./subscription.js";
|
|
14
|
+
import type { Analyzer } from "./types.js";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
analyzeAppServicePlan,
|
|
18
|
+
analyzeContainerApp,
|
|
19
|
+
analyzeDisk,
|
|
20
|
+
analyzeNic,
|
|
21
|
+
analyzePrivateEndpoint,
|
|
22
|
+
analyzePublicIp,
|
|
23
|
+
analyzeStaticSite,
|
|
24
|
+
analyzeStorageAccount,
|
|
25
|
+
analyzeVM,
|
|
26
|
+
} from "../resources/index.js";
|
|
27
|
+
import { createAdvisorAnalyzer } from "./advisor.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the default set of analyzers in the same order they were
|
|
31
|
+
* previously evaluated by the orchestrator's `switch` statement. The
|
|
32
|
+
* order is not behaviourally meaningful today (each resource is matched
|
|
33
|
+
* by exactly one analyzer) but is kept deterministic for predictable
|
|
34
|
+
* logging and to ease future debugging.
|
|
35
|
+
*/
|
|
36
|
+
export function createDefaultAnalyzers(): Analyzer[] {
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
analyze: ({
|
|
40
|
+
clients,
|
|
41
|
+
metricsCache,
|
|
42
|
+
resource,
|
|
43
|
+
thresholds,
|
|
44
|
+
timespanDays,
|
|
45
|
+
verbose,
|
|
46
|
+
}) =>
|
|
47
|
+
analyzeContainerApp(
|
|
48
|
+
resource,
|
|
49
|
+
clients.containerApps,
|
|
50
|
+
clients.monitor,
|
|
51
|
+
timespanDays,
|
|
52
|
+
thresholds,
|
|
53
|
+
verbose,
|
|
54
|
+
metricsCache,
|
|
55
|
+
),
|
|
56
|
+
id: "azure.container-app",
|
|
57
|
+
supports: (r) => r.type?.toLowerCase() === "microsoft.app/containerapps",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
analyze: ({ clients, resource, verbose }) =>
|
|
61
|
+
analyzeDisk(resource, clients.compute, verbose),
|
|
62
|
+
id: "azure.disk",
|
|
63
|
+
supports: (r) => r.type?.toLowerCase() === "microsoft.compute/disks",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
analyze: ({
|
|
67
|
+
clients,
|
|
68
|
+
metricsCache,
|
|
69
|
+
resource,
|
|
70
|
+
thresholds,
|
|
71
|
+
timespanDays,
|
|
72
|
+
verbose,
|
|
73
|
+
}) =>
|
|
74
|
+
analyzeVM(
|
|
75
|
+
resource,
|
|
76
|
+
clients.monitor,
|
|
77
|
+
clients.compute,
|
|
78
|
+
timespanDays,
|
|
79
|
+
thresholds,
|
|
80
|
+
verbose,
|
|
81
|
+
metricsCache,
|
|
82
|
+
),
|
|
83
|
+
id: "azure.vm",
|
|
84
|
+
supports: (r) =>
|
|
85
|
+
r.type?.toLowerCase() === "microsoft.compute/virtualmachines",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
analyze: ({ clients, resource, verbose }) =>
|
|
89
|
+
analyzeNic(resource, clients.network, verbose),
|
|
90
|
+
id: "azure.nic",
|
|
91
|
+
supports: (r) =>
|
|
92
|
+
r.type?.toLowerCase() === "microsoft.network/networkinterfaces",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
analyze: ({ clients, resource, verbose }) =>
|
|
96
|
+
analyzePrivateEndpoint(resource, clients.network, verbose),
|
|
97
|
+
id: "azure.private-endpoint",
|
|
98
|
+
supports: (r) =>
|
|
99
|
+
r.type?.toLowerCase() === "microsoft.network/privateendpoints",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
analyze: ({
|
|
103
|
+
clients,
|
|
104
|
+
metricsCache,
|
|
105
|
+
resource,
|
|
106
|
+
thresholds,
|
|
107
|
+
timespanDays,
|
|
108
|
+
verbose,
|
|
109
|
+
}) =>
|
|
110
|
+
analyzePublicIp(
|
|
111
|
+
resource,
|
|
112
|
+
clients.network,
|
|
113
|
+
clients.monitor,
|
|
114
|
+
timespanDays,
|
|
115
|
+
thresholds,
|
|
116
|
+
verbose,
|
|
117
|
+
metricsCache,
|
|
118
|
+
),
|
|
119
|
+
id: "azure.public-ip",
|
|
120
|
+
supports: (r) =>
|
|
121
|
+
r.type?.toLowerCase() === "microsoft.network/publicipaddresses",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
analyze: ({
|
|
125
|
+
clients,
|
|
126
|
+
metricsCache,
|
|
127
|
+
resource,
|
|
128
|
+
thresholds,
|
|
129
|
+
timespanDays,
|
|
130
|
+
verbose,
|
|
131
|
+
}) =>
|
|
132
|
+
analyzeStorageAccount(
|
|
133
|
+
resource,
|
|
134
|
+
clients.monitor,
|
|
135
|
+
timespanDays,
|
|
136
|
+
thresholds,
|
|
137
|
+
verbose,
|
|
138
|
+
metricsCache,
|
|
139
|
+
),
|
|
140
|
+
id: "azure.storage-account",
|
|
141
|
+
supports: (r) =>
|
|
142
|
+
r.type?.toLowerCase() === "microsoft.storage/storageaccounts",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
analyze: ({
|
|
146
|
+
clients,
|
|
147
|
+
metricsCache,
|
|
148
|
+
resource,
|
|
149
|
+
thresholds,
|
|
150
|
+
timespanDays,
|
|
151
|
+
verbose,
|
|
152
|
+
}) =>
|
|
153
|
+
analyzeAppServicePlan(
|
|
154
|
+
resource,
|
|
155
|
+
clients.webSite,
|
|
156
|
+
clients.monitor,
|
|
157
|
+
timespanDays,
|
|
158
|
+
thresholds,
|
|
159
|
+
verbose,
|
|
160
|
+
metricsCache,
|
|
161
|
+
),
|
|
162
|
+
id: "azure.app-service-plan",
|
|
163
|
+
supports: (r) => r.type?.toLowerCase() === "microsoft.web/serverfarms",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
analyze: ({
|
|
167
|
+
clients,
|
|
168
|
+
metricsCache,
|
|
169
|
+
resource,
|
|
170
|
+
thresholds,
|
|
171
|
+
timespanDays,
|
|
172
|
+
verbose,
|
|
173
|
+
}) =>
|
|
174
|
+
analyzeStaticSite(
|
|
175
|
+
resource,
|
|
176
|
+
clients.monitor,
|
|
177
|
+
timespanDays,
|
|
178
|
+
thresholds,
|
|
179
|
+
verbose,
|
|
180
|
+
metricsCache,
|
|
181
|
+
),
|
|
182
|
+
id: "azure.static-web-app",
|
|
183
|
+
supports: (r) => r.type?.toLowerCase() === "microsoft.web/staticsites",
|
|
184
|
+
},
|
|
185
|
+
];
|
|
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
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin architecture for Azure resource analyzers.
|
|
3
|
+
*
|
|
4
|
+
* Each `Analyzer` is a self-contained unit that:
|
|
5
|
+
* 1. declares the resource types it can handle (`supports`)
|
|
6
|
+
* 2. produces an `AnalysisResult` for a given resource (`analyze`)
|
|
7
|
+
*
|
|
8
|
+
* The orchestrator in `analyzer.ts` walks the registered analyzers for
|
|
9
|
+
* every resource it encounters. New sources (Azure Advisor, custom
|
|
10
|
+
* checks, pricing-enriched analyzers, …) can be added by implementing
|
|
11
|
+
* the interface and registering them in `registry.ts` without touching
|
|
12
|
+
* the orchestrator.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
|
|
16
|
+
import type { WebSiteManagementClient } from "@azure/arm-appservice";
|
|
17
|
+
import type { ComputeManagementClient } from "@azure/arm-compute";
|
|
18
|
+
import type { MonitorClient } from "@azure/arm-monitor";
|
|
19
|
+
import type { NetworkManagementClient } from "@azure/arm-network";
|
|
20
|
+
import type * as armResources from "@azure/arm-resources";
|
|
21
|
+
|
|
22
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
23
|
+
import type { MetricsCache } from "../utils.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Contract every analyzer must satisfy.
|
|
27
|
+
*/
|
|
28
|
+
export type Analyzer = {
|
|
29
|
+
analyze(ctx: AnalyzerContext): Promise<AnalysisResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Stable identifier of the analyzer (e.g. `azure.vm`, `azure.advisor`).
|
|
32
|
+
* Used for logging, telemetry and future deduplication logic.
|
|
33
|
+
*/
|
|
34
|
+
readonly id: string;
|
|
35
|
+
supports(resource: armResources.GenericResource): boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Per-resource analysis context handed to every analyzer.
|
|
40
|
+
*/
|
|
41
|
+
export type AnalyzerContext = {
|
|
42
|
+
clients: AzureClients;
|
|
43
|
+
/**
|
|
44
|
+
* Run-scoped metrics cache. Pass through to `getMetric` calls so that
|
|
45
|
+
* concurrent `analyzeAzureResources` invocations stay isolated.
|
|
46
|
+
*/
|
|
47
|
+
metricsCache: MetricsCache;
|
|
48
|
+
preferredLocation: string;
|
|
49
|
+
resource: armResources.GenericResource;
|
|
50
|
+
thresholds: Thresholds;
|
|
51
|
+
timespanDays: number;
|
|
52
|
+
verbose: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bundle of Azure SDK clients an analyzer might need. The orchestrator
|
|
57
|
+
* builds them once per subscription and passes the same instances to
|
|
58
|
+
* every analyzer.
|
|
59
|
+
*/
|
|
60
|
+
export type AzureClients = {
|
|
61
|
+
compute: ComputeManagementClient;
|
|
62
|
+
containerApps: ContainerAppsAPIClient;
|
|
63
|
+
monitor: MonitorClient;
|
|
64
|
+
network: NetworkManagementClient;
|
|
65
|
+
webSite: WebSiteManagementClient;
|
|
66
|
+
};
|
package/src/azure/config.ts
CHANGED
|
@@ -42,7 +42,9 @@ export async function loadAzureConfig(
|
|
|
42
42
|
const rawYaml = yaml.load(raw);
|
|
43
43
|
const parsed = ConfigSchema.parse(rawYaml);
|
|
44
44
|
return {
|
|
45
|
+
concurrency: parsed.azure.concurrency,
|
|
45
46
|
preferredLocation: parsed.azure.preferredLocation,
|
|
47
|
+
sources: parsed.azure.sources,
|
|
46
48
|
subscriptionIds: parsed.azure.subscriptionIds,
|
|
47
49
|
thresholds: parsed.azure.thresholds,
|
|
48
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
|
/**
|
|
@@ -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 analyzeAppServicePlan(
|
|
|
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,
|
|
@@ -79,6 +81,7 @@ export async function analyzeAppServicePlan(
|
|
|
79
81
|
"CpuPercentage",
|
|
80
82
|
"Average",
|
|
81
83
|
timespanDays,
|
|
84
|
+
cache,
|
|
82
85
|
);
|
|
83
86
|
|
|
84
87
|
const memoryPercentage = await getMetric(
|
|
@@ -87,6 +90,7 @@ export async function analyzeAppServicePlan(
|
|
|
87
90
|
"MemoryPercentage",
|
|
88
91
|
"Average",
|
|
89
92
|
timespanDays,
|
|
93
|
+
cache,
|
|
90
94
|
);
|
|
91
95
|
|
|
92
96
|
if (
|