"""Server capacity operations.""" from datetime import datetime from kubernetes_service import custom_objects_api, k8s_manager, v1 from runtime_config import ( logger, ) from typing import Dict, Tuple from user_service import get_pricing __all__ = ['calculate_monthly_server_cost', 'calculate_server_cost', 'check_longhorn_storage_capacity', 'get_cluster_capacity', 'parse_kubernetes_resource', 'parse_resource_to_mebibytes', 'parse_resource_to_millicores', 'parse_storage_to_gb', 'validate_server_capacity'] def check_longhorn_storage_capacity(required_storage_bytes: int, replicas: int = 3) -> Tuple[bool, str]: """Check if Longhorn cluster has enough capacity for a new volume""" try: # Get Longhorn nodes lh_nodes = custom_objects_api.list_namespaced_custom_object( group="longhorn.io", version="v1beta2", namespace="longhorn-system", plural="nodes" ) schedulable_nodes = 0 nodes_with_space = [] for item in lh_nodes.get('items', []): node_name = item['metadata']['name'] spec = item.get('spec', {}) status = item.get('status', {}) disk_status = status.get('diskStatus', {}) # Check if node allows scheduling if not spec.get('allowScheduling', True): continue # Check available storage on disks node_has_space = False for disk_uuid, disk_info in disk_status.items(): # Check individual disk schedulable status if available in conditions conditions = disk_info.get('conditions', []) # Check if disk is schedulable schedulable_status = "True" if isinstance(conditions, list): schedulable_cond = next((c for c in conditions if c.get('type') == 'Schedulable'), {}) schedulable_status = schedulable_cond.get('status', 'True') elif isinstance(conditions, dict): schedulable_status = conditions.get('Schedulable', {}).get('status', 'True') if schedulable_status == 'False': continue # storageScheduled is the space available for scheduling new replicas (after over-provisioning) storage_schedulable = int(disk_info.get('storageScheduled', 0)) if storage_schedulable >= required_storage_bytes: node_has_space = True # We only need one suitable disk per node break if node_has_space: schedulable_nodes += 1 nodes_with_space.append(node_name) logger.info(f"Longhorn storage check: {schedulable_nodes} nodes have >{required_storage_bytes/1024/1024/1024:.1f}GB schedulable. Nodes: {', '.join(nodes_with_space)}") if schedulable_nodes >= replicas: return True, f"Storage available on {schedulable_nodes} nodes" else: return False, f"Longhorn needs {replicas} nodes with {required_storage_bytes/1024/1024/1024:.1f}GB space, but only found {schedulable_nodes}: {', '.join(nodes_with_space)}" except Exception as e: logger.warning(f"Could not check Longhorn storage capacity: {e}") # Fallback: assume True if we can't check (e.g. permission error or API down), # but log warning. return True, f"Skipped storage check: {e}" def validate_server_capacity(server_config: Dict) -> Dict: """ Validate if there's sufficient capacity for the requested server. Uses Kubernetes allocatable resources which already account for system reservations. """ try: # Extract resource requirements cpu_cores = int(server_config['cpu'].split()[0]) ram_gb = int(server_config['ram'].replace('GB', '')) storage_gb = int(server_config['storage'].replace('GB', '')) # Convert to Kubernetes units # Use 50m (5%) per core for requests and 50% of RAM for requests # to allow smarter overcommitment while keeping limits at 100% cpu_request_millicores = cpu_cores * 50 memory_request_mebibytes = (ram_gb * 1024) * 0.5 storage_request_gb = storage_gb if not k8s_manager.connected: # Development mode - assume unlimited capacity return { 'success': True, 'message': 'Development mode: Capacity validation skipped', 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" } } } # Check Longhorn storage capacity storage_request_bytes = storage_gb * 1024 * 1024 * 1024 storage_ok, storage_msg = check_longhorn_storage_capacity(storage_request_bytes) if not storage_ok: logger.warning(f"❌ Storage capacity check failed: {storage_msg}") return { 'success': False, 'message': f'Insufficient storage capacity: {storage_msg}', 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" } } } try: # Get ALL nodes including control-plane nodes = v1.list_node() all_nodes = list(nodes.items) if not all_nodes: return { 'success': False, 'message': 'No nodes available in the cluster', 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" } } } # Check each node for capacity suitable_nodes = [] all_details = [] for node in all_nodes: node_name = node.metadata.name node_details = {'node': node_name, 'suitable': True, 'reasons': []} # Check node type labels = node.metadata.labels or {} is_control_plane = 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels node_type = 'control-plane' if is_control_plane else 'worker' node_details['type'] = node_type # Get node allocatable resources - THIS ALREADY EXCLUDES SYSTEM RESERVATIONS allocatable = node.status.allocatable # Parse available resources (allocatable = total - system reservations) allocatable_cpu_millicores = parse_resource_to_millicores(allocatable.get('cpu', '0')) allocatable_memory_mebibytes = parse_resource_to_mebibytes(allocatable.get('memory', '0')) # For storage, use capacity since ephemeral-storage might not be in allocatable capacity = node.status.capacity available_storage_gb = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) # Get currently allocated resources from running pods pods = v1.list_pod_for_all_namespaces(field_selector=f"spec.nodeName={node_name}") used_cpu_millicores = 0 used_memory_mebibytes = 0 for pod in pods.items: if pod.status.phase in ['Running', 'Pending']: for container in pod.spec.containers: resources = container.resources if resources and resources.requests: if 'cpu' in resources.requests: used_cpu_millicores += parse_resource_to_millicores(resources.requests['cpu']) if 'memory' in resources.requests: used_memory_mebibytes += parse_resource_to_mebibytes(resources.requests['memory']) # Calculate free resources # NO BUFFER NEEDED - allocatable already accounts for system reservations! free_cpu_millicores = allocatable_cpu_millicores - used_cpu_millicores free_memory_mebibytes = allocatable_memory_mebibytes - used_memory_mebibytes # For storage, assume 20% is used by system/overhead free_storage_gb = available_storage_gb * 0.8 # Debug logging logger.info(f"Node {node_name} ({node_type}):") logger.info(f" Allocatable: CPU={allocatable_cpu_millicores/1000:.1f}v, RAM={allocatable_memory_mebibytes/1024:.1f}GB") logger.info(f" Used by pods: CPU={used_cpu_millicores/1000:.1f}v, RAM={used_memory_mebibytes/1024:.1f}GB") logger.info(f" Free for new pods: CPU={free_cpu_millicores/1000:.1f}v, RAM={free_memory_mebibytes/1024:.1f}GB") logger.info(f" Storage: {free_storage_gb:.1f}GB free of {available_storage_gb:.1f}GB") logger.info(f" Requested: CPU={cpu_cores}v, RAM={ram_gb}GB, Storage={storage_gb}GB") # Check if node has enough resources cpu_passed = free_cpu_millicores >= cpu_request_millicores memory_passed = free_memory_mebibytes >= memory_request_mebibytes storage_passed = free_storage_gb >= storage_request_gb if not cpu_passed: node_details['suitable'] = False node_details['reasons'].append( f"CPU: {free_cpu_millicores/1000:.1f}v free < {cpu_cores}v needed" ) if not memory_passed: node_details['suitable'] = False node_details['reasons'].append( f"RAM: {free_memory_mebibytes/1024:.1f}GB free < {ram_gb}GB needed" ) if not storage_passed: node_details['suitable'] = False node_details['reasons'].append( f"Storage: {free_storage_gb:.1f}GB free < {storage_gb}GB needed" ) # Add resource details node_details.update({ 'free_cpu': free_cpu_millicores, 'free_memory': free_memory_mebibytes, 'free_storage': free_storage_gb, 'requested_cpu': cpu_request_millicores, 'requested_memory': memory_request_mebibytes, 'requested_storage': storage_request_gb, 'is_control_plane': is_control_plane, 'allocatable_cpu': allocatable_cpu_millicores, 'allocatable_memory': allocatable_memory_mebibytes, 'available_storage': available_storage_gb, 'used_cpu': used_cpu_millicores, 'used_memory': used_memory_mebibytes }) all_details.append(node_details) if node_details['suitable']: suitable_nodes.append({ 'name': node_name, 'type': node_type, 'free_cpu': free_cpu_millicores, 'free_memory': free_memory_mebibytes, 'free_storage': free_storage_gb }) logger.info(f"✅ Node {node_name} ({node_type}) has sufficient capacity") else: logger.info(f"❌ Node {node_name} ({node_type}) insufficient: {node_details['reasons']}") # Check if we found any suitable nodes if suitable_nodes: suitable_node_names = [n['name'] for n in suitable_nodes] return { 'success': True, 'message': f'Sufficient capacity available on {len(suitable_nodes)} node(s)', 'suitable_nodes': suitable_node_names, 'node_details': all_details, 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" }, 'suitable_nodes_count': len(suitable_nodes), 'total_nodes': len(all_nodes) } } else: error_details = [] for detail in all_details: if detail['reasons']: error_details.append(f"{detail['node']} ({detail['type']}): {', '.join(detail['reasons'])}") return { 'success': False, 'message': f'No nodes have sufficient capacity', 'details': all_details, 'error_details': error_details, 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" }, 'total_nodes': len(all_nodes), 'suitable_nodes': 0 } } except Exception as k8s_error: logger.error(f"Error checking Kubernetes capacity: {k8s_error}") return { 'success': False, 'message': f'Error checking cluster capacity: {str(k8s_error)}', 'summary': { 'requested': { 'cpu': f"{cpu_cores} vCPU", 'ram': f"{ram_gb}GB", 'storage': f"{storage_gb}GB" } } } except Exception as e: logger.error(f"Error in capacity validation: {e}") return { 'success': False, 'message': f'Capacity validation error: {str(e)}' } def parse_resource_to_millicores(resource_str: str) -> float: """Parse CPU resource string to millicores""" if not resource_str: return 0.0 resource_str = str(resource_str).strip() try: if resource_str.endswith('m'): # Millicores return float(resource_str[:-1]) elif resource_str.replace('.', '', 1).isdigit(): # Cores (e.g., "1", "0.5", "2.5") return float(resource_str) * 1000 elif resource_str.endswith('n'): # Nanocores return float(resource_str[:-1]) / 1000000 else: # Try to parse as plain number return float(resource_str) * 1000 except Exception: return 0.0 def parse_resource_to_mebibytes(resource_str: str) -> float: """Parse memory resource string to MiB (mebibytes)""" if not resource_str: return 0.0 resource_str = str(resource_str).strip() try: # Remove any spaces resource_str = resource_str.replace(' ', '') if resource_str.endswith('Ki'): # Kibibytes return float(resource_str[:-2]) / 1024 elif resource_str.endswith('Mi'): # Mebibytes return float(resource_str[:-2]) elif resource_str.endswith('Gi'): # Gibibytes return float(resource_str[:-2]) * 1024 elif resource_str.endswith('Ti'): # Tebibytes return float(resource_str[:-2]) * 1024 * 1024 elif resource_str.endswith('K'): # Kilobytes (SI) return float(resource_str[:-1]) / 1000 / 1024 * 1000 # Convert KiB to MiB elif resource_str.endswith('M'): # Megabytes (SI) return float(resource_str[:-1]) / 1024 * 1000 # Convert MB to MiB elif resource_str.endswith('G'): # Gigabytes (SI) return float(resource_str[:-1]) * 1000 # Convert GB to MiB else: # Assume it's already in bytes try: bytes_val = float(resource_str) return bytes_val / (1024 * 1024) # Convert bytes to MiB except: return 0.0 except Exception as e: logger.warning(f"Error parsing memory '{resource_str}': {e}") return 0.0 def parse_storage_to_gb(resource_str: str) -> float: """Parse storage resource string to GB""" if not resource_str: return 0.0 resource_str = str(resource_str).strip() try: # Remove any spaces resource_str = resource_str.replace(' ', '') if resource_str.endswith('Ki'): # Kibibytes return float(resource_str[:-2]) / (1024 * 1024) elif resource_str.endswith('Mi'): # Mebibytes return float(resource_str[:-2]) / 1024 elif resource_str.endswith('Gi'): # Gibibytes (1 GiB = 1.07374 GB) return float(resource_str[:-2]) * 1.07374 elif resource_str.endswith('Ti'): # Tebibytes return float(resource_str[:-2]) * 1024 * 1.07374 elif resource_str.endswith('K'): # Kilobytes (SI) return float(resource_str[:-1]) / (1000 * 1000) elif resource_str.endswith('M'): # Megabytes (SI) return float(resource_str[:-1]) / 1000 elif resource_str.endswith('G'): # Gigabytes (SI) return float(resource_str[:-1]) elif resource_str.endswith('T'): # Terabytes (SI) return float(resource_str[:-1]) * 1000 else: # Assume it's already in bytes try: bytes_val = float(resource_str) return bytes_val / (1000 * 1000 * 1000) # Convert bytes to GB except: return 0.0 except Exception as e: logger.warning(f"Error parsing storage '{resource_str}': {e}") return 0.0 def parse_kubernetes_resource(resource_str: str) -> float: """ Parse Kubernetes resource strings to numeric values. Supports: - CPU: "100m" = 100 millicores, "1" = 1 core, "0.5" = 500m - Memory: "1Gi" = 1024Mi, "1G" = 1000Mi, "100Mi" = 100Mi, "1M" = 1Mi - Storage: "10Gi" = 10GiB, "100G" = 100GB Returns: float representing the resource in base units: - CPU: millicores - Memory: MiB - Storage: GB """ if not resource_str: return 0.0 resource_str = str(resource_str).strip() try: # Handle CPU resources if resource_str.endswith('m'): # Millicores return float(resource_str[:-1]) elif resource_str.endswith('n'): # Nanocores return float(resource_str[:-1]) / 1000000 elif resource_str.replace('.', '', 1).isdigit(): # Cores (e.g., "1", "0.5", "2.5") return float(resource_str) * 1000 # Handle memory and storage resources # Get numeric value import re match = re.match(r'^([0-9.]+)([a-zA-Z]*)$', resource_str) if not match: # Try to parse as plain number try: return float(resource_str) except: return 0.0 value = float(match.group(1)) unit = match.group(2).upper() # Memory conversions to MiB if unit == 'KI' or unit == 'K': return value / 1024 # KiB to MiB elif unit == 'MI' or unit == 'M': return value # MiB elif unit == 'GI' or unit == 'G': return value * 1024 # GiB to MiB elif unit == 'TI' or unit == 'T': return value * 1024 * 1024 # TiB to MiB # If no unit, assume it's already in appropriate units return value except Exception as e: logger.warning(f"Error parsing resource string '{resource_str}': {e}") return 0.0 def get_cluster_capacity() -> Dict: """Get cluster capacity using allocatable resources.""" try: if not k8s_manager.connected: return { 'success': False, 'message': 'Kubernetes not connected' } nodes = v1.list_node() all_nodes = nodes.items total_allocatable_cpu = 0 total_allocatable_memory = 0 total_storage = 0 total_used_cpu = 0 total_used_memory = 0 node_details = [] # Get pod allocations all_pods = v1.list_pod_for_all_namespaces() pod_allocations = {} for pod in all_pods.items: if pod.spec.node_name and pod.status.phase in ['Running', 'Pending']: node_name = pod.spec.node_name pod_cpu = 0 pod_memory = 0 for container in pod.spec.containers: resources = container.resources if resources and resources.requests: if 'cpu' in resources.requests: pod_cpu += parse_resource_to_millicores(resources.requests['cpu']) if 'memory' in resources.requests: pod_memory += parse_resource_to_mebibytes(resources.requests['memory']) if node_name not in pod_allocations: pod_allocations[node_name] = {'cpu': 0, 'memory': 0} pod_allocations[node_name]['cpu'] += pod_cpu pod_allocations[node_name]['memory'] += pod_memory for node in all_nodes: node_name = node.metadata.name # Node type labels = node.metadata.labels or {} is_control_plane = 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels node_type = 'control-plane' if is_control_plane else 'worker' # Allocatable resources allocatable = node.status.allocatable cpu = parse_resource_to_millicores(allocatable.get('cpu', '0')) memory = parse_resource_to_mebibytes(allocatable.get('memory', '0')) # Storage capacity capacity = node.status.capacity storage = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) total_allocatable_cpu += cpu total_allocatable_memory += memory total_storage += storage # Used resources node_alloc = pod_allocations.get(node_name, {'cpu': 0, 'memory': 0}) total_used_cpu += node_alloc['cpu'] total_used_memory += node_alloc['memory'] # Free resources free_cpu = cpu - node_alloc['cpu'] free_memory = memory - node_alloc['memory'] free_storage = storage * 0.8 # 80% available # Utilization cpu_percent = (node_alloc['cpu'] / cpu * 100) if cpu > 0 else 0 memory_percent = (node_alloc['memory'] / memory * 100) if memory > 0 else 0 node_details.append({ 'name': node_name, 'type': node_type, 'allocatable': { 'cpu': cpu, 'cpu_cores': cpu / 1000, 'memory': memory, 'memory_gb': memory / 1024, 'storage': storage, 'storage_gb': storage }, 'used': { 'cpu': node_alloc['cpu'], 'memory': node_alloc['memory'] }, 'free': { 'cpu': free_cpu, 'memory': free_memory, 'storage': free_storage }, 'utilization': { 'cpu_percent': round(cpu_percent, 1), 'memory_percent': round(memory_percent, 1) } }) # Calculate totals free_cpu = total_allocatable_cpu - total_used_cpu free_memory = total_allocatable_memory - total_used_memory free_storage = total_storage * 0.8 overall_cpu_percent = (total_used_cpu / total_allocatable_cpu * 100) if total_allocatable_cpu > 0 else 0 overall_memory_percent = (total_used_memory / total_allocatable_memory * 100) if total_allocatable_memory > 0 else 0 return { 'success': True, 'message': f'Cluster capacity: {len(all_nodes)} total nodes', 'total_nodes': len(all_nodes), 'total_capacity': { 'cpu': total_allocatable_cpu, 'cpu_cores': total_allocatable_cpu / 1000, 'memory': total_allocatable_memory, 'memory_gb': total_allocatable_memory / 1024, 'storage': total_storage, 'storage_gb': total_storage }, 'used': { 'cpu': total_used_cpu, 'memory': total_used_memory }, 'available': { 'cpu': free_cpu, 'memory': free_memory, 'storage': free_storage }, 'utilization': { 'cpu_percent': round(overall_cpu_percent, 1), 'memory_percent': round(overall_memory_percent, 1) }, 'node_details': node_details, 'timestamp': datetime.now().isoformat() } except Exception as e: logger.error(f"Error getting cluster capacity: {e}") return { 'success': False, 'message': f'Error getting cluster capacity: {str(e)}', 'timestamp': datetime.now().isoformat() } def calculate_server_cost(config: Dict) -> int: """Calculate server creation cost - UPDATED with new pricing""" try: cpu_str = config['cpu'] ram_str = config['ram'] storage_str = config['storage'] # Parsowanie CPU cpu_cores = 0 if 'vCPU' in cpu_str: cpu_cores = int(cpu_str.split()[0]) elif 'GHz' in cpu_str: cpu_cores = int(cpu_str.split()[0]) else: import re match = re.search(r'\d+', cpu_str) if match: cpu_cores = int(match.group()) # Parsowanie RAM ram_gb = 0 import re match = re.search(r'(\d+)\s*GB', ram_str, re.IGNORECASE) if match: ram_gb = int(match.group(1)) else: match = re.search(r'(\d+)\s*GiB', ram_str, re.IGNORECASE) if match: ram_gb = int(match.group(1)) # Parsowanie Storage storage_gb = 0 match = re.search(r'(\d+)\s*GB', storage_str, re.IGNORECASE) if match: storage_gb = int(match.group(1)) else: match = re.search(r'(\d+)\s*GiB', storage_str, re.IGNORECASE) if match: storage_gb = int(match.group(1)) # Ceny dopasowane do polskiego rynku (w EURO centach) pricing = get_pricing() cpu_cost = cpu_cores * pricing['price_cpu'] ram_cost = ram_gb * pricing['price_ram'] storage_cost = storage_gb * pricing['price_storage'] server_type = config.get('serverType', 'vps') type_multiplier = { 'vps': pricing.get('multiplier_vps', 1.0), 'game': pricing.get('multiplier_game', 0.8), 'website': pricing.get('multiplier_website', 1.5), 'database': pricing.get('multiplier_database', 1.3) }.get(server_type, 1.0) total_cost_cents = int((cpu_cost + ram_cost + storage_cost) * type_multiplier) # Konwersja na kredyty (1 euro = 100 kredytów, 1 cent = 1 kredyt) total_credits = total_cost_cents logger.info(f"Server cost calculation for {config['name']}:") logger.info(f" CPU: {cpu_cores} vCPU * {pricing['price_cpu']} = {cpu_cost} cents") logger.info(f" RAM: {ram_gb}GB * {pricing['price_ram']} = {ram_cost} cents") logger.info(f" Storage: {storage_gb}GB * {pricing['price_storage']} = {storage_cost} cents") logger.info(f" Type multiplier: {type_multiplier}") logger.info(f" Total: {total_cost_cents} cents = {total_credits} credits") return total_credits except Exception as e: logger.error(f"Error calculating server cost: {e}") return 1000 # Domyślny koszt def calculate_monthly_server_cost(server: Dict) -> int: """Oblicz miesięczny koszt serwera""" try: # Ekstrakcja zasobów z konfiguracji serwera cpu_str = server['cpu'] ram_str = server['ram'] storage_str = server['storage'] # Parsowanie CPU - format może być "2 vCPU" lub podobny cpu_cores = 0 if 'vCPU' in cpu_str: cpu_cores = int(cpu_str.split()[0]) elif 'GHz' in cpu_str: cpu_cores = int(cpu_str.split()[0]) else: # Spróbuj wyodrębnić liczbę import re match = re.search(r'\d+', cpu_str) if match: cpu_cores = int(match.group()) # Parsowanie RAM - format może być "4GB" lub "4 GB" ram_gb = 0 import re match = re.search(r'(\d+)\s*GB', ram_str, re.IGNORECASE) if match: ram_gb = int(match.group(1)) else: # Spróbuj inne formaty match = re.search(r'(\d+)\s*GiB', ram_str, re.IGNORECASE) if match: ram_gb = int(match.group(1)) # Parsowanie Storage - format może być "50GB" lub "50 GB" storage_gb = 0 match = re.search(r'(\d+)\s*GB', storage_str, re.IGNORECASE) if match: storage_gb = int(match.group(1)) else: match = re.search(r'(\d+)\s*GiB', storage_str, re.IGNORECASE) if match: storage_gb = int(match.group(1)) # Kalkulacja kosztów według stawek dopasowanych do polskiego rynku (w EURO centach) pricing = get_pricing() cpu_cost = cpu_cores * pricing['price_cpu'] ram_cost = ram_gb * pricing['price_ram'] storage_cost = storage_gb * pricing['price_storage'] # Koszt bazowy zależny od typu serwera base_cost = 0 server_type = server.get('type', 'vps') type_multiplier = { 'vps': pricing.get('multiplier_vps', 1.0), 'game': pricing.get('multiplier_game', 0.8), 'website': pricing.get('multiplier_website', 1.5), 'database': pricing.get('multiplier_database', 1.3) }.get(server_type, 1.0) total_cost = int((base_cost + cpu_cost + ram_cost + storage_cost) * type_multiplier) # Konwersja na kredyty (1 euro = 100 kredytów) # Ponieważ koszt jest w centach, dzielimy przez 100 aby dostać euro, a potem *100 aby dostać kredyty total_credits = int(total_cost) # ponieważ 1 euro = 100 kredytów, a 1 cent = 1 kredyt logger.info(f"Monthly cost calculation for server {server.get('name', 'unknown')}:") logger.info(f" CPU: {cpu_cores} vCPU * {pricing['price_cpu']} = {cpu_cost} cents") logger.info(f" RAM: {ram_gb}GB * {pricing['price_ram']} = {ram_cost} cents") logger.info(f" Storage: {storage_gb}GB * {pricing['price_storage']} = {storage_cost} cents") logger.info(f" Type multiplier: {type_multiplier}") logger.info(f" Total: {total_cost} cents = {total_credits} credits") return total_credits except Exception as e: logger.error(f"Error calculating monthly cost: {e}") return 1000 # Domyślny koszt w przypadku błędu