@pagopa/dx-savemoney 0.1.5 → 0.2.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 +104 -27
- package/dist/azure/analyzer.d.ts +4 -4
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +16 -10
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/config.d.ts +15 -3
- package/dist/azure/config.d.ts.map +1 -1
- package/dist/azure/config.js +32 -16
- package/dist/azure/config.js.map +1 -1
- package/dist/azure/report.d.ts +2 -2
- package/dist/azure/report.d.ts.map +1 -1
- package/dist/azure/report.js +68 -3
- package/dist/azure/report.js.map +1 -1
- package/dist/azure/resources/app-service.d.ts +2 -2
- package/dist/azure/resources/app-service.d.ts.map +1 -1
- package/dist/azure/resources/app-service.js +8 -5
- package/dist/azure/resources/app-service.js.map +1 -1
- package/dist/azure/resources/container-app.d.ts +2 -2
- package/dist/azure/resources/container-app.d.ts.map +1 -1
- package/dist/azure/resources/container-app.js +10 -8
- package/dist/azure/resources/container-app.js.map +1 -1
- package/dist/azure/resources/public-ip.d.ts +2 -2
- package/dist/azure/resources/public-ip.d.ts.map +1 -1
- package/dist/azure/resources/public-ip.js +3 -3
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/resources/static-web-app.d.ts +2 -2
- package/dist/azure/resources/static-web-app.d.ts.map +1 -1
- package/dist/azure/resources/static-web-app.js +4 -5
- package/dist/azure/resources/static-web-app.js.map +1 -1
- package/dist/azure/resources/storage.d.ts +2 -2
- package/dist/azure/resources/storage.d.ts.map +1 -1
- package/dist/azure/resources/storage.js +4 -3
- package/dist/azure/resources/storage.js.map +1 -1
- package/dist/azure/resources/vm.d.ts +2 -2
- package/dist/azure/resources/vm.d.ts.map +1 -1
- package/dist/azure/resources/vm.js +4 -5
- package/dist/azure/resources/vm.js.map +1 -1
- package/dist/azure/types.d.ts +10 -2
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/azure/utils.d.ts +9 -0
- package/dist/azure/utils.d.ts.map +1 -1
- package/dist/azure/utils.js +14 -0
- package/dist/azure/utils.js.map +1 -1
- package/dist/index.d.ts +16 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -54
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +210 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +97 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/package.json +12 -6
- package/src/azure/__tests__/config.test.ts +97 -0
- package/src/azure/__tests__/fixtures/full-override.yaml +25 -0
- package/src/azure/__tests__/fixtures/partial-override.yaml +10 -0
- package/src/azure/__tests__/report.test.ts +138 -0
- package/src/azure/__tests__/utils.test.ts +124 -0
- package/src/azure/analyzer.ts +24 -3
- package/src/azure/config.ts +33 -21
- package/src/azure/report.ts +81 -4
- package/src/azure/resources/__tests__/storage.test.ts +185 -0
- package/src/azure/resources/app-service.ts +13 -5
- package/src/azure/resources/container-app.ts +13 -4
- package/src/azure/resources/public-ip.ts +4 -3
- package/src/azure/resources/static-web-app.ts +5 -5
- package/src/azure/resources/storage.ts +7 -3
- package/src/azure/resources/vm.ts +5 -5
- package/src/azure/types.ts +15 -2
- package/src/azure/utils.ts +21 -0
- package/src/index.ts +19 -69
- package/src/schema.ts +134 -0
- package/src/types.ts +14 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for analyzeStorageAccount() — verifies that custom thresholds
|
|
3
|
+
* actually change the analysis outcome.
|
|
4
|
+
*
|
|
5
|
+
* Key scenario:
|
|
6
|
+
* - Metric: 30 avg transactions/day
|
|
7
|
+
* - Default threshold: 10 → 30 >= 10 → NOT flagged
|
|
8
|
+
* - Custom threshold: 50 → 30 < 50 → IS flagged ✓ proves the feature works
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MonitorClient } from "@azure/arm-monitor";
|
|
12
|
+
import type { GenericResource } from "@azure/arm-resources";
|
|
13
|
+
|
|
14
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
import type { Thresholds } from "../../../types.js";
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_THRESHOLDS } from "../../../types.js";
|
|
19
|
+
|
|
20
|
+
// Mock the utils module so we control getMetric without real Azure calls
|
|
21
|
+
vi.mock("../../utils.js", () => ({
|
|
22
|
+
getMetric: vi.fn(),
|
|
23
|
+
verboseLog: vi.fn(),
|
|
24
|
+
verboseLogAnalysisResult: vi.fn(),
|
|
25
|
+
verboseLogResourceStart: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Import after vi.mock so the module resolves the mock
|
|
29
|
+
import { getMetric } from "../../utils.js";
|
|
30
|
+
import { analyzeStorageAccount } from "../storage.js";
|
|
31
|
+
|
|
32
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
// Minimal stub: analyzeStorageAccount only needs monitorClient to be passed
|
|
35
|
+
// through to getMetric, which is fully mocked. We provide only the shape that
|
|
36
|
+
// TypeScript requires without unsafe type assertions.
|
|
37
|
+
const FAKE_CLIENT = {
|
|
38
|
+
metrics: { list: vi.fn() },
|
|
39
|
+
} as unknown as MonitorClient;
|
|
40
|
+
|
|
41
|
+
const FAKE_RESOURCE: GenericResource = {
|
|
42
|
+
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/st1",
|
|
43
|
+
name: "st1",
|
|
44
|
+
type: "Microsoft.Storage/storageAccounts",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const mockGetMetric = vi.mocked(getMetric);
|
|
48
|
+
|
|
49
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("analyzeStorageAccount — threshold sensitivity", () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
mockGetMetric.mockReset();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("with DEFAULT threshold (transactionsPerDay = 10)", () => {
|
|
57
|
+
it("does NOT flag a resource with 30 transactions/day (30 ≥ 10)", async () => {
|
|
58
|
+
mockGetMetric.mockResolvedValue(30);
|
|
59
|
+
|
|
60
|
+
const result = await analyzeStorageAccount(
|
|
61
|
+
FAKE_RESOURCE,
|
|
62
|
+
FAKE_CLIENT,
|
|
63
|
+
30,
|
|
64
|
+
DEFAULT_THRESHOLDS,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(result.suspectedUnused).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("flags a resource with 5 transactions/day (5 < 10)", async () => {
|
|
71
|
+
mockGetMetric.mockResolvedValue(5);
|
|
72
|
+
|
|
73
|
+
const result = await analyzeStorageAccount(
|
|
74
|
+
FAKE_RESOURCE,
|
|
75
|
+
FAKE_CLIENT,
|
|
76
|
+
30,
|
|
77
|
+
DEFAULT_THRESHOLDS,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result.suspectedUnused).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not flag when metric is exactly at the threshold (10)", async () => {
|
|
84
|
+
mockGetMetric.mockResolvedValue(10);
|
|
85
|
+
|
|
86
|
+
const result = await analyzeStorageAccount(
|
|
87
|
+
FAKE_RESOURCE,
|
|
88
|
+
FAKE_CLIENT,
|
|
89
|
+
30,
|
|
90
|
+
DEFAULT_THRESHOLDS,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// 10 < 10 is false → not flagged
|
|
94
|
+
expect(result.suspectedUnused).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("with CUSTOM threshold (transactionsPerDay = 50)", () => {
|
|
99
|
+
const customThresholds: Thresholds = {
|
|
100
|
+
...DEFAULT_THRESHOLDS,
|
|
101
|
+
storage: { transactionsPerDay: 50 },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
it("DOES flag a resource with 30 transactions/day (30 < 50) — proves override works", async () => {
|
|
105
|
+
mockGetMetric.mockResolvedValue(30);
|
|
106
|
+
|
|
107
|
+
const result = await analyzeStorageAccount(
|
|
108
|
+
FAKE_RESOURCE,
|
|
109
|
+
FAKE_CLIENT,
|
|
110
|
+
30,
|
|
111
|
+
customThresholds,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(result.suspectedUnused).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does NOT flag a resource with 60 transactions/day (60 ≥ 50)", async () => {
|
|
118
|
+
mockGetMetric.mockResolvedValue(60);
|
|
119
|
+
|
|
120
|
+
const result = await analyzeStorageAccount(
|
|
121
|
+
FAKE_RESOURCE,
|
|
122
|
+
FAKE_CLIENT,
|
|
123
|
+
30,
|
|
124
|
+
customThresholds,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result.suspectedUnused).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("flags at threshold boundary (49 < 50)", async () => {
|
|
131
|
+
mockGetMetric.mockResolvedValue(49);
|
|
132
|
+
|
|
133
|
+
const result = await analyzeStorageAccount(
|
|
134
|
+
FAKE_RESOURCE,
|
|
135
|
+
FAKE_CLIENT,
|
|
136
|
+
30,
|
|
137
|
+
customThresholds,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result.suspectedUnused).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("edge cases", () => {
|
|
145
|
+
it("returns suspectedUnused: false when metric is null (data unavailable)", async () => {
|
|
146
|
+
mockGetMetric.mockResolvedValue(null);
|
|
147
|
+
|
|
148
|
+
const result = await analyzeStorageAccount(
|
|
149
|
+
FAKE_RESOURCE,
|
|
150
|
+
FAKE_CLIENT,
|
|
151
|
+
30,
|
|
152
|
+
DEFAULT_THRESHOLDS,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(result.suspectedUnused).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns suspectedUnused: false when resource id is missing", async () => {
|
|
159
|
+
const resourceWithoutId = { ...FAKE_RESOURCE, id: undefined };
|
|
160
|
+
|
|
161
|
+
const result = await analyzeStorageAccount(
|
|
162
|
+
resourceWithoutId,
|
|
163
|
+
FAKE_CLIENT,
|
|
164
|
+
30,
|
|
165
|
+
DEFAULT_THRESHOLDS,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(result.suspectedUnused).toBe(false);
|
|
169
|
+
expect(mockGetMetric).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("applies default thresholds when the parameter is omitted", async () => {
|
|
173
|
+
// 5 < DEFAULT 10 → flagged even without explicit thresholds argument
|
|
174
|
+
mockGetMetric.mockResolvedValue(5);
|
|
175
|
+
|
|
176
|
+
const result = await analyzeStorageAccount(
|
|
177
|
+
FAKE_RESOURCE,
|
|
178
|
+
FAKE_CLIENT,
|
|
179
|
+
30,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result.suspectedUnused).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -8,8 +8,9 @@ import type { MonitorClient } from "@azure/arm-monitor";
|
|
|
8
8
|
import * as armResources from "@azure/arm-resources";
|
|
9
9
|
import { getLogger } from "@logtape/logtape";
|
|
10
10
|
|
|
11
|
-
import type { AnalysisResult } from "../../types.js";
|
|
11
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
12
12
|
|
|
13
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
13
14
|
import {
|
|
14
15
|
getMetric,
|
|
15
16
|
verboseLog,
|
|
@@ -31,6 +32,7 @@ export async function analyzeAppServicePlan(
|
|
|
31
32
|
webSiteClient: WebSiteManagementClient,
|
|
32
33
|
monitorClient: MonitorClient,
|
|
33
34
|
timespanDays: number,
|
|
35
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
34
36
|
verbose = false,
|
|
35
37
|
): Promise<AnalysisResult> {
|
|
36
38
|
verboseLogResourceStart(
|
|
@@ -87,19 +89,25 @@ export async function analyzeAppServicePlan(
|
|
|
87
89
|
timespanDays,
|
|
88
90
|
);
|
|
89
91
|
|
|
90
|
-
if (
|
|
92
|
+
if (
|
|
93
|
+
cpuPercentage !== null &&
|
|
94
|
+
cpuPercentage < thresholds.appService.cpuPercent
|
|
95
|
+
) {
|
|
91
96
|
reason += `Very low CPU usage (${cpuPercentage.toFixed(2)}%). `;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
if (
|
|
99
|
+
if (
|
|
100
|
+
memoryPercentage !== null &&
|
|
101
|
+
memoryPercentage < thresholds.appService.memoryPercent
|
|
102
|
+
) {
|
|
95
103
|
reason += `Very low memory usage (${memoryPercentage.toFixed(2)}%). `;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
// Check if it's an oversized plan (Premium tier with low usage)
|
|
99
107
|
if (
|
|
100
108
|
planDetails.sku?.tier?.includes("Premium") &&
|
|
101
|
-
cpuPercentage &&
|
|
102
|
-
cpuPercentage <
|
|
109
|
+
cpuPercentage !== null &&
|
|
110
|
+
cpuPercentage < thresholds.appService.premiumCpuPercent
|
|
103
111
|
) {
|
|
104
112
|
reason += "Premium tier with low resource utilization. ";
|
|
105
113
|
}
|
|
@@ -8,8 +8,9 @@ import type { MonitorClient } from "@azure/arm-monitor";
|
|
|
8
8
|
import * as armResources from "@azure/arm-resources";
|
|
9
9
|
import { getLogger } from "@logtape/logtape";
|
|
10
10
|
|
|
11
|
-
import type { AnalysisResult } from "../../types.js";
|
|
11
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
12
12
|
|
|
13
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
13
14
|
import {
|
|
14
15
|
getMetric,
|
|
15
16
|
verboseLog,
|
|
@@ -32,6 +33,7 @@ export async function analyzeContainerApp(
|
|
|
32
33
|
containerAppsClient: ContainerAppsAPIClient,
|
|
33
34
|
monitorClient: MonitorClient,
|
|
34
35
|
timespanDays: number,
|
|
36
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
35
37
|
verbose = false,
|
|
36
38
|
): Promise<AnalysisResult> {
|
|
37
39
|
verboseLogResourceStart(
|
|
@@ -79,6 +81,7 @@ export async function analyzeContainerApp(
|
|
|
79
81
|
resource,
|
|
80
82
|
monitorClient,
|
|
81
83
|
timespanDays,
|
|
84
|
+
thresholds,
|
|
82
85
|
reason,
|
|
83
86
|
verbose,
|
|
84
87
|
);
|
|
@@ -86,6 +89,7 @@ export async function analyzeContainerApp(
|
|
|
86
89
|
resource,
|
|
87
90
|
monitorClient,
|
|
88
91
|
timespanDays,
|
|
92
|
+
thresholds,
|
|
89
93
|
reason,
|
|
90
94
|
verbose,
|
|
91
95
|
);
|
|
@@ -110,6 +114,7 @@ async function checkNetworkMetrics(
|
|
|
110
114
|
resource: armResources.GenericResource,
|
|
111
115
|
monitorClient: MonitorClient,
|
|
112
116
|
timespanDays: number,
|
|
117
|
+
thresholds: Thresholds,
|
|
113
118
|
reason: string,
|
|
114
119
|
verbose: boolean,
|
|
115
120
|
): Promise<string> {
|
|
@@ -149,7 +154,7 @@ async function checkNetworkMetrics(
|
|
|
149
154
|
if (
|
|
150
155
|
networkIn !== null &&
|
|
151
156
|
networkOut !== null &&
|
|
152
|
-
networkIn + networkOut <
|
|
157
|
+
networkIn + networkOut < thresholds.containerApp.networkBytes
|
|
153
158
|
) {
|
|
154
159
|
newReason += `Very low network traffic (${((networkIn + networkOut) / 1048576).toFixed(2)} MB/day avg). `;
|
|
155
160
|
}
|
|
@@ -164,6 +169,7 @@ async function checkResourceMetrics(
|
|
|
164
169
|
resource: armResources.GenericResource,
|
|
165
170
|
monitorClient: MonitorClient,
|
|
166
171
|
timespanDays: number,
|
|
172
|
+
thresholds: Thresholds,
|
|
167
173
|
reason: string,
|
|
168
174
|
verbose: boolean,
|
|
169
175
|
): Promise<string> {
|
|
@@ -200,11 +206,14 @@ async function checkResourceMetrics(
|
|
|
200
206
|
`Memory Usage: ${memoryUsage !== null ? `${(memoryUsage / 1048576).toFixed(2)} MB` : "N/A"}`,
|
|
201
207
|
);
|
|
202
208
|
|
|
203
|
-
if (cpuUsage !== null && cpuUsage <
|
|
209
|
+
if (cpuUsage !== null && cpuUsage < thresholds.containerApp.cpuNanoCores) {
|
|
204
210
|
newReason += `Very low CPU usage (${(cpuUsage / 1000000000).toFixed(4)} cores). `;
|
|
205
211
|
}
|
|
206
212
|
|
|
207
|
-
if (
|
|
213
|
+
if (
|
|
214
|
+
memoryUsage !== null &&
|
|
215
|
+
memoryUsage < thresholds.containerApp.memoryBytes
|
|
216
|
+
) {
|
|
208
217
|
newReason += `Very low memory usage (${(memoryUsage / 1048576).toFixed(2)} MB). `;
|
|
209
218
|
}
|
|
210
219
|
|
|
@@ -8,8 +8,9 @@ import type { NetworkManagementClient } from "@azure/arm-network";
|
|
|
8
8
|
import * as armResources from "@azure/arm-resources";
|
|
9
9
|
import { getLogger } from "@logtape/logtape";
|
|
10
10
|
|
|
11
|
-
import type { AnalysisResult } from "../../types.js";
|
|
11
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
12
12
|
|
|
13
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
13
14
|
import {
|
|
14
15
|
getMetric,
|
|
15
16
|
verboseLog,
|
|
@@ -31,6 +32,7 @@ export async function analyzePublicIp(
|
|
|
31
32
|
networkClient: NetworkManagementClient,
|
|
32
33
|
monitorClient: MonitorClient,
|
|
33
34
|
timespanDays: number,
|
|
35
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
34
36
|
verbose = false,
|
|
35
37
|
): Promise<AnalysisResult> {
|
|
36
38
|
verboseLogResourceStart(
|
|
@@ -87,8 +89,7 @@ export async function analyzePublicIp(
|
|
|
87
89
|
timespanDays,
|
|
88
90
|
);
|
|
89
91
|
|
|
90
|
-
if (bytesInDDoS !== null && bytesInDDoS <
|
|
91
|
-
// Less than ~340KB average per day
|
|
92
|
+
if (bytesInDDoS !== null && bytesInDDoS < thresholds.publicIp.bytesInDDoS) {
|
|
92
93
|
reason += `Very low network traffic (${(bytesInDDoS / 1024 / 1024).toFixed(2)} MB/day avg). `;
|
|
93
94
|
}
|
|
94
95
|
} catch (error) {
|
|
@@ -7,8 +7,9 @@ import type { MonitorClient } from "@azure/arm-monitor";
|
|
|
7
7
|
import * as armResources from "@azure/arm-resources";
|
|
8
8
|
import { getLogger } from "@logtape/logtape";
|
|
9
9
|
|
|
10
|
-
import type { AnalysisResult } from "../../types.js";
|
|
10
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
11
11
|
|
|
12
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
12
13
|
import {
|
|
13
14
|
getMetric,
|
|
14
15
|
verboseLog,
|
|
@@ -29,6 +30,7 @@ export async function analyzeStaticSite(
|
|
|
29
30
|
resource: armResources.GenericResource,
|
|
30
31
|
monitorClient: MonitorClient,
|
|
31
32
|
timespanDays: number,
|
|
33
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
32
34
|
verbose = false,
|
|
33
35
|
): Promise<AnalysisResult> {
|
|
34
36
|
verboseLogResourceStart(
|
|
@@ -84,13 +86,11 @@ export async function analyzeStaticSite(
|
|
|
84
86
|
if (siteHits === null && bytesSent === null) {
|
|
85
87
|
reason += `No traffic data available in ${timespanDays} days. `;
|
|
86
88
|
} else {
|
|
87
|
-
if (siteHits !== null && siteHits <
|
|
88
|
-
// Less than 100 requests total in the timespan (< ~3.3 requests/day)
|
|
89
|
+
if (siteHits !== null && siteHits < thresholds.staticSite.siteHits) {
|
|
89
90
|
reason += `Very low site traffic (${siteHits.toFixed(0)} requests in ${timespanDays} days). `;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
if (bytesSent !== null && bytesSent <
|
|
93
|
-
// Less than 1MB total in the timespan (< ~34KB/day)
|
|
93
|
+
if (bytesSent !== null && bytesSent < thresholds.staticSite.bytesSent) {
|
|
94
94
|
reason += `Very low data transfer (${(bytesSent / 1024 / 1024).toFixed(2)} MB in ${timespanDays} days). `;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -6,8 +6,9 @@ import type { MonitorClient } from "@azure/arm-monitor";
|
|
|
6
6
|
|
|
7
7
|
import * as armResources from "@azure/arm-resources";
|
|
8
8
|
|
|
9
|
-
import type { AnalysisResult } from "../../types.js";
|
|
9
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
10
10
|
|
|
11
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
11
12
|
import {
|
|
12
13
|
getMetric,
|
|
13
14
|
verboseLog,
|
|
@@ -27,6 +28,7 @@ export async function analyzeStorageAccount(
|
|
|
27
28
|
resource: armResources.GenericResource,
|
|
28
29
|
monitorClient: MonitorClient,
|
|
29
30
|
timespanDays: number,
|
|
31
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
30
32
|
verbose = false,
|
|
31
33
|
): Promise<AnalysisResult> {
|
|
32
34
|
verboseLogResourceStart(
|
|
@@ -51,8 +53,10 @@ export async function analyzeStorageAccount(
|
|
|
51
53
|
"Average",
|
|
52
54
|
timespanDays,
|
|
53
55
|
);
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
+
if (
|
|
57
|
+
transactions !== null &&
|
|
58
|
+
transactions < thresholds.storage.transactionsPerDay
|
|
59
|
+
) {
|
|
56
60
|
const result = {
|
|
57
61
|
costRisk,
|
|
58
62
|
reason: `Very low transaction count (${transactions.toFixed(2)} avg/day). `,
|
|
@@ -8,8 +8,9 @@ import type { MonitorClient } from "@azure/arm-monitor";
|
|
|
8
8
|
import * as armResources from "@azure/arm-resources";
|
|
9
9
|
import { getLogger } from "@logtape/logtape";
|
|
10
10
|
|
|
11
|
-
import type { AnalysisResult } from "../../types.js";
|
|
11
|
+
import type { AnalysisResult, Thresholds } from "../../types.js";
|
|
12
12
|
|
|
13
|
+
import { DEFAULT_THRESHOLDS } from "../../types.js";
|
|
13
14
|
import {
|
|
14
15
|
getMetric,
|
|
15
16
|
verboseLog,
|
|
@@ -32,6 +33,7 @@ export async function analyzeVM(
|
|
|
32
33
|
monitorClient: MonitorClient,
|
|
33
34
|
computeClient: ComputeManagementClient,
|
|
34
35
|
timespanDays: number,
|
|
36
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
35
37
|
verbose = false,
|
|
36
38
|
): Promise<AnalysisResult> {
|
|
37
39
|
verboseLogResourceStart(
|
|
@@ -114,12 +116,10 @@ export async function analyzeVM(
|
|
|
114
116
|
timespanDays,
|
|
115
117
|
);
|
|
116
118
|
|
|
117
|
-
if (cpuUsage !== null && cpuUsage <
|
|
118
|
-
// Less than 1% average CPU
|
|
119
|
+
if (cpuUsage !== null && cpuUsage < thresholds.vm.cpuPercent) {
|
|
119
120
|
reason += `Low CPU usage (avg ${cpuUsage.toFixed(2)}%). `;
|
|
120
121
|
}
|
|
121
|
-
if (networkIn !== null && networkIn <
|
|
122
|
-
// Less than 3MB average per day
|
|
122
|
+
if (networkIn !== null && networkIn < thresholds.vm.networkInBytesPerDay) {
|
|
123
123
|
reason += `Low network traffic (${(networkIn / 1024 / 1024).toFixed(2)} MB/day avg). `;
|
|
124
124
|
}
|
|
125
125
|
|
package/src/azure/types.ts
CHANGED
|
@@ -4,14 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
import type * as armResources from "@azure/arm-resources";
|
|
6
6
|
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AnalysisResult,
|
|
9
|
+
BaseConfig,
|
|
10
|
+
CostRisk,
|
|
11
|
+
Thresholds,
|
|
12
|
+
} from "../types.js";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Azure configuration extending base config
|
|
11
16
|
*/
|
|
12
17
|
export type AzureConfig = BaseConfig & {
|
|
18
|
+
/**
|
|
19
|
+
* Only analyze resources that match ALL the given tag key-value pairs.
|
|
20
|
+
* If omitted, all resources are analyzed.
|
|
21
|
+
*/
|
|
22
|
+
filterTags?: Map<string, string>;
|
|
13
23
|
subscriptionIds: string[];
|
|
14
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Analysis thresholds. Defaults from DEFAULT_THRESHOLDS are used when not provided.
|
|
26
|
+
*/
|
|
27
|
+
thresholds?: Thresholds;
|
|
15
28
|
verbose?: boolean;
|
|
16
29
|
};
|
|
17
30
|
|
package/src/azure/utils.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { MonitorClient } from "@azure/arm-monitor";
|
|
6
|
+
import type * as armResources from "@azure/arm-resources";
|
|
6
7
|
|
|
7
8
|
import { getLogger } from "@logtape/logtape";
|
|
8
9
|
|
|
@@ -158,6 +159,26 @@ export async function getMetric(
|
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Returns true if the resource matches ALL specified tag key-value pairs.
|
|
164
|
+
* If filterTags is empty or undefined, always returns true (no filtering).
|
|
165
|
+
*
|
|
166
|
+
* @param resource - The Azure resource to check
|
|
167
|
+
* @param filterTags - Map of required tag key→value pairs
|
|
168
|
+
*/
|
|
169
|
+
export function matchesTags(
|
|
170
|
+
resource: armResources.GenericResource,
|
|
171
|
+
filterTags: Map<string, string> | undefined,
|
|
172
|
+
): boolean {
|
|
173
|
+
if (!filterTags || filterTags.size === 0) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const resourceTags = resource.tags ?? {};
|
|
177
|
+
return [...filterTags.entries()].every(
|
|
178
|
+
([key, value]) => resourceTags[key] === value,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
/**
|
|
162
183
|
* Logs a verbose message, optionally with an object.
|
|
163
184
|
*
|
package/src/index.ts
CHANGED
|
@@ -15,85 +15,35 @@
|
|
|
15
15
|
|
|
16
16
|
// Export common types
|
|
17
17
|
export type { AzureConfig } from "./azure/types.js";
|
|
18
|
-
export * from "./types.js";
|
|
19
18
|
|
|
20
19
|
// Export Azure module
|
|
21
20
|
import * as azureModule from "./azure/index.js";
|
|
22
21
|
export const azure = azureModule;
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
// Utility imports for loadConfig and prompt functions
|
|
26
|
-
import * as fs from "fs";
|
|
27
|
-
import * as readline from "readline";
|
|
23
|
+
export * from "./types.js";
|
|
28
24
|
|
|
29
25
|
import type { AzureConfig } from "./azure/types.js";
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
* Loads configuration from file, environment variables, or interactive prompts.
|
|
33
|
-
*
|
|
34
|
-
* @param configPath - Optional path to JSON configuration file
|
|
35
|
-
* @returns Configuration object with subscription IDs and settings
|
|
36
|
-
*/
|
|
37
|
-
export async function loadConfig(configPath?: string): Promise<AzureConfig> {
|
|
38
|
-
const logger = getLogger(["savemoney", "config"]);
|
|
39
|
-
|
|
40
|
-
if (configPath && fs.existsSync(configPath)) {
|
|
41
|
-
try {
|
|
42
|
-
const configContent = fs.readFileSync(configPath, "utf-8");
|
|
43
|
-
const config = JSON.parse(configContent);
|
|
44
|
-
|
|
45
|
-
// Validate required fields
|
|
46
|
-
if (!config.tenantId || !config.subscriptionIds) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
"Config file must contain 'tenantId' and 'subscriptionIds'",
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
...config,
|
|
54
|
-
preferredLocation: config.preferredLocation || "italynorth",
|
|
55
|
-
timespanDays: config.timespanDays || 30,
|
|
56
|
-
};
|
|
57
|
-
} catch (error) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Failed to load config file: ${error instanceof Error ? error.message : error}`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
logger.info(
|
|
65
|
-
"Configuration file not found. Checking environment variables...",
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const tenantId =
|
|
69
|
-
process.env.ARM_TENANT_ID || (await prompt("Enter Tenant ID: "));
|
|
70
|
-
const subscriptionIds = process.env.ARM_SUBSCRIPTION_ID
|
|
71
|
-
? process.env.ARM_SUBSCRIPTION_ID.split(",")
|
|
72
|
-
: (await prompt("Enter Subscription IDs (comma-separated): ")).split(",");
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
preferredLocation: "italynorth",
|
|
76
|
-
subscriptionIds,
|
|
77
|
-
tenantId,
|
|
78
|
-
timespanDays: 30,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
27
|
+
import { loadAzureConfig } from "./azure/config.js";
|
|
81
28
|
|
|
82
29
|
/**
|
|
83
|
-
*
|
|
30
|
+
* Loads configuration from a YAML file, environment variables, or interactive prompts.
|
|
31
|
+
*
|
|
32
|
+
* The YAML file should have an `azure` top-level key:
|
|
33
|
+
* ```yaml
|
|
34
|
+
* azure:
|
|
35
|
+
* subscriptionIds:
|
|
36
|
+
* - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
37
|
+
* preferredLocation: italynorth
|
|
38
|
+
* timespanDays: 30
|
|
39
|
+
* thresholds:
|
|
40
|
+
* vm:
|
|
41
|
+
* cpuPercent: 5
|
|
42
|
+
* ```
|
|
84
43
|
*
|
|
85
|
-
* @param
|
|
86
|
-
* @returns
|
|
44
|
+
* @param configPath - Optional path to a YAML configuration file
|
|
45
|
+
* @returns Configuration object with subscription IDs, settings and thresholds
|
|
87
46
|
*/
|
|
88
|
-
export async function
|
|
89
|
-
|
|
90
|
-
input: process.stdin,
|
|
91
|
-
output: process.stdout,
|
|
92
|
-
});
|
|
93
|
-
return new Promise((resolve) =>
|
|
94
|
-
rl.question(question, (answer) => {
|
|
95
|
-
rl.close();
|
|
96
|
-
resolve(answer);
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
47
|
+
export async function loadConfig(configPath?: string): Promise<AzureConfig> {
|
|
48
|
+
return loadAzureConfig(configPath);
|
|
99
49
|
}
|