- 刪除舊版 clawbot.py (已有新版 openclaw.py) - 更新 models/ai.py 類型定義 (ClawBotAnalysisRequest/Response) - 更新 api/v1/ai.py import 與註解 - 更新 Discord username - 更新所有註解與文檔 依據: feedback_openclaw_naming.md (統帥 2026-03-20 正式命名決議) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
626 lines
24 KiB
Python
626 lines
24 KiB
Python
"""
|
||
FinOps Cost Analyzer - 閒置資源掃描與成本換算
|
||
Phase 3.3: 商業變現能力 - Day-1 ROI
|
||
|
||
核心功能:
|
||
1. Orphaned PVCs (孤兒儲存卷) - 沒有被任何 Pod 掛載
|
||
2. Zombie Pods (殭屍容器) - CPU 使用率連續 7 天 < 1%
|
||
3. Over-provisioned Nodes (過度配置節點) - Request 高但 Usage 低
|
||
|
||
輸出格式:
|
||
- total_wasted_usd: 每月浪費金額
|
||
- recommended_actions: OpenClaw 可執行的建議清單
|
||
"""
|
||
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta
|
||
from enum import Enum
|
||
from typing import Literal
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ==================== Types ====================
|
||
|
||
|
||
class ResourceType(str, Enum):
|
||
"""資源類型"""
|
||
PVC = "pvc" # PersistentVolumeClaim
|
||
POD = "pod" # Pod
|
||
NODE = "node" # Node
|
||
DEPLOYMENT = "deployment" # Deployment
|
||
SERVICE = "service" # Service
|
||
|
||
|
||
class WasteReason(str, Enum):
|
||
"""浪費原因"""
|
||
ORPHANED = "orphaned" # 孤兒資源 (無連結)
|
||
ZOMBIE = "zombie" # 殭屍 (幾乎無活動)
|
||
OVER_PROVISIONED = "over_provisioned" # 過度配置
|
||
IDLE = "idle" # 閒置
|
||
|
||
|
||
@dataclass
|
||
class WastedResource:
|
||
"""浪費的資源"""
|
||
resource_type: ResourceType
|
||
name: str
|
||
namespace: str
|
||
reason: WasteReason
|
||
details: str
|
||
monthly_cost_usd: float
|
||
created_at: datetime
|
||
last_used_at: datetime | None = None
|
||
|
||
# 資源規格
|
||
spec: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"resourceType": self.resource_type.value,
|
||
"name": self.name,
|
||
"namespace": self.namespace,
|
||
"reason": self.reason.value,
|
||
"details": self.details,
|
||
"monthlyCostUsd": round(self.monthly_cost_usd, 2),
|
||
"createdAt": self.created_at.isoformat(),
|
||
"lastUsedAt": self.last_used_at.isoformat() if self.last_used_at else None,
|
||
"spec": self.spec,
|
||
}
|
||
|
||
|
||
class SavingsType(str, Enum):
|
||
"""節省類型 - 區分真實省錢 vs 釋放資源"""
|
||
REALIZABLE = "realizable" # 真實省錢 (例如刪除 PVC → AWS 帳單立刻減少)
|
||
FREED = "freed" # 釋放資源 (例如刪除 Pod → 除非 Node 縮容否則不省錢)
|
||
|
||
|
||
@dataclass
|
||
class RecommendedAction:
|
||
"""建議的優化動作 (OpenClaw 可執行)"""
|
||
action_id: str
|
||
action_type: Literal["delete", "scale_down", "resize", "migrate"]
|
||
resource_type: ResourceType
|
||
resource_name: str
|
||
namespace: str
|
||
description: str
|
||
estimated_savings_usd: float
|
||
risk_level: Literal["low", "medium", "high", "critical"]
|
||
command_hint: str # 給 OpenClaw 的執行提示
|
||
savings_type: SavingsType = SavingsType.REALIZABLE # 節省類型
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"actionId": self.action_id,
|
||
"actionType": self.action_type,
|
||
"resourceType": self.resource_type.value,
|
||
"resourceName": self.resource_name,
|
||
"namespace": self.namespace,
|
||
"description": self.description,
|
||
"estimatedSavingsUsd": round(self.estimated_savings_usd, 2),
|
||
"riskLevel": self.risk_level,
|
||
"commandHint": self.command_hint,
|
||
"savingsType": self.savings_type.value,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class CostReport:
|
||
"""成本報告 (OpenClaw 整合用)"""
|
||
scan_id: str
|
||
scanned_at: datetime
|
||
cluster_name: str
|
||
|
||
# 核心指標
|
||
total_wasted_usd: float
|
||
total_resources_scanned: int
|
||
wasted_resources_count: int
|
||
|
||
# 詳細資料
|
||
wasted_resources: list[WastedResource]
|
||
recommended_actions: list[RecommendedAction]
|
||
|
||
# 分類統計
|
||
waste_by_type: dict[str, float]
|
||
waste_by_namespace: dict[str, float]
|
||
|
||
def to_dict(self) -> dict:
|
||
"""輸出 OpenClaw 可讀取的 JSON 格式"""
|
||
return {
|
||
"scanId": self.scan_id,
|
||
"scannedAt": self.scanned_at.isoformat(),
|
||
"clusterName": self.cluster_name,
|
||
|
||
# OpenClaw 核心關注
|
||
"totalWastedUsd": round(self.total_wasted_usd, 2),
|
||
"totalResourcesScanned": self.total_resources_scanned,
|
||
"wastedResourcesCount": self.wasted_resources_count,
|
||
|
||
# 詳細資料
|
||
"wastedResources": [r.to_dict() for r in self.wasted_resources],
|
||
"recommendedActions": [a.to_dict() for a in self.recommended_actions],
|
||
|
||
# 統計
|
||
"wasteByType": {k: round(v, 2) for k, v in self.waste_by_type.items()},
|
||
"wasteByNamespace": {k: round(v, 2) for k, v in self.waste_by_namespace.items()},
|
||
|
||
# 摘要 (給 AI 的自然語言描述)
|
||
"summary": self._generate_summary(),
|
||
}
|
||
|
||
def _generate_summary(self) -> str:
|
||
"""產生 AI 可讀的摘要"""
|
||
if self.total_wasted_usd < 10:
|
||
return f"Cluster {self.cluster_name} is well-optimized. Only ${self.total_wasted_usd:.2f}/month potential savings."
|
||
|
||
top_waste = max(self.waste_by_type.items(), key=lambda x: x[1]) if self.waste_by_type else ("none", 0)
|
||
return (
|
||
f"Cluster {self.cluster_name} has ${self.total_wasted_usd:.2f}/month in wasted resources. "
|
||
f"Found {self.wasted_resources_count} idle resources. "
|
||
f"Biggest waste: {top_waste[0]} (${top_waste[1]:.2f}/month). "
|
||
f"{len(self.recommended_actions)} optimization actions available."
|
||
)
|
||
|
||
|
||
# ==================== Pricing Configuration ====================
|
||
|
||
|
||
@dataclass
|
||
class PricingConfig:
|
||
"""
|
||
費率配置 (可依雲端供應商調整)
|
||
|
||
預設值基於 AWS 美東區域 (us-east-1)
|
||
"""
|
||
# 儲存 (per GB/month)
|
||
storage_gp3_per_gb: float = 0.08 # EBS gp3
|
||
storage_gp2_per_gb: float = 0.10 # EBS gp2
|
||
storage_io1_per_gb: float = 0.125 # EBS io1
|
||
storage_standard_per_gb: float = 0.05 # Standard HDD
|
||
|
||
# 運算 (per vCPU/month, 假設 on-demand)
|
||
compute_per_vcpu: float = 30.0 # ~$0.04/hr * 720hr
|
||
compute_per_gb_ram: float = 4.0 # ~$0.005/hr/GB * 720hr
|
||
|
||
# 網路
|
||
load_balancer_per_month: float = 18.0 # ALB/NLB 固定費
|
||
nat_gateway_per_month: float = 32.0 # NAT Gateway
|
||
|
||
# ╔════════════════════════════════════════════════════════════════╗
|
||
# ║ SAFETY_BUFFER: 縮容安全係數 ║
|
||
# ║ 避免建議縮到剛好 actual usage,造成 OOM/CPU throttling ║
|
||
# ║ 公式: wasted = requested - (actual × 1.2) ║
|
||
# ╚════════════════════════════════════════════════════════════════╝
|
||
safety_buffer: float = 1.2
|
||
|
||
def get_storage_price(self, storage_class: str) -> float:
|
||
"""依 StorageClass 取得費率"""
|
||
mapping = {
|
||
"gp3": self.storage_gp3_per_gb,
|
||
"gp2": self.storage_gp2_per_gb,
|
||
"io1": self.storage_io1_per_gb,
|
||
"standard": self.storage_standard_per_gb,
|
||
}
|
||
return mapping.get(storage_class.lower(), self.storage_gp3_per_gb)
|
||
|
||
|
||
# 預設費率
|
||
DEFAULT_PRICING = PricingConfig()
|
||
|
||
|
||
# ==================== Idle Resource Scanner ====================
|
||
|
||
|
||
class IdleResourceScanner:
|
||
"""
|
||
閒置資源掃描器
|
||
|
||
偵測並量化 K8s 叢集中的浪費資源,
|
||
轉換為美金金額,供 OpenClaw 決策
|
||
"""
|
||
|
||
def __init__(self, pricing: PricingConfig | None = None):
|
||
self.pricing = pricing or DEFAULT_PRICING
|
||
self._scan_counter = 0
|
||
|
||
async def full_scan(self, cluster_name: str = "default") -> CostReport:
|
||
"""
|
||
執行完整掃描
|
||
|
||
Returns:
|
||
CostReport 包含所有浪費資源與建議動作
|
||
"""
|
||
self._scan_counter += 1
|
||
scan_id = f"scan-{self._scan_counter:04d}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
||
|
||
logger.info(f"[FinOps] Starting full scan: {scan_id}")
|
||
|
||
# 執行各類掃描
|
||
orphaned_pvcs = await self._scan_orphaned_pvcs()
|
||
zombie_pods = await self._scan_zombie_pods()
|
||
over_provisioned = await self._scan_over_provisioned_nodes()
|
||
|
||
# 合併所有浪費資源
|
||
all_wasted = orphaned_pvcs + zombie_pods + over_provisioned
|
||
|
||
# 產生建議動作
|
||
actions = self._generate_recommendations(all_wasted)
|
||
|
||
# 計算統計
|
||
total_wasted = sum(r.monthly_cost_usd for r in all_wasted)
|
||
waste_by_type = self._group_by_type(all_wasted)
|
||
waste_by_ns = self._group_by_namespace(all_wasted)
|
||
|
||
report = CostReport(
|
||
scan_id=scan_id,
|
||
scanned_at=datetime.utcnow(),
|
||
cluster_name=cluster_name,
|
||
total_wasted_usd=total_wasted,
|
||
total_resources_scanned=self._get_mock_total_resources(),
|
||
wasted_resources_count=len(all_wasted),
|
||
wasted_resources=all_wasted,
|
||
recommended_actions=actions,
|
||
waste_by_type=waste_by_type,
|
||
waste_by_namespace=waste_by_ns,
|
||
)
|
||
|
||
logger.info(
|
||
f"[FinOps] Scan complete: {scan_id} - "
|
||
f"${total_wasted:.2f}/month wasted, {len(actions)} actions"
|
||
)
|
||
|
||
return report
|
||
|
||
# ==================== Orphaned PVCs ====================
|
||
|
||
async def _scan_orphaned_pvcs(self) -> list[WastedResource]:
|
||
"""
|
||
掃描孤兒 PVC
|
||
|
||
孤兒 PVC = 已建立但沒有被任何 Pod 掛載的 PersistentVolumeClaim
|
||
常見原因: Pod 刪除後忘記清理 PVC
|
||
"""
|
||
# Phase 3: Mock 資料 (實際連接 K8s API 待 Phase 4)
|
||
mock_orphans = [
|
||
{
|
||
"name": "data-postgres-backup-old",
|
||
"namespace": "database",
|
||
"size_gb": 500,
|
||
"storage_class": "gp3",
|
||
"created": datetime.utcnow() - timedelta(days=90),
|
||
"last_used": datetime.utcnow() - timedelta(days=60),
|
||
},
|
||
{
|
||
"name": "logs-elasticsearch-2023",
|
||
"namespace": "logging",
|
||
"size_gb": 200,
|
||
"storage_class": "gp2",
|
||
"created": datetime.utcnow() - timedelta(days=180),
|
||
"last_used": datetime.utcnow() - timedelta(days=120),
|
||
},
|
||
{
|
||
"name": "cache-redis-temp",
|
||
"namespace": "default",
|
||
"size_gb": 50,
|
||
"storage_class": "gp3",
|
||
"created": datetime.utcnow() - timedelta(days=30),
|
||
"last_used": None,
|
||
},
|
||
]
|
||
|
||
results = []
|
||
for pvc in mock_orphans:
|
||
price_per_gb = self.pricing.get_storage_price(pvc["storage_class"])
|
||
monthly_cost = pvc["size_gb"] * price_per_gb
|
||
|
||
results.append(WastedResource(
|
||
resource_type=ResourceType.PVC,
|
||
name=pvc["name"],
|
||
namespace=pvc["namespace"],
|
||
reason=WasteReason.ORPHANED,
|
||
details=f"PVC not mounted by any Pod. Size: {pvc['size_gb']}GB ({pvc['storage_class']})",
|
||
monthly_cost_usd=monthly_cost,
|
||
created_at=pvc["created"],
|
||
last_used_at=pvc["last_used"],
|
||
spec={
|
||
"sizeGb": pvc["size_gb"],
|
||
"storageClass": pvc["storage_class"],
|
||
},
|
||
))
|
||
|
||
logger.info(f"[FinOps] Found {len(results)} orphaned PVCs")
|
||
return results
|
||
|
||
# ==================== Zombie Pods ====================
|
||
|
||
async def _scan_zombie_pods(self) -> list[WastedResource]:
|
||
"""
|
||
掃描殭屍 Pod
|
||
|
||
殭屍 Pod = CPU 使用率連續 7 天 < 1% 的 Pod
|
||
常見原因: 被遺忘的測試 Pod、已下線但未刪除的服務
|
||
"""
|
||
mock_zombies = [
|
||
{
|
||
"name": "legacy-api-5d7b8c9f6-abc12",
|
||
"namespace": "legacy",
|
||
"cpu_request": 2.0, # vCPU
|
||
"mem_request_gb": 4.0,
|
||
"avg_cpu_percent": 0.3,
|
||
"created": datetime.utcnow() - timedelta(days=120),
|
||
"last_active": datetime.utcnow() - timedelta(days=45),
|
||
},
|
||
{
|
||
"name": "test-worker-batch-xyz99",
|
||
"namespace": "testing",
|
||
"cpu_request": 1.0,
|
||
"mem_request_gb": 2.0,
|
||
"avg_cpu_percent": 0.1,
|
||
"created": datetime.utcnow() - timedelta(days=60),
|
||
"last_active": datetime.utcnow() - timedelta(days=30),
|
||
},
|
||
{
|
||
"name": "debug-shell-admin",
|
||
"namespace": "default",
|
||
"cpu_request": 0.5,
|
||
"mem_request_gb": 1.0,
|
||
"avg_cpu_percent": 0.0,
|
||
"created": datetime.utcnow() - timedelta(days=14),
|
||
"last_active": datetime.utcnow() - timedelta(days=10),
|
||
},
|
||
]
|
||
|
||
results = []
|
||
for pod in mock_zombies:
|
||
# 計算成本: CPU + Memory
|
||
cpu_cost = pod["cpu_request"] * self.pricing.compute_per_vcpu
|
||
mem_cost = pod["mem_request_gb"] * self.pricing.compute_per_gb_ram
|
||
monthly_cost = cpu_cost + mem_cost
|
||
|
||
results.append(WastedResource(
|
||
resource_type=ResourceType.POD,
|
||
name=pod["name"],
|
||
namespace=pod["namespace"],
|
||
reason=WasteReason.ZOMBIE,
|
||
details=(
|
||
f"CPU usage < 1% for 7+ days. "
|
||
f"Avg: {pod['avg_cpu_percent']:.1f}%. "
|
||
f"Resources: {pod['cpu_request']} vCPU, {pod['mem_request_gb']}GB RAM"
|
||
),
|
||
monthly_cost_usd=monthly_cost,
|
||
created_at=pod["created"],
|
||
last_used_at=pod["last_active"],
|
||
spec={
|
||
"cpuRequest": pod["cpu_request"],
|
||
"memoryGb": pod["mem_request_gb"],
|
||
"avgCpuPercent": pod["avg_cpu_percent"],
|
||
},
|
||
))
|
||
|
||
logger.info(f"[FinOps] Found {len(results)} zombie Pods")
|
||
return results
|
||
|
||
# ==================== Over-provisioned Nodes ====================
|
||
|
||
async def _scan_over_provisioned_nodes(self) -> list[WastedResource]:
|
||
"""
|
||
掃描過度配置節點
|
||
|
||
過度配置 = Request 很高但實際 Usage 很低
|
||
例如: Request 8 vCPU 但只用 1 vCPU
|
||
"""
|
||
mock_nodes = [
|
||
{
|
||
"name": "worker-large-01",
|
||
"namespace": "kube-system",
|
||
"total_cpu": 16.0,
|
||
"total_mem_gb": 64.0,
|
||
"requested_cpu": 12.0,
|
||
"requested_mem_gb": 48.0,
|
||
"actual_cpu": 2.0,
|
||
"actual_mem_gb": 8.0,
|
||
"created": datetime.utcnow() - timedelta(days=200),
|
||
},
|
||
{
|
||
"name": "worker-gpu-unused",
|
||
"namespace": "kube-system",
|
||
"total_cpu": 8.0,
|
||
"total_mem_gb": 32.0,
|
||
"requested_cpu": 4.0,
|
||
"requested_mem_gb": 16.0,
|
||
"actual_cpu": 0.5,
|
||
"actual_mem_gb": 2.0,
|
||
"created": datetime.utcnow() - timedelta(days=90),
|
||
},
|
||
]
|
||
|
||
results = []
|
||
for node in mock_nodes:
|
||
# ╔════════════════════════════════════════════════════════════════╗
|
||
# ║ 安全緩衝計算: wasted = requested - (actual × SAFETY_BUFFER) ║
|
||
# ║ 避免縮容建議導致 OOM / CPU throttling ║
|
||
# ╚════════════════════════════════════════════════════════════════╝
|
||
buffered_cpu = node["actual_cpu"] * self.pricing.safety_buffer
|
||
buffered_mem = node["actual_mem_gb"] * self.pricing.safety_buffer
|
||
|
||
wasted_cpu = node["requested_cpu"] - buffered_cpu
|
||
wasted_mem = node["requested_mem_gb"] - buffered_mem
|
||
|
||
if wasted_cpu < 1 and wasted_mem < 4:
|
||
continue # 浪費不夠顯著 (含安全緩衝後)
|
||
|
||
cpu_waste_cost = wasted_cpu * self.pricing.compute_per_vcpu
|
||
mem_waste_cost = wasted_mem * self.pricing.compute_per_gb_ram
|
||
monthly_cost = cpu_waste_cost + mem_waste_cost
|
||
|
||
utilization = node["actual_cpu"] / node["requested_cpu"] * 100
|
||
|
||
results.append(WastedResource(
|
||
resource_type=ResourceType.NODE,
|
||
name=node["name"],
|
||
namespace=node["namespace"],
|
||
reason=WasteReason.OVER_PROVISIONED,
|
||
details=(
|
||
f"Utilization: {utilization:.0f}%. "
|
||
f"Requested: {node['requested_cpu']} vCPU, {node['requested_mem_gb']}GB. "
|
||
f"Actual: {node['actual_cpu']} vCPU, {node['actual_mem_gb']}GB"
|
||
),
|
||
monthly_cost_usd=monthly_cost,
|
||
created_at=node["created"],
|
||
last_used_at=datetime.utcnow(),
|
||
spec={
|
||
"totalCpu": node["total_cpu"],
|
||
"totalMemoryGb": node["total_mem_gb"],
|
||
"requestedCpu": node["requested_cpu"],
|
||
"requestedMemoryGb": node["requested_mem_gb"],
|
||
"actualCpu": node["actual_cpu"],
|
||
"actualMemoryGb": node["actual_mem_gb"],
|
||
"utilizationPercent": utilization,
|
||
},
|
||
))
|
||
|
||
logger.info(f"[FinOps] Found {len(results)} over-provisioned resources")
|
||
return results
|
||
|
||
# ==================== Recommendations ====================
|
||
|
||
def _generate_recommendations(
|
||
self,
|
||
wasted: list[WastedResource],
|
||
) -> list[RecommendedAction]:
|
||
"""
|
||
產生優化建議 (OpenClaw 可執行)
|
||
"""
|
||
actions = []
|
||
action_counter = 0
|
||
|
||
for resource in wasted:
|
||
action_counter += 1
|
||
action_id = f"action-{action_counter:03d}"
|
||
|
||
if resource.resource_type == ResourceType.PVC:
|
||
# ✅ REALIZABLE: 刪除 PVC → AWS 帳單立刻減少
|
||
actions.append(RecommendedAction(
|
||
action_id=action_id,
|
||
action_type="delete",
|
||
resource_type=resource.resource_type,
|
||
resource_name=resource.name,
|
||
namespace=resource.namespace,
|
||
description=f"Delete orphaned PVC '{resource.name}' - not mounted by any Pod",
|
||
estimated_savings_usd=resource.monthly_cost_usd,
|
||
risk_level="low",
|
||
command_hint=f"kubectl delete pvc {resource.name} -n {resource.namespace}",
|
||
savings_type=SavingsType.REALIZABLE,
|
||
))
|
||
|
||
elif resource.resource_type == ResourceType.POD:
|
||
# ⚠️ FREED: 刪除 Pod 只是釋放資源,除非 Node 縮容否則不省錢
|
||
risk = "medium" if resource.monthly_cost_usd > 50 else "low"
|
||
actions.append(RecommendedAction(
|
||
action_id=action_id,
|
||
action_type="delete",
|
||
resource_type=resource.resource_type,
|
||
resource_name=resource.name,
|
||
namespace=resource.namespace,
|
||
description=f"Delete zombie Pod '{resource.name}' - CPU < 1% for 7+ days",
|
||
estimated_savings_usd=resource.monthly_cost_usd,
|
||
risk_level=risk,
|
||
command_hint=f"kubectl delete pod {resource.name} -n {resource.namespace}",
|
||
savings_type=SavingsType.FREED,
|
||
))
|
||
|
||
elif resource.resource_type == ResourceType.NODE:
|
||
# ✅ REALIZABLE: Node 縮容/刪除 → AWS 帳單減少
|
||
actions.append(RecommendedAction(
|
||
action_id=action_id,
|
||
action_type="resize",
|
||
resource_type=resource.resource_type,
|
||
resource_name=resource.name,
|
||
namespace=resource.namespace,
|
||
description=(
|
||
f"Resize node '{resource.name}' - "
|
||
f"utilization only {resource.spec.get('utilizationPercent', 0):.0f}%"
|
||
),
|
||
estimated_savings_usd=resource.monthly_cost_usd,
|
||
risk_level="high",
|
||
command_hint=f"# Consider migrating workloads and downsizing {resource.name}",
|
||
savings_type=SavingsType.REALIZABLE,
|
||
))
|
||
|
||
# 按節省金額排序 (最大節省優先)
|
||
actions.sort(key=lambda a: a.estimated_savings_usd, reverse=True)
|
||
|
||
return actions
|
||
|
||
# ==================== Utilities ====================
|
||
|
||
def _group_by_type(self, resources: list[WastedResource]) -> dict[str, float]:
|
||
"""依類型分組統計"""
|
||
result: dict[str, float] = {}
|
||
for r in resources:
|
||
key = r.resource_type.value
|
||
result[key] = result.get(key, 0) + r.monthly_cost_usd
|
||
return result
|
||
|
||
def _group_by_namespace(self, resources: list[WastedResource]) -> dict[str, float]:
|
||
"""依 Namespace 分組統計"""
|
||
result: dict[str, float] = {}
|
||
for r in resources:
|
||
result[r.namespace] = result.get(r.namespace, 0) + r.monthly_cost_usd
|
||
return result
|
||
|
||
def _get_mock_total_resources(self) -> int:
|
||
"""Mock: 總掃描資源數"""
|
||
return 150 # 假設叢集有 150 個資源
|
||
|
||
def calculate_monthly_savings(self, report: CostReport) -> dict:
|
||
"""
|
||
計算月度節省摘要
|
||
|
||
╔════════════════════════════════════════════════════════════════╗
|
||
║ 嚴格區分真實省錢 vs 釋放資源 ║
|
||
║ - realizableSavingsUsd: 刪除後 AWS 帳單立刻減少 ║
|
||
║ - freedResourcesUsd: 釋放 Pod/Container,需要 Node 縮容才省錢 ║
|
||
╚════════════════════════════════════════════════════════════════╝
|
||
|
||
Returns:
|
||
OpenClaw 可直接使用的 JSON 格式
|
||
"""
|
||
realizable = sum(
|
||
a.estimated_savings_usd
|
||
for a in report.recommended_actions
|
||
if a.savings_type == SavingsType.REALIZABLE
|
||
)
|
||
freed = sum(
|
||
a.estimated_savings_usd
|
||
for a in report.recommended_actions
|
||
if a.savings_type == SavingsType.FREED
|
||
)
|
||
|
||
return {
|
||
"totalWastedUsd": round(report.total_wasted_usd, 2),
|
||
|
||
# ⚠️ 嚴格區分
|
||
"realizableSavingsUsd": round(realizable, 2), # 真實省錢
|
||
"freedResourcesUsd": round(freed, 2), # 釋放資源 (需縮容才省錢)
|
||
|
||
"potentialSavingsUsd": round(realizable + freed, 2), # 總計 (參考用)
|
||
"actionCount": len(report.recommended_actions),
|
||
"topActions": [
|
||
{
|
||
"action": a.description,
|
||
"savings": round(a.estimated_savings_usd, 2),
|
||
"risk": a.risk_level,
|
||
"savingsType": a.savings_type.value,
|
||
}
|
||
for a in report.recommended_actions[:5] # Top 5
|
||
],
|
||
"annualProjection": round(realizable * 12, 2), # 年度預估僅計真實省錢
|
||
"annualProjectionWithFreed": round((realizable + freed) * 12, 2),
|
||
}
|
||
|
||
|
||
# 全域實例
|
||
idle_scanner = IdleResourceScanner()
|