feat: 补全多云资产与账单同步
- 接入 AWS、华为云和 Azure 资产及账单 API - 按原始币种隔离统计并修正账号服务器数 - 修复侧栏菜单展开后的滚动行为
This commit is contained in:
1 parent
65e2f09b97
commit
132fe9ba97
25 files changed
+1094
-141
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AWSProvider 通过 EC2 Query API 同步 AWS 区域与实例。 // Logan.Li
|
||||
type AWSProvider struct {
|
||||
accessKey string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
// NewAWSProvider 创建 AWS Provider。 // Logan.Li
|
||||
func NewAWSProvider(accessKey, secretKey string) *AWSProvider {
|
||||
return &AWSProvider{accessKey: accessKey, secretKey: secretKey}
|
||||
}
|
||||
|
||||
// Name 返回 AWS 厂商标识。 // Logan.Li
|
||||
func (p *AWSProvider) Name() string { return "aws" }
|
||||
|
||||
// call 使用 AWS Signature V4 调用 EC2 Query API。 // Logan.Li
|
||||
func (p *AWSProvider) call(region, action string, values url.Values) ([]byte, error) {
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
values.Set("Action", action)
|
||||
values.Set("Version", "2016-11-15")
|
||||
now := time.Now().UTC()
|
||||
date := now.Format("20060102")
|
||||
host := "ec2." + region + ".amazonaws.com"
|
||||
payload := values.Encode()
|
||||
canonicalHeaders := "content-type:application/x-www-form-urlencoded; charset=utf-8\nhost:" + host + "\nx-amz-date:" + now.Format("20060102T150405Z") + "\n"
|
||||
signedHeaders := "content-type;host;x-amz-date"
|
||||
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(payload)
|
||||
credentialScope := date + "/" + region + "/ec2/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + now.Format("20060102T150405Z") + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, region)
|
||||
kService := hmacSHA256(kRegion, "ec2")
|
||||
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 AWS 请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
req.Header.Set("X-Amz-Date", now.Format("20060102T150405Z"))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 AWS API 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 AWS 响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("AWS API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ListRegions 获取当前凭据可见的 AWS 区域。 // Logan.Li
|
||||
func (p *AWSProvider) ListRegions() ([]CloudRegion, error) {
|
||||
body, err := p.call("us-east-1", "DescribeRegions", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Regions []struct {
|
||||
Name string `xml:"regionName"`
|
||||
Endpoint string `xml:"regionEndpoint"`
|
||||
} `xml:"regionInfo>item"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 AWS 区域响应失败: %w", err)
|
||||
}
|
||||
regions := make([]CloudRegion, 0, len(result.Regions))
|
||||
for _, region := range result.Regions {
|
||||
regions = append(regions, CloudRegion{RegionID: region.Name, LocalName: region.Name, Endpoint: "https://" + region.Endpoint, Status: "active"})
|
||||
}
|
||||
sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID })
|
||||
return regions, nil
|
||||
}
|
||||
|
||||
// ListInstances 获取一个 AWS 区域中的 EC2 实例。 // Logan.Li
|
||||
func (p *AWSProvider) ListInstances(regionID string) ([]CloudInstance, error) {
|
||||
body, err := p.call(regionID, "DescribeInstances", url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Reservations []struct {
|
||||
Instances []struct {
|
||||
ID string `xml:"instanceId"`
|
||||
Type string `xml:"instanceType"`
|
||||
State string `xml:"instanceState>name"`
|
||||
PrivateIP string `xml:"privateIpAddress"`
|
||||
PublicIP string `xml:"ipAddress"`
|
||||
Launch string `xml:"launchTime"`
|
||||
Placement string `xml:"placement>availabilityZone"`
|
||||
Platform string `xml:"platform"`
|
||||
Tags []struct {
|
||||
Key string `xml:"key"`
|
||||
Value string `xml:"value"`
|
||||
} `xml:"tagSet>item"`
|
||||
} `xml:"instancesSet>item"`
|
||||
} `xml:"reservationSet>item"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 AWS 实例响应失败: %w", err)
|
||||
}
|
||||
instances := []CloudInstance{}
|
||||
for _, reservation := range result.Reservations {
|
||||
for _, item := range reservation.Instances {
|
||||
name := item.ID
|
||||
for _, tag := range item.Tags {
|
||||
if tag.Key == "Name" && tag.Value != "" {
|
||||
name = tag.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
osType := "linux"
|
||||
if strings.EqualFold(item.Platform, "windows") {
|
||||
osType = "windows"
|
||||
}
|
||||
instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: name, Hostname: name, OSType: osType, Status: awsState(item.State), RegionID: regionID, ZoneID: item.Placement, PrivateIP: item.PrivateIP, PublicIP: item.PublicIP, InstanceType: item.Type, CreationTime: item.Launch})
|
||||
}
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// awsState 统一 AWS 实例状态,便于复用现有资产导入逻辑。 // Logan.Li
|
||||
func awsState(state string) string {
|
||||
if state == "running" {
|
||||
return "Running"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// sha256Hex 计算签名所需的 SHA256 十六进制摘要。 // Logan.Li
|
||||
func sha256Hex(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// hmacSHA256 计算签名链中的 HMAC-SHA256。 // Logan.Li
|
||||
func hmacSHA256(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li
|
||||
func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||
start, err := time.Parse("2006-01-02", billingDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
type aggregationKey struct {
|
||||
service string
|
||||
region string
|
||||
}
|
||||
aggregation := map[aggregationKey]float64{}
|
||||
nextToken := ""
|
||||
currency := "USD"
|
||||
for {
|
||||
payload := map[string]interface{}{
|
||||
"TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")},
|
||||
"Granularity": "DAILY",
|
||||
"Metrics": []string{"UnblendedCost"},
|
||||
"GroupBy": []map[string]string{
|
||||
{"Type": "DIMENSION", "Key": "SERVICE"},
|
||||
{"Type": "DIMENSION", "Key": "REGION"},
|
||||
},
|
||||
}
|
||||
if nextToken != "" {
|
||||
payload["NextPageToken"] = nextToken
|
||||
}
|
||||
body, callErr := p.callCostExplorer(payload)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
var response struct {
|
||||
NextPageToken string `json:"NextPageToken"`
|
||||
ResultsByTime []struct {
|
||||
Groups []struct {
|
||||
Keys []string `json:"Keys"`
|
||||
Metrics map[string]struct {
|
||||
Amount string `json:"Amount"`
|
||||
Unit string `json:"Unit"`
|
||||
} `json:"Metrics"`
|
||||
} `json:"Groups"`
|
||||
} `json:"ResultsByTime"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err)
|
||||
}
|
||||
for _, period := range response.ResultsByTime {
|
||||
for _, group := range period.Groups {
|
||||
metric := group.Metrics["UnblendedCost"]
|
||||
cost, _ := strconv.ParseFloat(metric.Amount, 64)
|
||||
if cost == 0 {
|
||||
continue
|
||||
}
|
||||
if metric.Unit != "" {
|
||||
currency = metric.Unit
|
||||
}
|
||||
service, region := "其他", "全局"
|
||||
if len(group.Keys) > 0 && group.Keys[0] != "" {
|
||||
service = group.Keys[0]
|
||||
}
|
||||
if len(group.Keys) > 1 && group.Keys[1] != "" && group.Keys[1] != "NoRegion" {
|
||||
region = group.Keys[1]
|
||||
}
|
||||
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||
}
|
||||
}
|
||||
nextToken = response.NextPageToken
|
||||
if nextToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
keys := make([]aggregationKey, 0, len(aggregation))
|
||||
for key := range aggregation {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].service == keys[j].service {
|
||||
return keys[i].region < keys[j].region
|
||||
}
|
||||
return keys[i].service < keys[j].service
|
||||
})
|
||||
records := make([]CloudBillingRecord, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
records = append(records, CloudBillingRecord{
|
||||
BillingDate: billingDate, Provider: "aws", Cost: math.Round(aggregation[key]*1000000) / 1000000,
|
||||
Currency: currency, Service: key.service, Region: key.region,
|
||||
Description: "云 API 自动同步(AWS Cost Explorer)",
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// callCostExplorer 使用 Signature V4 调用 AWS Cost Explorer JSON API。 // Logan.Li
|
||||
func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err)
|
||||
}
|
||||
const host = "ce.us-east-1.amazonaws.com"
|
||||
const target = "AWSInsightsIndexService.GetCostAndUsage"
|
||||
now := time.Now().UTC()
|
||||
amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102")
|
||||
canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n"
|
||||
signedHeaders := "content-type;host;x-amz-date;x-amz-target"
|
||||
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body))
|
||||
credentialScope := date + "/us-east-1/ce/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, "us-east-1")
|
||||
kService := hmacSHA256(kRegion, "ce")
|
||||
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
||||
req.Header.Set("X-Amz-Date", amzDate)
|
||||
req.Header.Set("X-Amz-Target", target)
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AzureProvider 使用服务主体凭据同步 Azure Resource Manager 虚拟机。 // Logan.Li
|
||||
type AzureProvider struct{ tenantID, subscriptionID, clientID, clientSecret string }
|
||||
|
||||
// NewAzureProvider 创建 Azure Provider,AccessKey 格式为 tenantId:subscriptionId:clientId。 // Logan.Li
|
||||
func NewAzureProvider(accessKey, secretKey string) *AzureProvider {
|
||||
parts := strings.Split(accessKey, ":")
|
||||
p := &AzureProvider{clientSecret: secretKey}
|
||||
if len(parts) == 3 {
|
||||
p.tenantID, p.subscriptionID, p.clientID = parts[0], parts[1], parts[2]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name 返回 Azure 厂商标识。 // Logan.Li
|
||||
func (p *AzureProvider) Name() string { return "azure" }
|
||||
|
||||
// token 获取 ARM 所需的服务主体访问令牌。 // Logan.Li
|
||||
func (p *AzureProvider) token() (string, error) {
|
||||
if p.tenantID == "" || p.subscriptionID == "" || p.clientID == "" || p.clientSecret == "" {
|
||||
return "", fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID,SecretKey 应为客户端密钥")
|
||||
}
|
||||
form := url.Values{"client_id": {p.clientID}, "client_secret": {p.clientSecret}, "grant_type": {"client_credentials"}, "scope": {"https://management.azure.com/.default"}}
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).PostForm("https://login.microsoftonline.com/"+p.tenantID+"/oauth2/v2.0/token", form)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 Azure 令牌失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("Azure 令牌接口返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var data struct {
|
||||
Token string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err != nil || data.Token == "" {
|
||||
return "", fmt.Errorf("解析 Azure 令牌失败")
|
||||
}
|
||||
return data.Token, nil
|
||||
}
|
||||
|
||||
// get 调用 Azure Resource Manager GET API。 // Logan.Li
|
||||
func (p *AzureProvider) get(path string) ([]byte, error) {
|
||||
token, err := p.token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://management.azure.com"+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 Azure API 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// post 调用 Azure Resource Manager JSON POST API。 // Logan.Li
|
||||
func (p *AzureProvider) post(path string, payload interface{}) ([]byte, error) {
|
||||
token, err := p.token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编码 Azure 请求失败: %w", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "https://management.azure.com"+path, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 Azure API 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ListRegions 获取 Azure 订阅可用区域。 // Logan.Li
|
||||
func (p *AzureProvider) ListRegions() ([]CloudRegion, error) {
|
||||
body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Value []struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Azure 区域响应失败: %w", err)
|
||||
}
|
||||
regions := []CloudRegion{}
|
||||
for _, item := range result.Value {
|
||||
regions = append(regions, CloudRegion{RegionID: item.Name, LocalName: item.DisplayName, Endpoint: "https://management.azure.com", Status: "active"})
|
||||
}
|
||||
return regions, nil
|
||||
}
|
||||
|
||||
// ListInstances 获取 Azure 指定区域的虚拟机并保留 ARM 提供的基础元数据。 // Logan.Li
|
||||
func (p *AzureProvider) ListInstances(regionID string) ([]CloudInstance, error) {
|
||||
body, err := p.get("/subscriptions/" + p.subscriptionID + "/providers/Microsoft.Compute/virtualMachines?api-version=2024-03-01")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Value []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Properties struct {
|
||||
Hardware struct {
|
||||
VMSize string `json:"vmSize"`
|
||||
} `json:"hardwareProfile"`
|
||||
OS struct {
|
||||
Type string `json:"osType"`
|
||||
Name string `json:"computerName"`
|
||||
} `json:"osProfile"`
|
||||
Provisioning string `json:"provisioningState"`
|
||||
} `json:"properties"`
|
||||
} `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Azure 实例响应失败: %w", err)
|
||||
}
|
||||
instances := []CloudInstance{}
|
||||
for _, item := range result.Value {
|
||||
if !strings.EqualFold(item.Location, regionID) {
|
||||
continue
|
||||
}
|
||||
instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Properties.OS.Name, OSType: item.Properties.OS.Type, Status: azureState(item.Properties.Provisioning), RegionID: item.Location, InstanceType: item.Properties.Hardware.VMSize})
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// azureState 统一 Azure 实例状态,便于复用导入逻辑。 // Logan.Li
|
||||
func azureState(state string) string {
|
||||
if strings.EqualFold(state, "Succeeded") {
|
||||
return "Running"
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchDailyBill 通过 Azure Cost Management Query 拉取订阅单日成本。 // Logan.Li
|
||||
func (p *AzureProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||
start, err := time.Parse("2006-01-02", billingDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
if p.subscriptionID == "" {
|
||||
return nil, fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"type": "ActualCost",
|
||||
"timeframe": "Custom",
|
||||
"timePeriod": map[string]string{
|
||||
"from": start.Format("2006-01-02T00:00:00Z"),
|
||||
"to": start.AddDate(0, 0, 1).Format("2006-01-02T00:00:00Z"),
|
||||
},
|
||||
"dataset": map[string]interface{}{
|
||||
"granularity": "Daily",
|
||||
"aggregation": map[string]interface{}{
|
||||
"totalCost": map[string]string{"name": "PreTaxCost", "function": "Sum"},
|
||||
},
|
||||
"grouping": []map[string]string{
|
||||
{"type": "Dimension", "name": "ServiceName"},
|
||||
{"type": "Dimension", "name": "ResourceLocation"},
|
||||
},
|
||||
},
|
||||
}
|
||||
path := "/subscriptions/" + p.subscriptionID + "/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
|
||||
body, err := p.post(path, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Properties struct {
|
||||
Columns []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"columns"`
|
||||
Rows [][]interface{} `json:"rows"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析 Azure Cost Management 响应失败: %w", err)
|
||||
}
|
||||
columnIndex := map[string]int{}
|
||||
for index, column := range response.Properties.Columns {
|
||||
columnIndex[strings.ToLower(column.Name)] = index
|
||||
}
|
||||
valueAt := func(row []interface{}, names ...string) interface{} {
|
||||
for _, name := range names {
|
||||
if index, ok := columnIndex[strings.ToLower(name)]; ok && index < len(row) {
|
||||
return row[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
type aggregationKey struct {
|
||||
service string
|
||||
region string
|
||||
}
|
||||
aggregation := map[aggregationKey]float64{}
|
||||
currency := "USD"
|
||||
for _, row := range response.Properties.Rows {
|
||||
cost, _ := strconv.ParseFloat(fmt.Sprint(valueAt(row, "PreTaxCost", "Cost", "totalCost")), 64)
|
||||
if cost == 0 {
|
||||
continue
|
||||
}
|
||||
service := firstNonEmpty(fmt.Sprint(valueAt(row, "ServiceName")), "其他")
|
||||
region := firstNonEmpty(fmt.Sprint(valueAt(row, "ResourceLocation")), "全局")
|
||||
if value := fmt.Sprint(valueAt(row, "Currency")); value != "" && value != "<nil>" {
|
||||
currency = value
|
||||
}
|
||||
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||
}
|
||||
keys := make([]aggregationKey, 0, len(aggregation))
|
||||
for key := range aggregation {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].service == keys[j].service {
|
||||
return keys[i].region < keys[j].region
|
||||
}
|
||||
return keys[i].service < keys[j].service
|
||||
})
|
||||
records := make([]CloudBillingRecord, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
records = append(records, CloudBillingRecord{
|
||||
BillingDate: billingDate, Provider: "azure", Cost: math.Round(aggregation[key]*1000000) / 1000000,
|
||||
Currency: currency, Service: key.service, Region: key.region,
|
||||
Description: "云 API 自动同步(Azure Cost Management)",
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type BillingFetcher interface {
|
||||
}
|
||||
|
||||
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
||||
var SupportedBillingProviders = []string{"aliyun", "tencent"}
|
||||
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"}
|
||||
|
||||
// NewBillingFetcher 根据云厂商创建账单拉取器
|
||||
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||
@@ -26,6 +26,12 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||
return NewAliyunProvider(accessKey, secretKey)
|
||||
case "tencent":
|
||||
return NewTencentProvider(accessKey, secretKey)
|
||||
case "aws":
|
||||
return NewAWSProvider(accessKey, secretKey)
|
||||
case "huawei":
|
||||
return NewHuaweiProvider(accessKey, secretKey)
|
||||
case "azure":
|
||||
return NewAzureProvider(accessKey, secretKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li
|
||||
type HuaweiProvider struct{ accessKey, secretKey string }
|
||||
|
||||
// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li
|
||||
func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider {
|
||||
return &HuaweiProvider{accessKey, secretKey}
|
||||
}
|
||||
|
||||
// Name 返回华为云厂商标识。 // Logan.Li
|
||||
func (p *HuaweiProvider) Name() string { return "huawei" }
|
||||
|
||||
// signedGet 使用 SDK-HMAC-SHA256 调用华为云 GET API。 // Logan.Li
|
||||
func (p *HuaweiProvider) signedGet(rawURL, _ string) ([]byte, error) {
|
||||
u, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建华为云请求失败: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format("20060102T150405Z")
|
||||
host := u.URL.Host
|
||||
canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n"
|
||||
signedHeaders := "host;x-sdk-date"
|
||||
canonicalRequest := "GET\n" + u.URL.EscapedPath() + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("")
|
||||
stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + sha256Hex(canonicalRequest)
|
||||
signature := hex.EncodeToString(hmacSHA256([]byte(p.secretKey), stringToSign))
|
||||
u.Header.Set("X-Sdk-Date", now)
|
||||
u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求华为云 API 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取华为云响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("华为云 API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ListRegions 获取当前账号的项目列表,项目 ID 作为可用于 ECS 请求的区域代码。 // Logan.Li
|
||||
func (p *HuaweiProvider) ListRegions() ([]CloudRegion, error) {
|
||||
body, err := p.signedGet("https://iam.myhuaweicloud.com/v3/projects", "iam")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Projects []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析华为云区域响应失败: %w", err)
|
||||
}
|
||||
regions := []CloudRegion{}
|
||||
for _, project := range result.Projects {
|
||||
if project.ID == "" || project.Name == "" {
|
||||
continue
|
||||
}
|
||||
regions = append(regions, CloudRegion{RegionID: project.ID, LocalName: project.Name, Endpoint: "https://ecs." + project.Name + ".myhuaweicloud.com", Status: "active"})
|
||||
}
|
||||
sort.Slice(regions, func(i, j int) bool { return regions[i].LocalName < regions[j].LocalName })
|
||||
return regions, nil
|
||||
}
|
||||
|
||||
// ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li
|
||||
func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) {
|
||||
regions, err := p.ListRegions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
regionName := ""
|
||||
for _, region := range regions {
|
||||
if region.RegionID == projectID {
|
||||
regionName = region.LocalName
|
||||
break
|
||||
}
|
||||
}
|
||||
if regionName == "" {
|
||||
return nil, fmt.Errorf("未找到华为云项目 %s", projectID)
|
||||
}
|
||||
body, err := p.signedGet("https://ecs."+regionName+".myhuaweicloud.com/v2.1/"+projectID+"/servers/detail", "ecs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Servers []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Flavor struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"flavor"`
|
||||
Created string `json:"created"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Addresses map[string][]struct {
|
||||
Addr string `json:"addr"`
|
||||
Type string `json:"OS-EXT-IPS:type"`
|
||||
} `json:"addresses"`
|
||||
Zone string `json:"OS-EXT-AZ:availability_zone"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析华为云实例响应失败: %w", err)
|
||||
}
|
||||
instances := []CloudInstance{}
|
||||
for _, server := range result.Servers {
|
||||
privateIP, publicIP := "", ""
|
||||
for _, addresses := range server.Addresses {
|
||||
for _, address := range addresses {
|
||||
if address.Type == "floating" {
|
||||
publicIP = address.Addr
|
||||
} else if privateIP == "" {
|
||||
privateIP = address.Addr
|
||||
}
|
||||
}
|
||||
}
|
||||
osType := server.Metadata["os_type"]
|
||||
if osType == "" {
|
||||
osType = server.Metadata["__os_type"]
|
||||
}
|
||||
instances = append(instances, CloudInstance{InstanceID: server.ID, InstanceName: server.Name, Hostname: server.Name, OSType: osType, Status: huaweiState(server.Status), RegionID: projectID, ZoneID: server.Zone, PrivateIP: privateIP, PublicIP: publicIP, InstanceType: server.Flavor.ID, CreationTime: server.Created})
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// huaweiState 统一华为云实例状态,便于复用导入逻辑。 // Logan.Li
|
||||
func huaweiState(state string) string {
|
||||
if strings.EqualFold(state, "ACTIVE") {
|
||||
return "Running"
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchDailyBill 通过华为云 BSS 费用流水接口拉取单日账单。 // Logan.Li
|
||||
func (p *HuaweiProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||
if _, err := time.Parse("2006-01-02", billingDate); err != nil {
|
||||
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
type aggregationKey struct {
|
||||
service string
|
||||
region string
|
||||
}
|
||||
aggregation := map[aggregationKey]float64{}
|
||||
const pageSize = 1000
|
||||
for offset := 0; ; offset += pageSize {
|
||||
query := url.Values{
|
||||
"cycle": {billingDate[:7]},
|
||||
"bill_date_begin": {billingDate},
|
||||
"bill_date_end": {billingDate},
|
||||
"offset": {strconv.Itoa(offset)},
|
||||
"limit": {strconv.Itoa(pageSize)},
|
||||
}
|
||||
body, err := p.signedGet("https://bss.myhuaweicloud.com/v2/bills/customer-bills/fee-records?"+query.Encode(), "bss")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
TotalCount int `json:"total_count"`
|
||||
FeeRecords []struct {
|
||||
CloudServiceType string `json:"cloud_service_type"`
|
||||
CloudServiceTypeName string `json:"cloud_service_type_name"`
|
||||
ResourceTypeName string `json:"resource_type_name"`
|
||||
Region string `json:"region"`
|
||||
RegionName string `json:"region_name"`
|
||||
Amount json.RawMessage `json:"amount"`
|
||||
OfficialAmount json.RawMessage `json:"official_amount"`
|
||||
DiscountAmount json.RawMessage `json:"discount_amount"`
|
||||
} `json:"fee_records"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析华为云 BSS 响应失败: %w", err)
|
||||
}
|
||||
for _, item := range response.FeeRecords {
|
||||
cost := huaweiBillAmount(item.Amount)
|
||||
if cost == 0 {
|
||||
cost = huaweiBillAmount(item.OfficialAmount) - huaweiBillAmount(item.DiscountAmount)
|
||||
}
|
||||
if cost == 0 {
|
||||
continue
|
||||
}
|
||||
service := firstNonEmpty(item.CloudServiceTypeName, item.ResourceTypeName, item.CloudServiceType, "其他")
|
||||
region := firstNonEmpty(item.RegionName, item.Region, "全局")
|
||||
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||
}
|
||||
if len(response.FeeRecords) < pageSize || (response.TotalCount > 0 && offset+pageSize >= response.TotalCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
keys := make([]aggregationKey, 0, len(aggregation))
|
||||
for key := range aggregation {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].service == keys[j].service {
|
||||
return keys[i].region < keys[j].region
|
||||
}
|
||||
return keys[i].service < keys[j].service
|
||||
})
|
||||
records := make([]CloudBillingRecord, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
records = append(records, CloudBillingRecord{
|
||||
BillingDate: billingDate, Provider: "huawei", Cost: math.Round(aggregation[key]*100) / 100,
|
||||
Currency: "CNY", Service: key.service, Region: key.region,
|
||||
Description: "云 API 自动同步(华为云 BSS)",
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// huaweiBillAmount 兼容华为云金额字段的字符串和数字格式。 // Logan.Li
|
||||
func huaweiBillAmount(raw json.RawMessage) float64 {
|
||||
value := strings.Trim(strings.TrimSpace(string(raw)), "\"")
|
||||
amount, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return amount
|
||||
}
|
||||
@@ -42,12 +42,14 @@ func NewProvider(provider, accessKey, secretKey string) Provider {
|
||||
switch provider {
|
||||
case "aliyun":
|
||||
return NewAliyunProvider(accessKey, secretKey)
|
||||
case "aws":
|
||||
return NewAWSProvider(accessKey, secretKey)
|
||||
case "tencent":
|
||||
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
||||
// case "huawei":
|
||||
// return NewHuaweiProvider(accessKey, secretKey)
|
||||
// case "aws":
|
||||
// return NewAWSProvider(accessKey, secretKey)
|
||||
case "huawei":
|
||||
return NewHuaweiProvider(accessKey, secretKey)
|
||||
case "azure":
|
||||
return NewAzureProvider(accessKey, secretKey)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,3 +26,12 @@ func TestTencentRetryDelay(t *testing.T) {
|
||||
t.Fatal("非限频错误不应重试")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingFetcherProviders 验证五家云厂商均已注册账单拉取器。 // Logan.Li
|
||||
func TestBillingFetcherProviders(t *testing.T) {
|
||||
for _, provider := range SupportedBillingProviders {
|
||||
if fetcher := NewBillingFetcher(provider, "ak", "sk"); fetcher == nil {
|
||||
t.Fatalf("云厂商 %s 未注册账单拉取器", provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user