@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.
Files changed (83) hide show
  1. package/README.md +233 -0
  2. package/dist/azure/analyzer.d.ts +32 -0
  3. package/dist/azure/analyzer.d.ts.map +1 -0
  4. package/dist/azure/analyzer.js +128 -0
  5. package/dist/azure/analyzer.js.map +1 -0
  6. package/dist/azure/config.d.ts +19 -0
  7. package/dist/azure/config.d.ts.map +1 -0
  8. package/dist/azure/config.js +62 -0
  9. package/dist/azure/config.js.map +1 -0
  10. package/dist/azure/index.d.ts +9 -0
  11. package/dist/azure/index.d.ts.map +1 -0
  12. package/dist/azure/index.js +9 -0
  13. package/dist/azure/index.js.map +1 -0
  14. package/dist/azure/report.d.ts +12 -0
  15. package/dist/azure/report.d.ts.map +1 -0
  16. package/dist/azure/report.js +40 -0
  17. package/dist/azure/report.js.map +1 -0
  18. package/dist/azure/resources/app-service.d.ts +18 -0
  19. package/dist/azure/resources/app-service.d.ts.map +1 -0
  20. package/dist/azure/resources/app-service.js +65 -0
  21. package/dist/azure/resources/app-service.js.map +1 -0
  22. package/dist/azure/resources/disk.d.ts +16 -0
  23. package/dist/azure/resources/disk.d.ts.map +1 -0
  24. package/dist/azure/resources/disk.js +56 -0
  25. package/dist/azure/resources/disk.js.map +1 -0
  26. package/dist/azure/resources/index.d.ts +11 -0
  27. package/dist/azure/resources/index.d.ts.map +1 -0
  28. package/dist/azure/resources/index.js +11 -0
  29. package/dist/azure/resources/index.js.map +1 -0
  30. package/dist/azure/resources/nic.d.ts +15 -0
  31. package/dist/azure/resources/nic.d.ts.map +1 -0
  32. package/dist/azure/resources/nic.js +56 -0
  33. package/dist/azure/resources/nic.js.map +1 -0
  34. package/dist/azure/resources/private-endpoint.d.ts +15 -0
  35. package/dist/azure/resources/private-endpoint.d.ts.map +1 -0
  36. package/dist/azure/resources/private-endpoint.js +66 -0
  37. package/dist/azure/resources/private-endpoint.js.map +1 -0
  38. package/dist/azure/resources/public-ip.d.ts +18 -0
  39. package/dist/azure/resources/public-ip.d.ts.map +1 -0
  40. package/dist/azure/resources/public-ip.js +61 -0
  41. package/dist/azure/resources/public-ip.js.map +1 -0
  42. package/dist/azure/resources/storage.d.ts +16 -0
  43. package/dist/azure/resources/storage.d.ts.map +1 -0
  44. package/dist/azure/resources/storage.js +39 -0
  45. package/dist/azure/resources/storage.js.map +1 -0
  46. package/dist/azure/resources/vm.d.ts +19 -0
  47. package/dist/azure/resources/vm.d.ts.map +1 -0
  48. package/dist/azure/resources/vm.js +77 -0
  49. package/dist/azure/resources/vm.js.map +1 -0
  50. package/dist/azure/types.d.ts +34 -0
  51. package/dist/azure/types.d.ts.map +1 -0
  52. package/dist/azure/types.js +5 -0
  53. package/dist/azure/types.js.map +1 -0
  54. package/dist/azure/utils.d.ts +40 -0
  55. package/dist/azure/utils.d.ts.map +1 -0
  56. package/dist/azure/utils.js +104 -0
  57. package/dist/azure/utils.js.map +1 -0
  58. package/dist/index.d.ts +34 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +77 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/types.d.ts +21 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +14 -0
  65. package/dist/types.js.map +1 -0
  66. package/package.json +60 -0
  67. package/src/azure/analyzer.ts +213 -0
  68. package/src/azure/config.ts +81 -0
  69. package/src/azure/index.ts +9 -0
  70. package/src/azure/report.ts +52 -0
  71. package/src/azure/resources/app-service.ts +118 -0
  72. package/src/azure/resources/disk.ts +88 -0
  73. package/src/azure/resources/index.ts +11 -0
  74. package/src/azure/resources/nic.ts +90 -0
  75. package/src/azure/resources/private-endpoint.ts +112 -0
  76. package/src/azure/resources/public-ip.ts +106 -0
  77. package/src/azure/resources/storage.ts +67 -0
  78. package/src/azure/resources/vm.ts +129 -0
  79. package/src/azure/types.ts +38 -0
  80. package/src/azure/utils.ts +141 -0
  81. package/src/index.test.ts +95 -0
  82. package/src/index.ts +99 -0
  83. package/src/types.ts +34 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Azure configuration loading utilities
3
+ */
4
+
5
+ import { getLogger } from "@logtape/logtape";
6
+ import * as fs from "fs";
7
+ import * as readline from "readline";
8
+
9
+ import type { AzureConfig } from "./types.js";
10
+
11
+ /**
12
+ * Loads Azure configuration from file, environment variables, or interactive prompts.
13
+ *
14
+ * @param configPath - Optional path to JSON configuration file
15
+ * @returns Azure configuration object with subscription IDs and settings
16
+ */
17
+ export async function loadAzureConfig(
18
+ configPath?: string,
19
+ ): Promise<AzureConfig> {
20
+ if (configPath && fs.existsSync(configPath)) {
21
+ try {
22
+ const configContent = fs.readFileSync(configPath, "utf-8");
23
+ const config = JSON.parse(configContent) as Partial<AzureConfig>;
24
+
25
+ if (!config.tenantId || !config.subscriptionIds) {
26
+ throw new Error(
27
+ "Config file must contain 'tenantId' and 'subscriptionIds'",
28
+ );
29
+ }
30
+
31
+ return {
32
+ ...config,
33
+ preferredLocation: config.preferredLocation || "italynorth",
34
+ subscriptionIds: config.subscriptionIds,
35
+ tenantId: config.tenantId,
36
+ timespanDays: config.timespanDays || 30,
37
+ };
38
+ } catch (error) {
39
+ throw new Error(
40
+ `Failed to load config file: ${error instanceof Error ? error.message : error}`,
41
+ );
42
+ }
43
+ }
44
+
45
+ const logger = getLogger(["savemoney", "azure", "config"]);
46
+ logger.info(
47
+ "Configuration file not found. Checking environment variables...",
48
+ );
49
+
50
+ const tenantId =
51
+ process.env.ARM_TENANT_ID || (await prompt("Enter Tenant ID: "));
52
+ const subscriptionIds = process.env.ARM_SUBSCRIPTION_ID
53
+ ? process.env.ARM_SUBSCRIPTION_ID.split(",")
54
+ : (await prompt("Enter Subscription IDs (comma-separated): ")).split(",");
55
+
56
+ return {
57
+ preferredLocation: "italynorth",
58
+ subscriptionIds,
59
+ tenantId,
60
+ timespanDays: 30,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Prompts user for input via stdin.
66
+ *
67
+ * @param question - The question to display to the user
68
+ * @returns User's input as a string
69
+ */
70
+ export async function prompt(question: string): Promise<string> {
71
+ const rl = readline.createInterface({
72
+ input: process.stdin,
73
+ output: process.stdout,
74
+ });
75
+ return new Promise((resolve) =>
76
+ rl.question(question, (answer) => {
77
+ rl.close();
78
+ resolve(answer);
79
+ }),
80
+ );
81
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Azure module - Entry point
3
+ *
4
+ * This module contains all Azure-specific logic for analyzing resources.
5
+ */
6
+
7
+ export * from "./analyzer.js";
8
+ export * from "./config.js";
9
+ export * from "./types.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Azure report generation
3
+ */
4
+
5
+ import type {
6
+ AzureDetailedResourceReport,
7
+ AzureResourceReport,
8
+ } from "./types.js";
9
+
10
+ /**
11
+ * Generates a report from Azure resource analysis in the specified format.
12
+ *
13
+ * @param report - Array of detailed resource reports
14
+ * @param format - Output format (table, json, or detailed-json)
15
+ */
16
+ export async function generateReport(
17
+ report: AzureDetailedResourceReport[],
18
+ format: "detailed-json" | "json" | "table",
19
+ ) {
20
+ if (format === "detailed-json") {
21
+ console.log(JSON.stringify(report, null, 2));
22
+ return;
23
+ }
24
+
25
+ // For other formats, we extract the summary data.
26
+ const summaryReport: AzureResourceReport[] = report.map((r) => ({
27
+ costRisk: r.analysis.costRisk,
28
+ location: r.resource.location ?? "",
29
+ name: r.resource.name ?? "unknown",
30
+ reason: r.analysis.reason,
31
+ resourceGroup: r.resource.id?.split("/")[4],
32
+ subscriptionId: r.resource.id?.split("/")[2] ?? "unknown",
33
+ suspectedUnused: r.analysis.suspectedUnused,
34
+ type: r.resource.type ?? "unknown",
35
+ }));
36
+
37
+ if (format === "json") {
38
+ console.log(JSON.stringify(summaryReport, null, 2));
39
+ } else {
40
+ console.table(
41
+ summaryReport.map((r) => ({
42
+ Name: r.name,
43
+ Reason: r.reason,
44
+ "Resource Group": r.resourceGroup || "N/A",
45
+ Risk: r.costRisk,
46
+ Type: r.type,
47
+ Unused: r.suspectedUnused ? "Yes" : "No",
48
+ })),
49
+ ["Name", "Type", "Resource Group", "Risk", "Unused", "Reason"],
50
+ );
51
+ }
52
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Azure App Service Plan analysis
3
+ */
4
+
5
+ import type { WebSiteManagementClient } from "@azure/arm-appservice";
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 App Service Plan for potential cost optimization.
22
+ *
23
+ * @param resource - The Azure resource object
24
+ * @param webSiteClient - Azure Web Site client for App Service Plan details
25
+ * @param monitorClient - Azure Monitor client for metrics
26
+ * @param timespanDays - Number of days to analyze metrics
27
+ * @returns Analysis result with cost risk and reason
28
+ */
29
+ export async function analyzeAppServicePlan(
30
+ resource: armResources.GenericResource,
31
+ webSiteClient: WebSiteManagementClient,
32
+ monitorClient: MonitorClient,
33
+ timespanDays: number,
34
+ verbose = false,
35
+ ): Promise<AnalysisResult> {
36
+ verboseLogResourceStart(
37
+ verbose,
38
+ resource.name || "unknown",
39
+ "App Service Plan (microsoft.web/serverfarms)",
40
+ );
41
+ verboseLog(verbose, "Resource details:", resource);
42
+
43
+ const costRisk: "high" | "low" | "medium" = "high";
44
+ let reason = "";
45
+
46
+ if (!resource.id) {
47
+ return {
48
+ costRisk,
49
+ reason: "Resource ID is missing.",
50
+ suspectedUnused: false,
51
+ };
52
+ }
53
+
54
+ // Extract resource group and App Service Plan name from resource ID
55
+ const resourceParts = resource.id.split("/");
56
+ const resourceGroupName = resourceParts[4];
57
+ const planName = resourceParts[8];
58
+
59
+ try {
60
+ // Get detailed App Service Plan information
61
+ const planDetails = await webSiteClient.appServicePlans.get(
62
+ resourceGroupName,
63
+ planName,
64
+ );
65
+
66
+ verboseLog(verbose, "App Service Plan API details:", planDetails);
67
+
68
+ // Check if the plan has no apps
69
+ if (!planDetails.numberOfSites || planDetails.numberOfSites === 0) {
70
+ reason += "App Service Plan has no apps deployed. ";
71
+ }
72
+
73
+ // Check CPU and Memory metrics
74
+ const cpuPercentage = await getMetric(
75
+ monitorClient,
76
+ resource.id,
77
+ "CpuPercentage",
78
+ "Average",
79
+ timespanDays,
80
+ );
81
+
82
+ const memoryPercentage = await getMetric(
83
+ monitorClient,
84
+ resource.id,
85
+ "MemoryPercentage",
86
+ "Average",
87
+ timespanDays,
88
+ );
89
+
90
+ if (cpuPercentage !== null && cpuPercentage < 5) {
91
+ reason += `Very low CPU usage (${cpuPercentage.toFixed(2)}%). `;
92
+ }
93
+
94
+ if (memoryPercentage !== null && memoryPercentage < 10) {
95
+ reason += `Very low memory usage (${memoryPercentage.toFixed(2)}%). `;
96
+ }
97
+
98
+ // Check if it's an oversized plan (Premium tier with low usage)
99
+ if (
100
+ planDetails.sku?.tier?.includes("Premium") &&
101
+ cpuPercentage &&
102
+ cpuPercentage < 10
103
+ ) {
104
+ reason += "Premium tier with low resource utilization. ";
105
+ }
106
+ } catch (error) {
107
+ const logger = getLogger(["savemoney", "azure"]);
108
+ logger.warn(
109
+ `Failed to get App Service Plan details for ${planName}: ${error instanceof Error ? error.message : error}`,
110
+ );
111
+ reason += "Could not retrieve detailed App Service Plan information. ";
112
+ }
113
+
114
+ const suspectedUnused = reason.length > 0;
115
+ const result = { costRisk, reason: reason.trim(), suspectedUnused };
116
+ verboseLogAnalysisResult(verbose, result);
117
+ return result;
118
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Azure Managed Disk analysis
3
+ */
4
+
5
+ import type { ComputeManagementClient } from "@azure/arm-compute";
6
+
7
+ import * as armResources from "@azure/arm-resources";
8
+ import { getLogger } from "@logtape/logtape";
9
+
10
+ import type { AnalysisResult } from "../../types.js";
11
+
12
+ import {
13
+ verboseLog,
14
+ verboseLogAnalysisResult,
15
+ verboseLogResourceStart,
16
+ } from "../utils.js";
17
+
18
+ /**
19
+ * Analyzes an Azure Managed Disk for potential cost optimization.
20
+ *
21
+ * @param resource - The Azure resource object
22
+ * @param computeClient - Azure Compute client for disk details
23
+ * @param verbose - Whether verbose logging is enabled
24
+ * @returns Analysis result with cost risk and reason
25
+ */
26
+ export async function analyzeDisk(
27
+ resource: armResources.GenericResource,
28
+ computeClient: ComputeManagementClient,
29
+ verbose = false,
30
+ ): Promise<AnalysisResult> {
31
+ verboseLogResourceStart(
32
+ verbose,
33
+ resource.name || "unknown",
34
+ "Managed Disk (microsoft.compute/disks)",
35
+ );
36
+ verboseLog(verbose, "Resource details:", resource);
37
+
38
+ const costRisk: "high" | "low" | "medium" = "medium";
39
+
40
+ if (!resource.id) {
41
+ return {
42
+ costRisk,
43
+ reason: "Resource ID is missing.",
44
+ suspectedUnused: false,
45
+ };
46
+ }
47
+
48
+ // Extract resource group and disk name from resource ID
49
+ const resourceParts = resource.id.split("/");
50
+ const resourceGroupName = resourceParts[4];
51
+ const diskName = resourceParts[8];
52
+
53
+ try {
54
+ // Get the actual disk details to check attachment state
55
+ const diskDetails = await computeClient.disks.get(
56
+ resourceGroupName,
57
+ diskName,
58
+ );
59
+
60
+ verboseLog(verbose, "Disk API details:", diskDetails);
61
+
62
+ // Check if disk is unattached
63
+ if (
64
+ diskDetails.diskState?.toLowerCase() === "unattached" ||
65
+ !diskDetails.managedBy
66
+ ) {
67
+ const result = {
68
+ costRisk,
69
+ reason: "Disk is unattached. ",
70
+ suspectedUnused: true,
71
+ };
72
+ verboseLogAnalysisResult(verbose, result);
73
+ return result;
74
+ }
75
+ } catch (error) {
76
+ const logger = getLogger(["savemoney", "azure"]);
77
+ logger.warn(
78
+ `Failed to get disk details for ${diskName}: ${error instanceof Error ? error.message : error}`,
79
+ );
80
+ // Fallback to checking properties if API call fails
81
+ // Note: GenericResource doesn't have diskState property
82
+ // Without API access, we can't determine if disk is unattached
83
+ }
84
+
85
+ const result = { costRisk, reason: "", suspectedUnused: false };
86
+ verboseLogAnalysisResult(verbose, result);
87
+ return result;
88
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Azure resource-specific analysis functions
3
+ */
4
+
5
+ export { analyzeAppServicePlan } from "./app-service.js";
6
+ export { analyzeDisk } from "./disk.js";
7
+ export { analyzeNic } from "./nic.js";
8
+ export { analyzePrivateEndpoint } from "./private-endpoint.js";
9
+ export { analyzePublicIp } from "./public-ip.js";
10
+ export { analyzeStorageAccount } from "./storage.js";
11
+ export { analyzeVM } from "./vm.js";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Azure Network Interface analysis
3
+ */
4
+
5
+ import type { NetworkManagementClient } from "@azure/arm-network";
6
+
7
+ import * as armResources from "@azure/arm-resources";
8
+ import { getLogger } from "@logtape/logtape";
9
+
10
+ import type { AnalysisResult } from "../../types.js";
11
+
12
+ import {
13
+ verboseLog,
14
+ verboseLogAnalysisResult,
15
+ verboseLogResourceStart,
16
+ } from "../utils.js";
17
+
18
+ /**
19
+ * Analyzes an Azure Network Interface for potential cost optimization.
20
+ *
21
+ * @param resource - The Azure resource object
22
+ * @param networkClient - Azure Network client for NIC details
23
+ * @returns Analysis result with cost risk and reason
24
+ */
25
+ export async function analyzeNic(
26
+ resource: armResources.GenericResource,
27
+ networkClient: NetworkManagementClient,
28
+ verbose = false,
29
+ ): Promise<AnalysisResult> {
30
+ verboseLogResourceStart(
31
+ verbose,
32
+ resource.name || "unknown",
33
+ "Network Interface (microsoft.network/networkinterfaces)",
34
+ );
35
+ verboseLog(verbose, "Resource details:", resource);
36
+
37
+ const costRisk: "high" | "low" | "medium" = "medium";
38
+ let reason = "";
39
+
40
+ if (!resource.id) {
41
+ return {
42
+ costRisk,
43
+ reason: "Resource ID is missing.",
44
+ suspectedUnused: false,
45
+ };
46
+ }
47
+
48
+ // Extract resource group and NIC name from resource ID
49
+ const resourceParts = resource.id.split("/");
50
+ const resourceGroupName = resourceParts[4];
51
+ const nicName = resourceParts[8];
52
+
53
+ try {
54
+ // Get detailed NIC information
55
+ const nicDetails = await networkClient.networkInterfaces.get(
56
+ resourceGroupName,
57
+ nicName,
58
+ );
59
+
60
+ verboseLog(verbose, "NIC API details:", nicDetails);
61
+
62
+ // Check if NIC is not attached to any VM or private endpoint
63
+ if (!nicDetails.virtualMachine && !nicDetails.privateEndpoint) {
64
+ reason += "NIC not attached to any VM or private endpoint. ";
65
+ }
66
+
67
+ // Check if NIC has no public IP
68
+ const hasPublicIP = nicDetails.ipConfigurations?.some(
69
+ (config) => config.publicIPAddress,
70
+ );
71
+ if (!hasPublicIP) {
72
+ reason += "No public IP assigned. ";
73
+ }
74
+
75
+ // Note: Network Interface metrics are not available for Private Endpoint NICs
76
+ // and most Azure NICs don't expose standard traffic metrics through Azure Monitor
77
+ // The primary checks (attachment to VM and public IP assignment) are sufficient
78
+ } catch (error) {
79
+ const logger = getLogger(["savemoney", "azure"]);
80
+ logger.warn(
81
+ `Failed to get NIC details for ${nicName}: ${error instanceof Error ? error.message : error}`,
82
+ );
83
+ reason += "Could not retrieve detailed NIC information. ";
84
+ }
85
+
86
+ const suspectedUnused = reason.length > 0;
87
+ const result = { costRisk, reason: reason.trim(), suspectedUnused };
88
+ verboseLogAnalysisResult(verbose, result);
89
+ return result;
90
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Azure Private Endpoint analysis
3
+ */
4
+
5
+ import type { NetworkManagementClient } from "@azure/arm-network";
6
+
7
+ import * as armResources from "@azure/arm-resources";
8
+ import { getLogger } from "@logtape/logtape";
9
+
10
+ import type { AnalysisResult } from "../../types.js";
11
+
12
+ import {
13
+ verboseLog,
14
+ verboseLogAnalysisResult,
15
+ verboseLogResourceStart,
16
+ } from "../utils.js";
17
+
18
+ /**
19
+ * Analyzes an Azure Private Endpoint for potential cost optimization.
20
+ *
21
+ * @param resource - The Azure resource object
22
+ * @param networkClient - Azure Network client for Private Endpoint details
23
+ * @returns Analysis result with cost risk and reason
24
+ */
25
+ export async function analyzePrivateEndpoint(
26
+ resource: armResources.GenericResource,
27
+ networkClient: NetworkManagementClient,
28
+ verbose = false,
29
+ ): Promise<AnalysisResult> {
30
+ verboseLogResourceStart(
31
+ verbose,
32
+ resource.name || "unknown",
33
+ "Private Endpoint (microsoft.network/privateendpoints)",
34
+ );
35
+ verboseLog(verbose, "Resource details:", resource);
36
+
37
+ const costRisk: "high" | "low" | "medium" = "medium";
38
+ let reason = "";
39
+
40
+ if (!resource.id) {
41
+ return {
42
+ costRisk,
43
+ reason: "Resource ID is missing.",
44
+ suspectedUnused: false,
45
+ };
46
+ }
47
+
48
+ // Extract resource group and private endpoint name from resource ID
49
+ const resourceParts = resource.id.split("/");
50
+ const resourceGroupName = resourceParts[4];
51
+ const privateEndpointName = resourceParts[8];
52
+
53
+ try {
54
+ // Get detailed Private Endpoint information
55
+ const privateEndpointDetails = await networkClient.privateEndpoints.get(
56
+ resourceGroupName,
57
+ privateEndpointName,
58
+ );
59
+
60
+ verboseLog(
61
+ verbose,
62
+ "Private Endpoint API details:",
63
+ privateEndpointDetails,
64
+ );
65
+
66
+ // Check if Private Endpoint has no private link service connection
67
+ if (
68
+ !privateEndpointDetails.privateLinkServiceConnections ||
69
+ privateEndpointDetails.privateLinkServiceConnections.length === 0
70
+ ) {
71
+ reason +=
72
+ "Private Endpoint has no private link service connections configured. ";
73
+ }
74
+
75
+ // Check connection state
76
+ const hasFailedConnection =
77
+ privateEndpointDetails.privateLinkServiceConnections?.some(
78
+ (connection) =>
79
+ connection.privateLinkServiceConnectionState?.status === "Rejected" ||
80
+ connection.privateLinkServiceConnectionState?.status ===
81
+ "Disconnected",
82
+ );
83
+
84
+ if (hasFailedConnection) {
85
+ reason += "Private Endpoint has rejected or disconnected connections. ";
86
+ }
87
+
88
+ // Check if Private Endpoint has network interfaces
89
+ if (
90
+ !privateEndpointDetails.networkInterfaces ||
91
+ privateEndpointDetails.networkInterfaces.length === 0
92
+ ) {
93
+ reason += "Private Endpoint has no network interfaces attached. ";
94
+ }
95
+
96
+ // Check subnet configuration
97
+ if (!privateEndpointDetails.subnet) {
98
+ reason += "Private Endpoint is not associated with a subnet. ";
99
+ }
100
+ } catch (error) {
101
+ const logger = getLogger(["savemoney", "azure"]);
102
+ logger.warn(
103
+ `Failed to get Private Endpoint details for ${privateEndpointName}: ${error instanceof Error ? error.message : error}`,
104
+ );
105
+ reason += "Could not retrieve detailed Private Endpoint information. ";
106
+ }
107
+
108
+ const suspectedUnused = reason.length > 0;
109
+ const result = { costRisk, reason: reason.trim(), suspectedUnused };
110
+ verboseLogAnalysisResult(verbose, result);
111
+ return result;
112
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Azure Public IP analysis
3
+ */
4
+
5
+ import type { MonitorClient } from "@azure/arm-monitor";
6
+ import type { NetworkManagementClient } from "@azure/arm-network";
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 Public IP for potential cost optimization.
22
+ *
23
+ * @param resource - The Azure resource object
24
+ * @param networkClient - Azure Network client for Public IP details
25
+ * @param monitorClient - Azure Monitor client for metrics
26
+ * @param timespanDays - Number of days to analyze metrics
27
+ * @returns Analysis result with cost risk and reason
28
+ */
29
+ export async function analyzePublicIp(
30
+ resource: armResources.GenericResource,
31
+ networkClient: NetworkManagementClient,
32
+ monitorClient: MonitorClient,
33
+ timespanDays: number,
34
+ verbose = false,
35
+ ): Promise<AnalysisResult> {
36
+ verboseLogResourceStart(
37
+ verbose,
38
+ resource.name || "unknown",
39
+ "Public IP (microsoft.network/publicipaddresses)",
40
+ );
41
+ verboseLog(verbose, "Resource details:", resource);
42
+
43
+ const costRisk: "high" | "low" | "medium" = "medium";
44
+ let reason = "";
45
+
46
+ if (!resource.id) {
47
+ return {
48
+ costRisk,
49
+ reason: "Resource ID is missing.",
50
+ suspectedUnused: false,
51
+ };
52
+ }
53
+
54
+ // Extract resource group and public IP name from resource ID
55
+ const resourceParts = resource.id.split("/");
56
+ const resourceGroupName = resourceParts[4];
57
+ const publicIpName = resourceParts[8];
58
+
59
+ try {
60
+ // Get detailed Public IP information
61
+ const publicIpDetails = await networkClient.publicIPAddresses.get(
62
+ resourceGroupName,
63
+ publicIpName,
64
+ );
65
+
66
+ verboseLog(verbose, "Public IP API details:", publicIpDetails);
67
+
68
+ // Check if Public IP is not associated with any resource
69
+ if (!publicIpDetails.ipConfiguration && !publicIpDetails.natGateway) {
70
+ reason += "Public IP not associated with any resource. ";
71
+ }
72
+
73
+ // Check if it's a static IP that might be unused
74
+ if (
75
+ publicIpDetails.publicIPAllocationMethod === "Static" &&
76
+ !publicIpDetails.ipConfiguration
77
+ ) {
78
+ reason += "Static IP not in use. ";
79
+ }
80
+
81
+ // Check network metrics for low usage
82
+ const bytesInDDoS = await getMetric(
83
+ monitorClient,
84
+ resource.id,
85
+ "BytesInDDoS",
86
+ "Total",
87
+ timespanDays,
88
+ );
89
+
90
+ if (bytesInDDoS !== null && bytesInDDoS < 1000000) {
91
+ // Less than 1MB total in 30 days
92
+ reason += `Very low network traffic (${(bytesInDDoS / 1024 / 1024).toFixed(2)} MB). `;
93
+ }
94
+ } catch (error) {
95
+ const logger = getLogger(["savemoney", "azure"]);
96
+ logger.warn(
97
+ `Failed to get Public IP details for ${publicIpName}: ${error instanceof Error ? error.message : error}`,
98
+ );
99
+ reason += "Could not retrieve detailed Public IP information. ";
100
+ }
101
+
102
+ const suspectedUnused = reason.length > 0;
103
+ const result = { costRisk, reason: reason.trim(), suspectedUnused };
104
+ verboseLogAnalysisResult(verbose, result);
105
+ return result;
106
+ }