@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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for generateReport() with the "lint" format.
|
|
3
|
+
*
|
|
4
|
+
* Since process.stdout.isTTY is false/undefined in test environments,
|
|
5
|
+
* ANSI color codes are empty strings (~no-ops), making the output
|
|
6
|
+
* easy to assert on plain text.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { AzureDetailedResourceReport } from "../types.js";
|
|
12
|
+
|
|
13
|
+
import { generateReport } from "../report.js";
|
|
14
|
+
|
|
15
|
+
// ── test fixtures ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function makeEntry(
|
|
18
|
+
id: string,
|
|
19
|
+
costRisk: "high" | "low" | "medium",
|
|
20
|
+
reason: string,
|
|
21
|
+
suspectedUnused = true,
|
|
22
|
+
): AzureDetailedResourceReport {
|
|
23
|
+
return {
|
|
24
|
+
analysis: { costRisk, reason, suspectedUnused },
|
|
25
|
+
resource: {
|
|
26
|
+
id,
|
|
27
|
+
name: id.split("/").pop() ?? "unknown",
|
|
28
|
+
type: "Microsoft.Compute/virtualMachines",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HIGH_ENTRY = makeEntry(
|
|
34
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-high",
|
|
35
|
+
"high",
|
|
36
|
+
"VM is deallocated. No disk activity detected.",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const MEDIUM_ENTRY = makeEntry(
|
|
40
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-medium",
|
|
41
|
+
"medium",
|
|
42
|
+
"Low CPU usage.",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const LOW_ENTRY = makeEntry(
|
|
46
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-low",
|
|
47
|
+
"low",
|
|
48
|
+
"Possible idle resource.",
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function allLogs(spy: ReturnType<typeof vi.spyOn>) {
|
|
55
|
+
return spy.mock.calls.map((c) => c[0] as string).join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe("generateReport — lint format", () => {
|
|
61
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prints the resource ID for each entry", async () => {
|
|
72
|
+
await generateReport([HIGH_ENTRY], "lint");
|
|
73
|
+
const output = allLogs(logSpy);
|
|
74
|
+
expect(output).toContain(HIGH_ENTRY.resource.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("uses ✖ icon for high-risk resources", async () => {
|
|
78
|
+
await generateReport([HIGH_ENTRY], "lint");
|
|
79
|
+
const output = allLogs(logSpy);
|
|
80
|
+
expect(output).toContain("✖");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses ⚠ icon for medium-risk resources", async () => {
|
|
84
|
+
await generateReport([MEDIUM_ENTRY], "lint");
|
|
85
|
+
const output = allLogs(logSpy);
|
|
86
|
+
expect(output).toContain("⚠");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uses ℹ icon for low-risk resources", async () => {
|
|
90
|
+
await generateReport([LOW_ENTRY], "lint");
|
|
91
|
+
const output = allLogs(logSpy);
|
|
92
|
+
expect(output).toContain("ℹ");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("splits a multi-sentence reason into separate findings", async () => {
|
|
96
|
+
// Reason has two sentences separated by '. '
|
|
97
|
+
await generateReport([HIGH_ENTRY], "lint");
|
|
98
|
+
const calls = logSpy.mock.calls.map(
|
|
99
|
+
(c) => (c[0] as string | undefined) ?? "",
|
|
100
|
+
);
|
|
101
|
+
const findingLines = calls.filter((l) => l.startsWith(" "));
|
|
102
|
+
// "VM is deallocated." and "No disk activity detected." → 2 findings
|
|
103
|
+
expect(findingLines.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("prints a Summary line at the end", async () => {
|
|
107
|
+
await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
|
|
108
|
+
const output = allLogs(logSpy);
|
|
109
|
+
expect(output).toContain("Summary:");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("summary reports correct total issue count", async () => {
|
|
113
|
+
await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
|
|
114
|
+
const output = allLogs(logSpy);
|
|
115
|
+
// HIGH has 2 findings (split reason), MEDIUM and LOW have 1 each → total 4
|
|
116
|
+
expect(output).toContain("4 issues found");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("summary reports correctly with a single issue (no plural)", async () => {
|
|
120
|
+
await generateReport([MEDIUM_ENTRY], "lint");
|
|
121
|
+
const output = allLogs(logSpy);
|
|
122
|
+
// "1 issue found" (not "1 issues found")
|
|
123
|
+
expect(output).toContain("1 issue found");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("shows risk level label in uppercase for each finding", async () => {
|
|
127
|
+
await generateReport([HIGH_ENTRY], "lint");
|
|
128
|
+
const output = allLogs(logSpy);
|
|
129
|
+
expect(output).toContain("HIGH");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("prints nothing but the summary for an empty report", async () => {
|
|
133
|
+
await generateReport([], "lint");
|
|
134
|
+
const calls = logSpy.mock.calls.map((c) => c[0] as string);
|
|
135
|
+
expect(calls).toHaveLength(1);
|
|
136
|
+
expect(calls[0]).toContain("0 issues found");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for matchesTags() — verifies AND-logic tag filtering:
|
|
3
|
+
* 1. No filter (undefined/empty) → always include the resource.
|
|
4
|
+
* 2. Exact key-value match → include.
|
|
5
|
+
* 3. Key missing on resource → exclude.
|
|
6
|
+
* 4. Key present but wrong value → exclude.
|
|
7
|
+
* 5. Multiple tags: all match → include; any mismatch → exclude.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { GenericResource } from "@azure/arm-resources";
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { matchesTags } from "../utils.js";
|
|
15
|
+
|
|
16
|
+
function makeResource(tags?: Record<string, string>): GenericResource {
|
|
17
|
+
return { id: "r1", name: "res", tags };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("matchesTags", () => {
|
|
21
|
+
describe("when no filter is provided", () => {
|
|
22
|
+
it("returns true for undefined filterTags", () => {
|
|
23
|
+
expect(matchesTags(makeResource({ env: "prod" }), undefined)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns true for empty Map", () => {
|
|
27
|
+
expect(matchesTags(makeResource({ env: "prod" }), new Map())).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true even when resource has no tags", () => {
|
|
31
|
+
expect(matchesTags(makeResource(), undefined)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("single tag filter", () => {
|
|
36
|
+
it("returns true when tag key and value match exactly", () => {
|
|
37
|
+
expect(
|
|
38
|
+
matchesTags(makeResource({ env: "prod" }), new Map([["env", "prod"]])),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false when tag key is missing from resource", () => {
|
|
43
|
+
expect(
|
|
44
|
+
matchesTags(makeResource({ app: "myapp" }), new Map([["env", "prod"]])),
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns false when tag key exists but value differs", () => {
|
|
49
|
+
expect(
|
|
50
|
+
matchesTags(makeResource({ env: "dev" }), new Map([["env", "prod"]])),
|
|
51
|
+
).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns false when resource has no tags at all", () => {
|
|
55
|
+
expect(matchesTags(makeResource(), new Map([["env", "prod"]]))).toBe(
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is case-sensitive for tag values", () => {
|
|
61
|
+
expect(
|
|
62
|
+
matchesTags(makeResource({ env: "Prod" }), new Map([["env", "prod"]])),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("multiple tag filters (AND logic)", () => {
|
|
68
|
+
it("returns true when all filter tags match", () => {
|
|
69
|
+
const resource = makeResource({
|
|
70
|
+
env: "prod",
|
|
71
|
+
region: "italy",
|
|
72
|
+
team: "dx",
|
|
73
|
+
});
|
|
74
|
+
expect(
|
|
75
|
+
matchesTags(
|
|
76
|
+
resource,
|
|
77
|
+
new Map([
|
|
78
|
+
["env", "prod"],
|
|
79
|
+
["team", "dx"],
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns false when one of the filter tags is missing", () => {
|
|
86
|
+
const resource = makeResource({ env: "prod" });
|
|
87
|
+
expect(
|
|
88
|
+
matchesTags(
|
|
89
|
+
resource,
|
|
90
|
+
new Map([
|
|
91
|
+
["env", "prod"],
|
|
92
|
+
["team", "dx"],
|
|
93
|
+
]),
|
|
94
|
+
),
|
|
95
|
+
).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false when one of the filter tags has the wrong value", () => {
|
|
99
|
+
const resource = makeResource({ env: "prod", team: "backend" });
|
|
100
|
+
expect(
|
|
101
|
+
matchesTags(
|
|
102
|
+
resource,
|
|
103
|
+
new Map([
|
|
104
|
+
["env", "prod"],
|
|
105
|
+
["team", "dx"],
|
|
106
|
+
]),
|
|
107
|
+
),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns false when both filter tags have wrong values", () => {
|
|
112
|
+
const resource = makeResource({ env: "dev", team: "backend" });
|
|
113
|
+
expect(
|
|
114
|
+
matchesTags(
|
|
115
|
+
resource,
|
|
116
|
+
new Map([
|
|
117
|
+
["env", "prod"],
|
|
118
|
+
["team", "dx"],
|
|
119
|
+
]),
|
|
120
|
+
),
|
|
121
|
+
).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/azure/analyzer.ts
CHANGED
|
@@ -13,7 +13,12 @@ import { getLogger } from "@logtape/logtape";
|
|
|
13
13
|
|
|
14
14
|
import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type AnalysisResult,
|
|
18
|
+
DEFAULT_THRESHOLDS,
|
|
19
|
+
mergeResults,
|
|
20
|
+
type Thresholds,
|
|
21
|
+
} from "../types.js";
|
|
17
22
|
import { generateReport } from "./report.js";
|
|
18
23
|
import {
|
|
19
24
|
analyzeAppServicePlan,
|
|
@@ -26,16 +31,17 @@ import {
|
|
|
26
31
|
analyzeStorageAccount,
|
|
27
32
|
analyzeVM,
|
|
28
33
|
} from "./resources/index.js";
|
|
34
|
+
import { matchesTags } from "./utils.js";
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* Analyzes resources in multiple Azure subscriptions and generates a report.
|
|
32
38
|
*
|
|
33
39
|
* @param config - Azure configuration with subscription IDs and settings
|
|
34
|
-
* @param format - Output format (table, json,
|
|
40
|
+
* @param format - Output format (table, json, detailed-json, or lint)
|
|
35
41
|
*/
|
|
36
42
|
export async function analyzeAzureResources(
|
|
37
43
|
config: AzureConfig,
|
|
38
|
-
format: "detailed-json" | "json" | "table",
|
|
44
|
+
format: "detailed-json" | "json" | "lint" | "table",
|
|
39
45
|
) {
|
|
40
46
|
const logger = getLogger(["savemoney", "azure"]);
|
|
41
47
|
const credential = new DefaultAzureCredential();
|
|
@@ -66,8 +72,15 @@ export async function analyzeAzureResources(
|
|
|
66
72
|
subscriptionId.trim(),
|
|
67
73
|
);
|
|
68
74
|
|
|
75
|
+
const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
|
|
76
|
+
|
|
69
77
|
// Use the async iterator to avoid memory explosion for large environments
|
|
70
78
|
for await (const resource of resourceClient.resources.list()) {
|
|
79
|
+
// Skip resources that don't match the requested tag filter
|
|
80
|
+
if (!matchesTags(resource, config.filterTags)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
const { costRisk, reason, suspectedUnused } = await analyzeResource(
|
|
72
85
|
resource,
|
|
73
86
|
monitorClient,
|
|
@@ -77,6 +90,7 @@ export async function analyzeAzureResources(
|
|
|
77
90
|
containerAppsClient,
|
|
78
91
|
config.preferredLocation,
|
|
79
92
|
config.timespanDays,
|
|
93
|
+
thresholds,
|
|
80
94
|
config.verbose || false,
|
|
81
95
|
);
|
|
82
96
|
|
|
@@ -127,6 +141,7 @@ export async function analyzeResource(
|
|
|
127
141
|
containerAppsClient: ContainerAppsAPIClient,
|
|
128
142
|
preferredLocation: string,
|
|
129
143
|
timespanDays: number,
|
|
144
|
+
thresholds: Thresholds,
|
|
130
145
|
verbose = false,
|
|
131
146
|
): Promise<AnalysisResult> {
|
|
132
147
|
const type = resource.type?.toLowerCase() || "";
|
|
@@ -150,6 +165,7 @@ export async function analyzeResource(
|
|
|
150
165
|
containerAppsClient,
|
|
151
166
|
monitorClient,
|
|
152
167
|
timespanDays,
|
|
168
|
+
thresholds,
|
|
153
169
|
verbose,
|
|
154
170
|
);
|
|
155
171
|
result = mergeResults(result, containerAppResult);
|
|
@@ -166,6 +182,7 @@ export async function analyzeResource(
|
|
|
166
182
|
monitorClient,
|
|
167
183
|
computeClient,
|
|
168
184
|
timespanDays,
|
|
185
|
+
thresholds,
|
|
169
186
|
verbose,
|
|
170
187
|
);
|
|
171
188
|
result = mergeResults(result, vmResult);
|
|
@@ -191,6 +208,7 @@ export async function analyzeResource(
|
|
|
191
208
|
networkClient,
|
|
192
209
|
monitorClient,
|
|
193
210
|
timespanDays,
|
|
211
|
+
thresholds,
|
|
194
212
|
verbose,
|
|
195
213
|
);
|
|
196
214
|
result = mergeResults(result, pipResult);
|
|
@@ -201,6 +219,7 @@ export async function analyzeResource(
|
|
|
201
219
|
resource,
|
|
202
220
|
monitorClient,
|
|
203
221
|
timespanDays,
|
|
222
|
+
thresholds,
|
|
204
223
|
verbose,
|
|
205
224
|
);
|
|
206
225
|
result = mergeResults(result, storageResult);
|
|
@@ -212,6 +231,7 @@ export async function analyzeResource(
|
|
|
212
231
|
webSiteClient,
|
|
213
232
|
monitorClient,
|
|
214
233
|
timespanDays,
|
|
234
|
+
thresholds,
|
|
215
235
|
verbose,
|
|
216
236
|
);
|
|
217
237
|
result = mergeResults(result, aspResult);
|
|
@@ -222,6 +242,7 @@ export async function analyzeResource(
|
|
|
222
242
|
resource,
|
|
223
243
|
monitorClient,
|
|
224
244
|
timespanDays,
|
|
245
|
+
thresholds,
|
|
225
246
|
verbose,
|
|
226
247
|
);
|
|
227
248
|
result = mergeResults(result, staticSiteResult);
|
package/src/azure/config.ts
CHANGED
|
@@ -4,38 +4,53 @@
|
|
|
4
4
|
|
|
5
5
|
import { getLogger } from "@logtape/logtape";
|
|
6
6
|
import * as fs from "fs";
|
|
7
|
+
import * as yaml from "js-yaml";
|
|
7
8
|
import * as readline from "readline";
|
|
9
|
+
import { z } from "zod";
|
|
8
10
|
|
|
9
11
|
import type { AzureConfig } from "./types.js";
|
|
10
12
|
|
|
13
|
+
import { ConfigSchema } from "../schema.js";
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
|
-
* Loads Azure configuration from file, environment variables, or interactive prompts.
|
|
16
|
+
* Loads Azure configuration from a YAML file, environment variables, or interactive prompts.
|
|
17
|
+
*
|
|
18
|
+
* The YAML file must have an `azure` top-level key:
|
|
19
|
+
* ```yaml
|
|
20
|
+
* azure:
|
|
21
|
+
* subscriptionIds:
|
|
22
|
+
* - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
23
|
+
* preferredLocation: italynorth
|
|
24
|
+
* timespanDays: 30
|
|
25
|
+
* thresholds:
|
|
26
|
+
* vm:
|
|
27
|
+
* cpuPercent: 5
|
|
28
|
+
* ```
|
|
13
29
|
*
|
|
14
|
-
* @param configPath - Optional path to
|
|
15
|
-
* @returns Azure configuration object with subscription IDs and
|
|
30
|
+
* @param configPath - Optional path to a YAML configuration file
|
|
31
|
+
* @returns Azure configuration object with subscription IDs, settings and thresholds
|
|
16
32
|
*/
|
|
17
33
|
export async function loadAzureConfig(
|
|
18
34
|
configPath?: string,
|
|
19
35
|
): Promise<AzureConfig> {
|
|
20
|
-
if (configPath
|
|
36
|
+
if (configPath) {
|
|
37
|
+
if (!fs.existsSync(configPath)) {
|
|
38
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
39
|
+
}
|
|
21
40
|
try {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
if (!config.tenantId || !config.subscriptionIds) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
"Config file must contain 'tenantId' and 'subscriptionIds'",
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
41
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
42
|
+
const rawYaml = yaml.load(raw);
|
|
43
|
+
const parsed = ConfigSchema.parse(rawYaml);
|
|
31
44
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
timespanDays: config.timespanDays || 30,
|
|
45
|
+
preferredLocation: parsed.azure.preferredLocation,
|
|
46
|
+
subscriptionIds: parsed.azure.subscriptionIds,
|
|
47
|
+
thresholds: parsed.azure.thresholds,
|
|
48
|
+
timespanDays: parsed.azure.timespanDays,
|
|
37
49
|
};
|
|
38
50
|
} catch (error) {
|
|
51
|
+
if (error instanceof z.ZodError) {
|
|
52
|
+
throw new Error(`Invalid config file:\n${z.prettifyError(error)}`);
|
|
53
|
+
}
|
|
39
54
|
throw new Error(
|
|
40
55
|
`Failed to load config file: ${error instanceof Error ? error.message : error}`,
|
|
41
56
|
);
|
|
@@ -47,8 +62,6 @@ export async function loadAzureConfig(
|
|
|
47
62
|
"Configuration file not found. Checking environment variables...",
|
|
48
63
|
);
|
|
49
64
|
|
|
50
|
-
const tenantId =
|
|
51
|
-
process.env.ARM_TENANT_ID || (await prompt("Enter Tenant ID: "));
|
|
52
65
|
const subscriptionIds = process.env.ARM_SUBSCRIPTION_ID
|
|
53
66
|
? process.env.ARM_SUBSCRIPTION_ID.split(",")
|
|
54
67
|
: (await prompt("Enter Subscription IDs (comma-separated): ")).split(",");
|
|
@@ -56,7 +69,6 @@ export async function loadAzureConfig(
|
|
|
56
69
|
return {
|
|
57
70
|
preferredLocation: "italynorth",
|
|
58
71
|
subscriptionIds,
|
|
59
|
-
tenantId,
|
|
60
72
|
timespanDays: 30,
|
|
61
73
|
};
|
|
62
74
|
}
|
package/src/azure/report.ts
CHANGED
|
@@ -7,15 +7,36 @@ import type {
|
|
|
7
7
|
AzureResourceReport,
|
|
8
8
|
} from "./types.js";
|
|
9
9
|
|
|
10
|
+
// ANSI color codes — only applied when stdout is a TTY to avoid cluttering redirected output
|
|
11
|
+
const isTTY = process.stdout.isTTY ?? false;
|
|
12
|
+
const RED = isTTY ? "\x1b[31m" : "";
|
|
13
|
+
const YELLOW = isTTY ? "\x1b[33m" : "";
|
|
14
|
+
const BLUE = isTTY ? "\x1b[34m" : "";
|
|
15
|
+
const BOLD = isTTY ? "\x1b[1m" : "";
|
|
16
|
+
const RESET = isTTY ? "\x1b[0m" : "";
|
|
17
|
+
const DIM = isTTY ? "\x1b[2m" : "";
|
|
18
|
+
|
|
19
|
+
const RISK_ICON = {
|
|
20
|
+
high: `${RED}✖${RESET}`,
|
|
21
|
+
low: `${BLUE}ℹ${RESET}`,
|
|
22
|
+
medium: `${YELLOW}⚠${RESET}`,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const RISK_COLOR = {
|
|
26
|
+
high: RED,
|
|
27
|
+
low: BLUE,
|
|
28
|
+
medium: YELLOW,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
10
31
|
/**
|
|
11
32
|
* Generates a report from Azure resource analysis in the specified format.
|
|
12
33
|
*
|
|
13
34
|
* @param report - Array of detailed resource reports
|
|
14
|
-
* @param format - Output format (table, json,
|
|
35
|
+
* @param format - Output format (table, json, detailed-json, or lint)
|
|
15
36
|
*/
|
|
16
37
|
export async function generateReport(
|
|
17
38
|
report: AzureDetailedResourceReport[],
|
|
18
|
-
format: "detailed-json" | "json" | "table",
|
|
39
|
+
format: "detailed-json" | "json" | "lint" | "table",
|
|
19
40
|
) {
|
|
20
41
|
if (format === "detailed-json") {
|
|
21
42
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -36,6 +57,8 @@ export async function generateReport(
|
|
|
36
57
|
|
|
37
58
|
if (format === "json") {
|
|
38
59
|
console.log(JSON.stringify(summaryReport, null, 2));
|
|
60
|
+
} else if (format === "lint") {
|
|
61
|
+
generateLintReport(report);
|
|
39
62
|
} else {
|
|
40
63
|
console.table(
|
|
41
64
|
summaryReport.map((r) => ({
|
|
@@ -44,9 +67,63 @@ export async function generateReport(
|
|
|
44
67
|
"Resource Group": r.resourceGroup || "N/A",
|
|
45
68
|
Risk: r.costRisk,
|
|
46
69
|
Type: r.type,
|
|
47
|
-
Unused: r.suspectedUnused ? "Yes" : "No",
|
|
48
70
|
})),
|
|
49
|
-
["Name", "Type", "Resource Group", "Risk", "
|
|
71
|
+
["Name", "Type", "Resource Group", "Risk", "Reason"],
|
|
50
72
|
);
|
|
51
73
|
}
|
|
52
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Renders a linter-style report to stdout, grouping findings by resource.
|
|
78
|
+
*
|
|
79
|
+
* Example output:
|
|
80
|
+
*
|
|
81
|
+
* /subscriptions/.../virtualMachines/my-vm
|
|
82
|
+
* ✖ HIGH VM is deallocated.
|
|
83
|
+
* ✖ HIGH No tags found.
|
|
84
|
+
*
|
|
85
|
+
* Summary: 3 issues found (2 high, 0 medium, 1 low)
|
|
86
|
+
*/
|
|
87
|
+
function generateLintReport(report: AzureDetailedResourceReport[]): void {
|
|
88
|
+
const counts = { high: 0, low: 0, medium: 0 };
|
|
89
|
+
|
|
90
|
+
for (const entry of report) {
|
|
91
|
+
const resourceId =
|
|
92
|
+
entry.resource.id ?? `unknown/${entry.resource.name ?? "unknown"}`;
|
|
93
|
+
const risk = entry.analysis.costRisk;
|
|
94
|
+
const findings = splitReasons(entry.analysis.reason);
|
|
95
|
+
|
|
96
|
+
console.log(`${BOLD}${resourceId}${RESET}`);
|
|
97
|
+
|
|
98
|
+
for (const finding of findings) {
|
|
99
|
+
const icon = RISK_ICON[risk];
|
|
100
|
+
const color = RISK_COLOR[risk];
|
|
101
|
+
const label = `${color}${risk.toUpperCase().padEnd(6)}${RESET}`;
|
|
102
|
+
console.log(` ${icon} ${label} ${DIM}${finding}${RESET}`);
|
|
103
|
+
counts[risk]++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const total = counts.high + counts.medium + counts.low;
|
|
110
|
+
const summaryLine =
|
|
111
|
+
`${BOLD}Summary:${RESET} ${total} issue${total !== 1 ? "s" : ""} found` +
|
|
112
|
+
` ${RED}(${counts.high} high${RESET}` +
|
|
113
|
+
`, ${YELLOW}${counts.medium} medium${RESET}` +
|
|
114
|
+
`, ${BLUE}${counts.low} low${RESET})`;
|
|
115
|
+
|
|
116
|
+
console.log(summaryLine);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Splits a concatenated reason string (sentences separated by ". ") into
|
|
121
|
+
* individual finding strings, stripping trailing whitespace.
|
|
122
|
+
*/
|
|
123
|
+
function splitReasons(reason: string): string[] {
|
|
124
|
+
return reason
|
|
125
|
+
.split(/\.\s+/)
|
|
126
|
+
.map((s) => s.trim())
|
|
127
|
+
.filter((s) => s.length > 0)
|
|
128
|
+
.map((s) => (s.endsWith(".") ? s : `${s}.`));
|
|
129
|
+
}
|