@pagopa/dx-savemoney 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -0
- package/dist/azure/analyzer.d.ts +32 -0
- package/dist/azure/analyzer.d.ts.map +1 -0
- package/dist/azure/analyzer.js +128 -0
- package/dist/azure/analyzer.js.map +1 -0
- package/dist/azure/config.d.ts +19 -0
- package/dist/azure/config.d.ts.map +1 -0
- package/dist/azure/config.js +62 -0
- package/dist/azure/config.js.map +1 -0
- package/dist/azure/index.d.ts +9 -0
- package/dist/azure/index.d.ts.map +1 -0
- package/dist/azure/index.js +9 -0
- package/dist/azure/index.js.map +1 -0
- package/dist/azure/report.d.ts +12 -0
- package/dist/azure/report.d.ts.map +1 -0
- package/dist/azure/report.js +40 -0
- package/dist/azure/report.js.map +1 -0
- package/dist/azure/resources/app-service.d.ts +18 -0
- package/dist/azure/resources/app-service.d.ts.map +1 -0
- package/dist/azure/resources/app-service.js +65 -0
- package/dist/azure/resources/app-service.js.map +1 -0
- package/dist/azure/resources/disk.d.ts +16 -0
- package/dist/azure/resources/disk.d.ts.map +1 -0
- package/dist/azure/resources/disk.js +56 -0
- package/dist/azure/resources/disk.js.map +1 -0
- package/dist/azure/resources/index.d.ts +11 -0
- package/dist/azure/resources/index.d.ts.map +1 -0
- package/dist/azure/resources/index.js +11 -0
- package/dist/azure/resources/index.js.map +1 -0
- package/dist/azure/resources/nic.d.ts +15 -0
- package/dist/azure/resources/nic.d.ts.map +1 -0
- package/dist/azure/resources/nic.js +56 -0
- package/dist/azure/resources/nic.js.map +1 -0
- package/dist/azure/resources/private-endpoint.d.ts +15 -0
- package/dist/azure/resources/private-endpoint.d.ts.map +1 -0
- package/dist/azure/resources/private-endpoint.js +66 -0
- package/dist/azure/resources/private-endpoint.js.map +1 -0
- package/dist/azure/resources/public-ip.d.ts +18 -0
- package/dist/azure/resources/public-ip.d.ts.map +1 -0
- package/dist/azure/resources/public-ip.js +61 -0
- package/dist/azure/resources/public-ip.js.map +1 -0
- package/dist/azure/resources/storage.d.ts +16 -0
- package/dist/azure/resources/storage.d.ts.map +1 -0
- package/dist/azure/resources/storage.js +39 -0
- package/dist/azure/resources/storage.js.map +1 -0
- package/dist/azure/resources/vm.d.ts +19 -0
- package/dist/azure/resources/vm.d.ts.map +1 -0
- package/dist/azure/resources/vm.js +77 -0
- package/dist/azure/resources/vm.js.map +1 -0
- package/dist/azure/types.d.ts +34 -0
- package/dist/azure/types.d.ts.map +1 -0
- package/dist/azure/types.js +5 -0
- package/dist/azure/types.js.map +1 -0
- package/dist/azure/utils.d.ts +40 -0
- package/dist/azure/utils.d.ts.map +1 -0
- package/dist/azure/utils.js +104 -0
- package/dist/azure/utils.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/src/azure/analyzer.ts +213 -0
- package/src/azure/config.ts +81 -0
- package/src/azure/index.ts +9 -0
- package/src/azure/report.ts +52 -0
- package/src/azure/resources/app-service.ts +118 -0
- package/src/azure/resources/disk.ts +88 -0
- package/src/azure/resources/index.ts +11 -0
- package/src/azure/resources/nic.ts +90 -0
- package/src/azure/resources/private-endpoint.ts +112 -0
- package/src/azure/resources/public-ip.ts +106 -0
- package/src/azure/resources/storage.ts +67 -0
- package/src/azure/resources/vm.ts +129 -0
- package/src/azure/types.ts +38 -0
- package/src/azure/utils.ts +141 -0
- package/src/index.test.ts +95 -0
- package/src/index.ts +99 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Storage Account analysis
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MonitorClient } from "@azure/arm-monitor";
|
|
6
|
+
|
|
7
|
+
import * as armResources from "@azure/arm-resources";
|
|
8
|
+
|
|
9
|
+
import type { AnalysisResult } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
getMetric,
|
|
13
|
+
verboseLog,
|
|
14
|
+
verboseLogAnalysisResult,
|
|
15
|
+
verboseLogResourceStart,
|
|
16
|
+
} from "../utils.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Analyzes an Azure Storage Account for potential cost optimization.
|
|
20
|
+
*
|
|
21
|
+
* @param resource - The Azure resource object
|
|
22
|
+
* @param monitorClient - Azure Monitor client for metrics
|
|
23
|
+
* @param timespanDays - Number of days to analyze metrics
|
|
24
|
+
* @returns Analysis result with cost risk and reason
|
|
25
|
+
*/
|
|
26
|
+
export async function analyzeStorageAccount(
|
|
27
|
+
resource: armResources.GenericResource,
|
|
28
|
+
monitorClient: MonitorClient,
|
|
29
|
+
timespanDays: number,
|
|
30
|
+
verbose = false,
|
|
31
|
+
): Promise<AnalysisResult> {
|
|
32
|
+
verboseLogResourceStart(
|
|
33
|
+
verbose,
|
|
34
|
+
resource.name || "unknown",
|
|
35
|
+
"Storage Account (microsoft.storage/storageaccounts)",
|
|
36
|
+
);
|
|
37
|
+
verboseLog(verbose, "Resource details:", resource);
|
|
38
|
+
|
|
39
|
+
const costRisk: "high" | "low" | "medium" = "medium";
|
|
40
|
+
if (!resource.id) {
|
|
41
|
+
return {
|
|
42
|
+
costRisk,
|
|
43
|
+
reason: "Resource ID is missing.",
|
|
44
|
+
suspectedUnused: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const transactions = await getMetric(
|
|
48
|
+
monitorClient,
|
|
49
|
+
resource.id,
|
|
50
|
+
"Transactions",
|
|
51
|
+
"Total",
|
|
52
|
+
timespanDays,
|
|
53
|
+
);
|
|
54
|
+
if (transactions !== null && transactions < 100) {
|
|
55
|
+
// Very low transactions
|
|
56
|
+
const result = {
|
|
57
|
+
costRisk,
|
|
58
|
+
reason: `Very low transaction count (${transactions}). `,
|
|
59
|
+
suspectedUnused: true,
|
|
60
|
+
};
|
|
61
|
+
verboseLogAnalysisResult(verbose, result);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
const result = { costRisk, reason: "", suspectedUnused: false };
|
|
65
|
+
verboseLogAnalysisResult(verbose, result);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Virtual Machine analysis
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ComputeManagementClient } from "@azure/arm-compute";
|
|
6
|
+
import type { MonitorClient } from "@azure/arm-monitor";
|
|
7
|
+
|
|
8
|
+
import * as armResources from "@azure/arm-resources";
|
|
9
|
+
import { getLogger } from "@logtape/logtape";
|
|
10
|
+
|
|
11
|
+
import type { AnalysisResult } from "../../types.js";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getMetric,
|
|
15
|
+
verboseLog,
|
|
16
|
+
verboseLogAnalysisResult,
|
|
17
|
+
verboseLogResourceStart,
|
|
18
|
+
} from "../utils.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Analyzes an Azure Virtual Machine for potential cost optimization.
|
|
22
|
+
*
|
|
23
|
+
* @param resource - The Azure resource object
|
|
24
|
+
* @param monitorClient - Azure Monitor client for fetching metrics
|
|
25
|
+
* @param computeClient - Azure Compute client for VM details
|
|
26
|
+
* @param timespanDays - Number of days to analyze metrics
|
|
27
|
+
* @param verbose - Whether verbose logging is enabled
|
|
28
|
+
* @returns Analysis result with cost risk and reason
|
|
29
|
+
*/
|
|
30
|
+
export async function analyzeVM(
|
|
31
|
+
resource: armResources.GenericResource,
|
|
32
|
+
monitorClient: MonitorClient,
|
|
33
|
+
computeClient: ComputeManagementClient,
|
|
34
|
+
timespanDays: number,
|
|
35
|
+
verbose = false,
|
|
36
|
+
): Promise<AnalysisResult> {
|
|
37
|
+
verboseLogResourceStart(
|
|
38
|
+
verbose,
|
|
39
|
+
resource.name || "unknown",
|
|
40
|
+
"Virtual Machine (microsoft.compute/virtualmachines)",
|
|
41
|
+
);
|
|
42
|
+
verboseLog(verbose, "Resource details:", resource);
|
|
43
|
+
|
|
44
|
+
const costRisk: "high" | "low" | "medium" = "high";
|
|
45
|
+
let reason = "";
|
|
46
|
+
|
|
47
|
+
if (!resource.id) {
|
|
48
|
+
return {
|
|
49
|
+
costRisk,
|
|
50
|
+
reason: "Resource ID is missing.",
|
|
51
|
+
suspectedUnused: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extract resource group and VM name from resource ID
|
|
56
|
+
const resourceParts = resource.id.split("/");
|
|
57
|
+
const resourceGroupName = resourceParts[4];
|
|
58
|
+
const vmName = resourceParts[8];
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Get the actual VM instance view to check power state
|
|
62
|
+
const instanceView = await computeClient.virtualMachines.instanceView(
|
|
63
|
+
resourceGroupName,
|
|
64
|
+
vmName,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
verboseLog(verbose, "VM Instance View:", instanceView);
|
|
68
|
+
|
|
69
|
+
// Check power state from instance view
|
|
70
|
+
const vmStatus = instanceView.statuses?.find((s: { code?: string }) =>
|
|
71
|
+
s.code?.startsWith("PowerState/"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (vmStatus?.code === "PowerState/deallocated") {
|
|
75
|
+
const result = {
|
|
76
|
+
costRisk,
|
|
77
|
+
reason: "VM is deallocated. ",
|
|
78
|
+
suspectedUnused: true,
|
|
79
|
+
};
|
|
80
|
+
verboseLogAnalysisResult(verbose, result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (vmStatus?.code === "PowerState/stopped") {
|
|
85
|
+
const result = {
|
|
86
|
+
costRisk,
|
|
87
|
+
reason: "VM is stopped. ",
|
|
88
|
+
suspectedUnused: true,
|
|
89
|
+
};
|
|
90
|
+
verboseLogAnalysisResult(verbose, result);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const logger = getLogger(["savemoney", "azure"]);
|
|
95
|
+
logger.warn(
|
|
96
|
+
`Failed to get VM instance view for ${vmName}: ${error instanceof Error ? error.message : error}`,
|
|
97
|
+
);
|
|
98
|
+
// Continue with metric analysis if instance view fails
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check metrics for low utilization
|
|
102
|
+
const cpuUsage = await getMetric(
|
|
103
|
+
monitorClient,
|
|
104
|
+
resource.id,
|
|
105
|
+
"Percentage CPU",
|
|
106
|
+
"Average",
|
|
107
|
+
timespanDays,
|
|
108
|
+
);
|
|
109
|
+
const networkIn = await getMetric(
|
|
110
|
+
monitorClient,
|
|
111
|
+
resource.id,
|
|
112
|
+
"Network In Total",
|
|
113
|
+
"Total",
|
|
114
|
+
timespanDays,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (cpuUsage !== null && cpuUsage < 1) {
|
|
118
|
+
// Less than 1% average CPU
|
|
119
|
+
reason += `Low CPU usage (avg ${cpuUsage.toFixed(2)}%). `;
|
|
120
|
+
}
|
|
121
|
+
if (networkIn !== null && networkIn < 1024 * 1024 * 10) {
|
|
122
|
+
// Less than 10MB total network in
|
|
123
|
+
reason += `Low network traffic. `;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = { costRisk, reason, suspectedUnused: reason.length > 0 };
|
|
127
|
+
verboseLogAnalysisResult(verbose, result);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure-specific types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type * as armResources from "@azure/arm-resources";
|
|
6
|
+
|
|
7
|
+
import type { AnalysisResult, BaseConfig, CostRisk } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Azure configuration extending base config
|
|
11
|
+
*/
|
|
12
|
+
export type AzureConfig = BaseConfig & {
|
|
13
|
+
subscriptionIds: string[];
|
|
14
|
+
tenantId: string;
|
|
15
|
+
verbose?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detailed report for a single Azure resource with full resource object
|
|
20
|
+
*/
|
|
21
|
+
export type AzureDetailedResourceReport = {
|
|
22
|
+
analysis: AnalysisResult;
|
|
23
|
+
resource: armResources.GenericResource;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Summary report for a single Azure resource
|
|
28
|
+
*/
|
|
29
|
+
export type AzureResourceReport = {
|
|
30
|
+
costRisk: CostRisk;
|
|
31
|
+
location?: string;
|
|
32
|
+
name: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
resourceGroup?: string;
|
|
35
|
+
subscriptionId: string;
|
|
36
|
+
suspectedUnused: boolean;
|
|
37
|
+
type: string;
|
|
38
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure utility functions for debugging and metrics
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MonitorClient } from "@azure/arm-monitor";
|
|
6
|
+
|
|
7
|
+
import { getLogger } from "@logtape/logtape";
|
|
8
|
+
|
|
9
|
+
import type { AnalysisResult } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetches a specific metric for a resource from Azure Monitor.
|
|
13
|
+
*
|
|
14
|
+
* @param monitorClient - The Azure Monitor client instance
|
|
15
|
+
* @param resourceId - The Azure resource ID
|
|
16
|
+
* @param metricName - The name of the metric to fetch (e.g., "Percentage CPU")
|
|
17
|
+
* @param aggregation - The aggregation type (e.g., "Average", "Total")
|
|
18
|
+
* @param timespanDays - Number of days to look back for metrics
|
|
19
|
+
* @returns The metric value or null if unavailable
|
|
20
|
+
*/
|
|
21
|
+
export async function getMetric(
|
|
22
|
+
monitorClient: MonitorClient,
|
|
23
|
+
resourceId: string,
|
|
24
|
+
metricName: string,
|
|
25
|
+
aggregation: string,
|
|
26
|
+
timespanDays: number,
|
|
27
|
+
): Promise<null | number> {
|
|
28
|
+
try {
|
|
29
|
+
const timespan = `P${timespanDays}D`;
|
|
30
|
+
const result = await monitorClient.metrics.list(resourceId, {
|
|
31
|
+
aggregation,
|
|
32
|
+
metricnames: metricName,
|
|
33
|
+
timespan,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const metricData = result.value[0]?.timeseries?.[0]?.data?.[0];
|
|
37
|
+
if (!metricData) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const aggregationLower = aggregation.toLowerCase();
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
aggregationLower === "average" &&
|
|
45
|
+
typeof metricData.average === "number"
|
|
46
|
+
) {
|
|
47
|
+
return metricData.average;
|
|
48
|
+
}
|
|
49
|
+
if (aggregationLower === "total" && typeof metricData.total === "number") {
|
|
50
|
+
return metricData.total;
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
aggregationLower === "minimum" &&
|
|
54
|
+
typeof metricData.minimum === "number"
|
|
55
|
+
) {
|
|
56
|
+
return metricData.minimum;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
aggregationLower === "maximum" &&
|
|
60
|
+
typeof metricData.maximum === "number"
|
|
61
|
+
) {
|
|
62
|
+
return metricData.maximum;
|
|
63
|
+
}
|
|
64
|
+
if (aggregationLower === "count" && typeof metricData.count === "number") {
|
|
65
|
+
return metricData.count;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const logger = getLogger(["savemoney", "azure", "metrics"]);
|
|
71
|
+
logger.error(
|
|
72
|
+
`Failed to fetch metric ${metricName} for resource ${resourceId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
73
|
+
);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Logs a verbose message, optionally with an object.
|
|
80
|
+
*
|
|
81
|
+
* @param verbose - Whether verbose logging is enabled
|
|
82
|
+
* @param message - The message to log
|
|
83
|
+
* @param object - Optional object to stringify and log
|
|
84
|
+
*/
|
|
85
|
+
export function verboseLog(
|
|
86
|
+
verbose: boolean,
|
|
87
|
+
message: string,
|
|
88
|
+
object?: unknown,
|
|
89
|
+
) {
|
|
90
|
+
if (verbose) {
|
|
91
|
+
const logger = getLogger(["savemoney", "azure", "verbose"]);
|
|
92
|
+
if (object !== undefined) {
|
|
93
|
+
logger.debug(`${message} ${JSON.stringify(object, null, 2)}`);
|
|
94
|
+
} else {
|
|
95
|
+
logger.debug(message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Logs the analysis result for a resource.
|
|
102
|
+
*
|
|
103
|
+
* @param verbose - Whether verbose logging is enabled
|
|
104
|
+
* @param result - The analysis result object
|
|
105
|
+
*/
|
|
106
|
+
export function verboseLogAnalysisResult(
|
|
107
|
+
verbose: boolean,
|
|
108
|
+
result: AnalysisResult,
|
|
109
|
+
) {
|
|
110
|
+
if (verbose) {
|
|
111
|
+
const logger = getLogger(["savemoney", "azure", "verbose"]);
|
|
112
|
+
logger.debug("\n📊 ANALYSIS RESULT:");
|
|
113
|
+
logger.debug(` Cost Risk: ${result.costRisk.toUpperCase()}`);
|
|
114
|
+
logger.debug(
|
|
115
|
+
` Suspected Unused: ${result.suspectedUnused ? "YES" : "NO"}`,
|
|
116
|
+
);
|
|
117
|
+
logger.debug(` Reason: ${result.reason || "No issues found"}`);
|
|
118
|
+
logger.debug("=".repeat(80) + "\n");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Logs a resource analysis header with visual separator.
|
|
124
|
+
*
|
|
125
|
+
* @param verbose - Whether verbose logging is enabled
|
|
126
|
+
* @param resourceName - Name of the resource being analyzed
|
|
127
|
+
* @param resourceType - Type of the resource
|
|
128
|
+
*/
|
|
129
|
+
export function verboseLogResourceStart(
|
|
130
|
+
verbose: boolean,
|
|
131
|
+
resourceName: string,
|
|
132
|
+
resourceType: string,
|
|
133
|
+
) {
|
|
134
|
+
if (verbose) {
|
|
135
|
+
const logger = getLogger(["savemoney", "azure", "verbose"]);
|
|
136
|
+
logger.debug("\n" + "=".repeat(80));
|
|
137
|
+
logger.debug(`🔍 ANALYZING: ${resourceName}`);
|
|
138
|
+
logger.debug(` Type: ${resourceType}`);
|
|
139
|
+
logger.debug("=".repeat(80));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { AnalysisResult } from "./index.js";
|
|
4
|
+
|
|
5
|
+
import { mergeResults } from "./index.js";
|
|
6
|
+
|
|
7
|
+
describe("mergeResults", () => {
|
|
8
|
+
it("should concatenate reasons from both results", () => {
|
|
9
|
+
const baseResult: AnalysisResult = {
|
|
10
|
+
costRisk: "low",
|
|
11
|
+
reason: "No tags found. ",
|
|
12
|
+
suspectedUnused: true,
|
|
13
|
+
};
|
|
14
|
+
const specificResult: AnalysisResult = {
|
|
15
|
+
costRisk: "medium",
|
|
16
|
+
reason: "Disk is unattached. ",
|
|
17
|
+
suspectedUnused: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const merged = mergeResults(baseResult, specificResult);
|
|
21
|
+
|
|
22
|
+
expect(merged.reason).toBe("No tags found. Disk is unattached. ");
|
|
23
|
+
expect(merged.costRisk).toBe("medium");
|
|
24
|
+
expect(merged.suspectedUnused).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should use specific result's cost risk", () => {
|
|
28
|
+
const baseResult: AnalysisResult = {
|
|
29
|
+
costRisk: "low",
|
|
30
|
+
reason: "Base reason. ",
|
|
31
|
+
suspectedUnused: false,
|
|
32
|
+
};
|
|
33
|
+
const specificResult: AnalysisResult = {
|
|
34
|
+
costRisk: "high",
|
|
35
|
+
reason: "High risk reason. ",
|
|
36
|
+
suspectedUnused: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const merged = mergeResults(baseResult, specificResult);
|
|
40
|
+
|
|
41
|
+
expect(merged.costRisk).toBe("high");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should OR suspectedUnused flags", () => {
|
|
45
|
+
const baseResult: AnalysisResult = {
|
|
46
|
+
costRisk: "low",
|
|
47
|
+
reason: "Base. ",
|
|
48
|
+
suspectedUnused: false,
|
|
49
|
+
};
|
|
50
|
+
const specificResult: AnalysisResult = {
|
|
51
|
+
costRisk: "medium",
|
|
52
|
+
reason: "Specific. ",
|
|
53
|
+
suspectedUnused: true,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const merged = mergeResults(baseResult, specificResult);
|
|
57
|
+
|
|
58
|
+
expect(merged.suspectedUnused).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should handle empty reasons", () => {
|
|
62
|
+
const baseResult: AnalysisResult = {
|
|
63
|
+
costRisk: "low",
|
|
64
|
+
reason: "",
|
|
65
|
+
suspectedUnused: false,
|
|
66
|
+
};
|
|
67
|
+
const specificResult: AnalysisResult = {
|
|
68
|
+
costRisk: "medium",
|
|
69
|
+
reason: "Only specific reason. ",
|
|
70
|
+
suspectedUnused: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const merged = mergeResults(baseResult, specificResult);
|
|
74
|
+
|
|
75
|
+
expect(merged.reason).toBe("Only specific reason. ");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should preserve both reasons when both are present", () => {
|
|
79
|
+
const baseResult: AnalysisResult = {
|
|
80
|
+
costRisk: "low",
|
|
81
|
+
reason: "First issue. ",
|
|
82
|
+
suspectedUnused: true,
|
|
83
|
+
};
|
|
84
|
+
const specificResult: AnalysisResult = {
|
|
85
|
+
costRisk: "high",
|
|
86
|
+
reason: "Second issue. ",
|
|
87
|
+
suspectedUnused: false,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const merged = mergeResults(baseResult, specificResult);
|
|
91
|
+
|
|
92
|
+
expect(merged.reason).toBe("First issue. Second issue. ");
|
|
93
|
+
expect(merged.suspectedUnused).toBe(true); // true OR false = true
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaveMoney Package
|
|
3
|
+
*
|
|
4
|
+
* A tool that analyzes cloud resources and reports potentially unused
|
|
5
|
+
* or cost-inefficient ones.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Multi-cloud support (Azure, AWS planned)
|
|
9
|
+
* - Metric-based analysis
|
|
10
|
+
* - Multiple output formats (table, JSON, detailed-JSON)
|
|
11
|
+
* - Configurable via CLI options, environment variables, or config file
|
|
12
|
+
*
|
|
13
|
+
* This tool does NOT modify, tag, or delete any resources.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Export common types
|
|
17
|
+
export type { AzureConfig } from "./azure/types.js";
|
|
18
|
+
export * from "./types.js";
|
|
19
|
+
|
|
20
|
+
// Export Azure module
|
|
21
|
+
import * as azureModule from "./azure/index.js";
|
|
22
|
+
export const azure = azureModule;
|
|
23
|
+
|
|
24
|
+
import { getLogger } from "@logtape/logtape";
|
|
25
|
+
// Utility imports for loadConfig and prompt functions
|
|
26
|
+
import * as fs from "fs";
|
|
27
|
+
import * as readline from "readline";
|
|
28
|
+
|
|
29
|
+
import type { AzureConfig } from "./azure/types.js";
|
|
30
|
+
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Prompts user for input via stdin.
|
|
84
|
+
*
|
|
85
|
+
* @param question - The question to display to the user
|
|
86
|
+
* @returns User's input as a string
|
|
87
|
+
*/
|
|
88
|
+
export async function prompt(question: string): Promise<string> {
|
|
89
|
+
const rl = readline.createInterface({
|
|
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
|
+
);
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common types shared across all cloud providers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type AnalysisResult = {
|
|
6
|
+
costRisk: CostRisk;
|
|
7
|
+
reason: string;
|
|
8
|
+
suspectedUnused: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base configuration interface that all cloud providers should extend
|
|
13
|
+
*/
|
|
14
|
+
export type BaseConfig = {
|
|
15
|
+
preferredLocation: string;
|
|
16
|
+
timespanDays: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CostRisk = "high" | "low" | "medium";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
|
|
23
|
+
*/
|
|
24
|
+
export function mergeResults(
|
|
25
|
+
baseResult: AnalysisResult,
|
|
26
|
+
specificResult: AnalysisResult,
|
|
27
|
+
): AnalysisResult {
|
|
28
|
+
return {
|
|
29
|
+
costRisk: specificResult.costRisk,
|
|
30
|
+
reason: baseResult.reason + specificResult.reason,
|
|
31
|
+
suspectedUnused:
|
|
32
|
+
baseResult.suspectedUnused || specificResult.suspectedUnused,
|
|
33
|
+
};
|
|
34
|
+
}
|