mirror of
https://github.com/Kori1c/ecs-controller.git
synced 2026-09-07 00:18:14 +08:00
837 lines
29 KiB
Go
837 lines
29 KiB
Go
package cloud
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Instance struct {
|
|
ID string `json:"instanceId"`
|
|
Name string `json:"instanceName"`
|
|
Status string `json:"status"`
|
|
PublicIP string `json:"publicIp"`
|
|
PrivateIP string `json:"privateIp"`
|
|
InstanceType string `json:"instanceType"`
|
|
CPU int `json:"cpu"`
|
|
Memory int `json:"memory"`
|
|
OSName string `json:"osName"`
|
|
}
|
|
|
|
type RunRequest struct {
|
|
RegionID, ZoneID, InstanceType, ImageID, InstanceName string
|
|
VPCID, VSwitchID, SecurityGroupID string
|
|
Bandwidth, DiskSize, LoginPort int
|
|
DiskCategory, PublicIPMode, Password, ClientToken string
|
|
}
|
|
|
|
type RunResult struct{ InstanceID, PublicIP string }
|
|
|
|
type Client interface {
|
|
DescribeRegions(context.Context) ([]map[string]any, error)
|
|
DescribeZones(context.Context, string) ([]map[string]any, error)
|
|
DescribeInstances(context.Context, string) ([]Instance, error)
|
|
DescribeInstance(context.Context, string, string) (*Instance, error)
|
|
StartInstance(context.Context, string, string) error
|
|
StopInstance(context.Context, string, string, string) error
|
|
DeleteInstance(context.Context, string, string) error
|
|
RunInstances(context.Context, RunRequest) (RunResult, error)
|
|
AllocateEIP(context.Context, string) (string, string, error)
|
|
AssociateEIP(context.Context, string, string, string) error
|
|
UnassociateEIP(context.Context, string, string) error
|
|
ReleaseEIP(context.Context, string, string) error
|
|
PrepareNetwork(context.Context, string, string, string, string) (vpcID, vswitchID, securityGroupID string, err error)
|
|
CleanupNetwork(context.Context, string, string, string, string) error
|
|
GetTraffic(context.Context, string) (float64, error)
|
|
GetOutboundTrafficDelta(context.Context, string, string, string, int64, int64) (bytes float64, lastSampleMS int64, points int, metric string, err error)
|
|
GetBilling(context.Context, string, string, string) (balance float64, monthlyCost float64, currency string, err error)
|
|
}
|
|
|
|
// PreflightClient contains the optional inventory APIs used by the create
|
|
// preview. Keeping it separate preserves compatibility with small fake cloud
|
|
// clients used by monitoring and unit tests.
|
|
type PreflightClient interface {
|
|
DescribeInstanceType(context.Context, string, string) (map[string]any, error)
|
|
DescribeAvailableZones(context.Context, string, string, string) ([]map[string]any, error)
|
|
DescribeImagesForArchitecture(context.Context, string, string, string) ([]map[string]any, error)
|
|
GetSystemDiskOptions(context.Context, string, string, string) ([]map[string]any, error)
|
|
}
|
|
|
|
type BillingClient interface {
|
|
GetAccountBalance(context.Context, string) (float64, string, error)
|
|
GetBillOverview(context.Context, string, string) (float64, string, error)
|
|
}
|
|
|
|
// MonthlyTrafficClient queries the current month's instance traffic directly
|
|
// from CloudMonitor instead of accumulating samples from the last refresh.
|
|
type MonthlyTrafficClient interface {
|
|
GetInstanceMonthlyTraffic(context.Context, string, string, string, int64, int64) (bytes float64, points int, err error)
|
|
}
|
|
|
|
// NetworkClient allows the caller to authorize the correct remote login port
|
|
// without changing the legacy Client interface.
|
|
type NetworkClient interface {
|
|
PrepareNetworkForPort(context.Context, string, string, string, string, int) (string, string, string, error)
|
|
}
|
|
|
|
// BandwidthEIPClient preserves the legacy AllocateEIP method while allowing
|
|
// ECS creation and replacement to carry the user's selected bandwidth.
|
|
type BandwidthEIPClient interface {
|
|
AllocateEIPWithBandwidth(context.Context, string, int) (string, string, error)
|
|
}
|
|
|
|
type Service struct{ ECS, VPC, EIP, CMS, CDT, BSS *RPCClient }
|
|
|
|
func NewRPCService(accessKey, secret, region string) *Service {
|
|
base := func(product, version, endpoint string) *RPCClient {
|
|
return &RPCClient{HTTPClient: &http.Client{Timeout: 30 * time.Second}, Endpoint: endpoint, Version: version, Product: product, AccessKey: accessKey, Secret: secret}
|
|
}
|
|
return &Service{
|
|
ECS: base("Ecs", "2014-05-26", "https://ecs."+region+".aliyuncs.com/"),
|
|
VPC: base("Vpc", "2016-04-28", "https://vpc."+region+".aliyuncs.com/"),
|
|
EIP: base("Vpc", "2016-04-28", "https://vpc."+region+".aliyuncs.com/"),
|
|
CMS: base("Cms", "2019-01-01", "https://metrics.aliyuncs.com/"),
|
|
CDT: base("CDT", "2021-08-13", "https://cdt.aliyuncs.com/"),
|
|
BSS: base("BssOpenApi", "2017-12-14", "https://business.aliyuncs.com/"),
|
|
}
|
|
}
|
|
|
|
func (s *Service) WithSite(siteType string) *Service {
|
|
copyService := *s
|
|
if siteType == "international" {
|
|
copyService.BSS = &RPCClient{HTTPClient: s.BSS.HTTPClient, Endpoint: "https://business.ap-southeast-1.aliyuncs.com/", Version: "2017-12-14", Product: "BssOpenApi", AccessKey: s.BSS.AccessKey, Secret: s.BSS.Secret}
|
|
}
|
|
return ©Service
|
|
}
|
|
|
|
func (s *Service) DescribeRegions(ctx context.Context) ([]map[string]any, error) {
|
|
result, err := s.ECS.Call(ctx, "DescribeRegions", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mapsAt(result, "Regions.Region"), nil
|
|
}
|
|
|
|
func (s *Service) DescribeZones(ctx context.Context, region string) ([]map[string]any, error) {
|
|
result, err := s.ECS.Call(ctx, "DescribeZones", map[string]string{"RegionId": region})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mapsAt(result, "Zones.Zone"), nil
|
|
}
|
|
|
|
func (s *Service) DescribeImages(ctx context.Context, region, osKey string) ([]map[string]any, error) {
|
|
return s.describeImages(ctx, region, osKey, "")
|
|
}
|
|
|
|
func (s *Service) DescribeImagesForArchitecture(ctx context.Context, region, osKey, architecture string) ([]map[string]any, error) {
|
|
return s.describeImages(ctx, region, osKey, architecture)
|
|
}
|
|
|
|
func (s *Service) describeImages(ctx context.Context, region, osKey, architecture string) ([]map[string]any, error) {
|
|
params := map[string]string{"RegionId": region, "Status": "Available", "ImageOwnerAlias": "system", "PageSize": "100"}
|
|
if architecture != "" {
|
|
params["Architecture"] = architecture
|
|
}
|
|
result, err := s.ECS.Call(ctx, "DescribeImages", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
images := mapsAt(result, "Images.Image")
|
|
key := strings.ToLower(osKey)
|
|
out := make([]map[string]any, 0)
|
|
for _, image := range images {
|
|
text := strings.ToLower(stringValue(image["OSName"]) + " " + stringValue(image["OSNameEn"]) + " " + stringValue(image["ImageName"]))
|
|
if key == "" || ((strings.Contains(key, "debian") && strings.Contains(text, "debian")) || (strings.Contains(key, "ubuntu") && strings.Contains(text, "ubuntu")) || (strings.Contains(key, "windows") && strings.Contains(text, "windows")) || (strings.Contains(key, "alibaba") && strings.Contains(text, "alibaba")) || (strings.Contains(key, "centos") && strings.Contains(text, "centos"))) {
|
|
out = append(out, image)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) DescribeInstanceType(ctx context.Context, region, instanceType string) (map[string]any, error) {
|
|
encoded, _ := json.Marshal([]string{instanceType})
|
|
result, err := s.ECS.Call(ctx, "DescribeInstanceTypes", map[string]string{"RegionId": region, "InstanceTypes": string(encoded)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
types := mapsAt(result, "InstanceTypes.InstanceType")
|
|
for _, item := range types {
|
|
if stringValue(item["InstanceTypeId"]) == instanceType {
|
|
return item, nil
|
|
}
|
|
}
|
|
if len(types) == 0 {
|
|
return nil, fmt.Errorf("实例规格 %s 不存在或当前账号不可用", instanceType)
|
|
}
|
|
return types[0], nil
|
|
}
|
|
|
|
func (s *Service) DescribeAvailableZones(ctx context.Context, region, instanceType, diskCategory string) ([]map[string]any, error) {
|
|
params := map[string]string{"RegionId": region, "DestinationResource": "Zone", "InstanceType": instanceType, "InstanceChargeType": "PostPaid", "SpotStrategy": "NoSpot", "NetworkCategory": "vpc", "IoOptimized": "optimized", "AvailableResourceCreation": "Instance"}
|
|
if diskCategory != "" {
|
|
params["SystemDisk.Category"] = diskCategory
|
|
}
|
|
result, err := s.ECS.Call(ctx, "DescribeAvailableResource", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
zones := mapsAt(result, "AvailableZones.AvailableZone")
|
|
if len(zones) == 0 {
|
|
// Some API responses expose the same records under Zones.Zone.
|
|
zones = mapsAt(result, "Zones.Zone")
|
|
}
|
|
out := make([]map[string]any, 0, len(zones))
|
|
for _, zone := range zones {
|
|
status := strings.ToLower(stringValue(zone["Status"]))
|
|
if status != "" && status != "available" && status != "stock" {
|
|
continue
|
|
}
|
|
out = append(out, zone)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) DescribeImagesWithArchitecture(ctx context.Context, region, osKey, architecture string) ([]map[string]any, error) {
|
|
return s.describeImages(ctx, region, osKey, architecture)
|
|
}
|
|
|
|
func (s *Service) GetSystemDiskOptions(ctx context.Context, region, zone, instanceType string) ([]map[string]any, error) {
|
|
result, err := s.ECS.Call(ctx, "DescribeAvailableResource", map[string]string{"RegionId": region, "ZoneId": zone, "DestinationResource": "SystemDisk", "InstanceType": instanceType, "InstanceChargeType": "PostPaid", "SpotStrategy": "NoSpot", "NetworkCategory": "vpc", "IoOptimized": "optimized"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return diskOptionsFromResponse(result), nil
|
|
}
|
|
|
|
func (s *Service) DescribeInstances(ctx context.Context, region string) ([]Instance, error) {
|
|
const pageSize = 100
|
|
const maxPages = 10000
|
|
instances := make([]Instance, 0)
|
|
for page := 1; page <= maxPages; page++ {
|
|
result, err := s.ECS.Call(ctx, "DescribeInstances", map[string]string{
|
|
"RegionId": region,
|
|
"PageNumber": strconv.Itoa(page),
|
|
"PageSize": strconv.Itoa(pageSize),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := mapsAt(result, "Instances.Instance")
|
|
total := intValue(result["TotalCount"])
|
|
if total > 0 && len(items) == 0 && len(instances) < total {
|
|
return nil, fmt.Errorf("DescribeInstances returned an incomplete page")
|
|
}
|
|
for _, item := range items {
|
|
instances = append(instances, instanceFromMap(item))
|
|
}
|
|
if total > 0 && len(instances) >= total {
|
|
return instances, nil
|
|
}
|
|
if len(items) < pageSize {
|
|
return instances, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("DescribeInstances exceeded the pagination safety limit")
|
|
}
|
|
|
|
func (s *Service) DescribeInstance(ctx context.Context, region, id string) (*Instance, error) {
|
|
result, err := s.ECS.Call(ctx, "DescribeInstances", map[string]string{"RegionId": region, "InstanceId.1": id})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := mapsAt(result, "Instances.Instance")
|
|
if len(items) == 0 {
|
|
return nil, &APIError{Code: "InvalidInstanceId.NotFound", Message: fmt.Sprintf("instance %s not found", id)}
|
|
}
|
|
v := instanceFromMap(items[0])
|
|
return &v, nil
|
|
}
|
|
|
|
func (s *Service) StartInstance(ctx context.Context, region, id string) error {
|
|
_, err := s.ECS.Call(ctx, "StartInstance", map[string]string{"RegionId": region, "InstanceId": id})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) StopInstance(ctx context.Context, region, id, mode string) error {
|
|
_, err := s.ECS.Call(ctx, "StopInstance", map[string]string{"RegionId": region, "InstanceId": id, "StoppedMode": mode})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) DeleteInstance(ctx context.Context, region, id string) error {
|
|
_, err := s.ECS.Call(ctx, "DeleteInstance", map[string]string{"RegionId": region, "InstanceId": id, "Force": "true"})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) RunInstances(ctx context.Context, req RunRequest) (RunResult, error) {
|
|
bandwidth := req.Bandwidth
|
|
allocatePublicIP := "true"
|
|
if req.PublicIPMode == "eip" {
|
|
bandwidth = 0
|
|
allocatePublicIP = "false"
|
|
}
|
|
clientToken := req.ClientToken
|
|
if clientToken == "" {
|
|
clientToken = req.InstanceName
|
|
}
|
|
p := map[string]string{"RegionId": req.RegionID, "ZoneId": req.ZoneID, "InstanceType": req.InstanceType, "ImageId": req.ImageID, "InstanceName": req.InstanceName, "VSwitchId": req.VSwitchID, "SecurityGroupId.1": req.SecurityGroupID, "InternetMaxBandwidthOut": strconv.Itoa(bandwidth), "AllocatePublicIp": allocatePublicIP, "InternetChargeType": "PayByTraffic", "InstanceChargeType": "PostPaid", "Password": req.Password, "Amount": "1", "ClientToken": clientToken, "IoOptimized": "optimized", "DeletionProtection": "false"}
|
|
if req.VPCID != "" {
|
|
p["VpcId"] = req.VPCID
|
|
}
|
|
if req.DiskCategory != "" {
|
|
p["SystemDisk.Category"] = req.DiskCategory
|
|
}
|
|
if req.DiskSize > 0 {
|
|
p["SystemDisk.Size"] = strconv.Itoa(req.DiskSize)
|
|
}
|
|
result, err := s.ECS.Call(ctx, "RunInstances", p)
|
|
if err != nil {
|
|
return RunResult{}, err
|
|
}
|
|
id := stringValue(result["InstanceId"])
|
|
if id == "" {
|
|
if ids := mapsAt(result, "InstanceIdSet.InstanceId"); len(ids) > 0 {
|
|
id = stringValue(ids[0]["InstanceId"])
|
|
}
|
|
}
|
|
return RunResult{InstanceID: id, PublicIP: stringValue(result["PublicIpAddress"])}, nil
|
|
}
|
|
|
|
func (s *Service) AllocateEIP(ctx context.Context, region string) (string, string, error) {
|
|
return s.AllocateEIPWithBandwidth(ctx, region, 100)
|
|
}
|
|
|
|
func (s *Service) AllocateEIPWithBandwidth(ctx context.Context, region string, bandwidth int) (string, string, error) {
|
|
if bandwidth < 1 {
|
|
bandwidth = 1
|
|
}
|
|
result, err := s.EIP.Call(ctx, "AllocateEipAddress", map[string]string{"RegionId": region, "InternetChargeType": "PayByTraffic", "Bandwidth": strconv.Itoa(bandwidth)})
|
|
return stringValue(result["AllocationId"]), stringValue(result["EipAddress"]), err
|
|
}
|
|
func (s *Service) AssociateEIP(ctx context.Context, region, allocationID, instanceID string) error {
|
|
_, err := s.EIP.Call(ctx, "AssociateEipAddress", map[string]string{"RegionId": region, "AllocationId": allocationID, "InstanceId": instanceID, "InstanceType": "Ecs"})
|
|
return err
|
|
}
|
|
func (s *Service) UnassociateEIP(ctx context.Context, region, allocationID string) error {
|
|
_, err := s.EIP.Call(ctx, "UnassociateEipAddress", map[string]string{"RegionId": region, "AllocationId": allocationID})
|
|
return err
|
|
}
|
|
func (s *Service) ReleaseEIP(ctx context.Context, region, allocationID string) error {
|
|
_, err := s.EIP.Call(ctx, "ReleaseEipAddress", map[string]string{"RegionId": region, "AllocationId": allocationID})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) GetTraffic(ctx context.Context, targetRegion string) (float64, error) {
|
|
result, err := s.CDT.Call(ctx, "ListCdtInternetTraffic", nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
details, ok := result["TrafficDetails"].([]any)
|
|
if !ok {
|
|
if raw, isString := result["TrafficDetails"].(string); isString {
|
|
_ = json.Unmarshal([]byte(raw), &details)
|
|
}
|
|
}
|
|
if !ok && len(details) == 0 {
|
|
return 0, fmt.Errorf("CDT response missing TrafficDetails")
|
|
}
|
|
targetOverseas := overseasRegion(targetRegion)
|
|
var bytes float64
|
|
for _, raw := range details {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if overseasRegion(stringValue(item["BusinessRegionId"])) == targetOverseas {
|
|
bytes += floatValue(item["Traffic"])
|
|
}
|
|
}
|
|
return bytes / (1024 * 1024 * 1024), nil
|
|
}
|
|
|
|
func (s *Service) GetBilling(ctx context.Context, siteType, instanceID, billingCycle string) (float64, float64, string, error) {
|
|
bss := s.WithSite(siteType).BSS
|
|
balance, currency, err := s.GetAccountBalance(ctx, siteType)
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
billResult, err := bss.Call(ctx, "DescribeInstanceBill", map[string]string{"BillingCycle": billingCycle, "InstanceID": instanceID, "Granularity": "MONTHLY"})
|
|
if err != nil {
|
|
return balance, 0, currency, err
|
|
}
|
|
items := mapsAt(billResult, "Data.Items.Item")
|
|
var total float64
|
|
for _, item := range items {
|
|
total += floatValue(item["PretaxAmount"])
|
|
}
|
|
return balance, total, currency, nil
|
|
}
|
|
|
|
func (s *Service) GetAccountBalance(ctx context.Context, siteType string) (float64, string, error) {
|
|
bss := s.WithSite(siteType).BSS
|
|
result, err := bss.Call(ctx, "QueryAccountBalance", nil)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
data, _ := result["Data"].(map[string]any)
|
|
currency := stringValue(data["Currency"])
|
|
if currency == "" {
|
|
currency = "CNY"
|
|
}
|
|
return floatValue(data["AvailableAmount"]), currency, nil
|
|
}
|
|
|
|
func (s *Service) GetBillOverview(ctx context.Context, siteType, billingCycle string) (float64, string, error) {
|
|
bss := s.WithSite(siteType).BSS
|
|
result, err := bss.Call(ctx, "QueryBillOverview", map[string]string{"BillingCycle": billingCycle, "Granularity": "MONTHLY"})
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
data, _ := result["Data"].(map[string]any)
|
|
total := floatValue(data["TotalCost"])
|
|
if total == 0 {
|
|
total = floatValue(data["TotalAmount"])
|
|
}
|
|
if total == 0 {
|
|
for _, item := range mapsAt(result, "Data.Items.Item") {
|
|
total += floatValue(item["PretaxAmount"])
|
|
}
|
|
}
|
|
currency := stringValue(data["Currency"])
|
|
if currency == "" {
|
|
currency = "CNY"
|
|
}
|
|
return total, currency, nil
|
|
}
|
|
|
|
func (s *Service) GetOutboundTrafficDelta(ctx context.Context, region, instanceID, publicIP string, startMS, endMS int64) (float64, int64, int, string, error) {
|
|
if endMS <= startMS {
|
|
return 0, startMS, 0, "", nil
|
|
}
|
|
type candidate struct {
|
|
name string
|
|
dimensions string
|
|
}
|
|
candidates := []candidate{{"InternetOutRate", `{"instanceId":"` + instanceID + `"}`}}
|
|
if publicIP != "" {
|
|
candidates = append([]candidate{{"VPC_PublicIP_InternetOutRate", `{"instanceId":"` + instanceID + `","ip":"` + publicIP + `"}`}}, candidates...)
|
|
}
|
|
var lastErr error
|
|
for _, candidate := range candidates {
|
|
result, err := s.CMS.Call(ctx, "DescribeMetricList", map[string]string{"Namespace": "acs_ecs_dashboard", "MetricName": candidate.name, "Period": "60", "StartTime": strconv.FormatInt(startMS, 10), "EndTime": strconv.FormatInt(endMS, 10), "Dimensions": candidate.dimensions, "Length": "1440"})
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
points, err := metricPoints(result["Datapoints"])
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
var bytes float64
|
|
var last int64
|
|
count := 0
|
|
for _, point := range points {
|
|
ts := int64(floatValue(point["timestamp"]))
|
|
if ts <= startMS || ts > endMS {
|
|
continue
|
|
}
|
|
rate := floatValue(point["Average"])
|
|
if rate == 0 {
|
|
rate = floatValue(point["Maximum"])
|
|
}
|
|
if rate == 0 {
|
|
rate = floatValue(point["Minimum"])
|
|
}
|
|
if rate < 0 {
|
|
rate = 0
|
|
}
|
|
bytes += rate * 60 / 8
|
|
if ts > last {
|
|
last = ts
|
|
}
|
|
count++
|
|
}
|
|
// A successful CMS response with no points means this metric/dimension
|
|
// did not produce a usable sample. Let the next candidate or CDT fallback
|
|
// handle it instead of recording a false zero.
|
|
if count > 0 {
|
|
return bytes, last, count, candidate.name, nil
|
|
}
|
|
lastErr = fmt.Errorf("CMS metric %s returned no datapoints", candidate.name)
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("CMS returned no usable datapoints")
|
|
}
|
|
return 0, startMS, 0, "", lastErr
|
|
}
|
|
|
|
func (s *Service) GetInstanceMonthlyTraffic(ctx context.Context, region, instanceID, publicIP string, startMS, endMS int64) (float64, int, error) {
|
|
if endMS <= startMS {
|
|
return 0, 0, nil
|
|
}
|
|
type candidate struct {
|
|
name string
|
|
dimensions string
|
|
}
|
|
candidates := []candidate{{"InternetOutRate", `{"instanceId":"` + instanceID + `"}`}}
|
|
if publicIP != "" {
|
|
candidates = append([]candidate{{"VPC_PublicIP_InternetOutRate", `{"instanceId":"` + instanceID + `","ip":"` + publicIP + `"}`}}, candidates...)
|
|
}
|
|
var lastErr error
|
|
const periodSeconds = 3600
|
|
for _, candidate := range candidates {
|
|
result, err := s.CMS.Call(ctx, "DescribeMetricList", map[string]string{
|
|
"Namespace": "acs_ecs_dashboard",
|
|
"MetricName": candidate.name,
|
|
"Period": strconv.Itoa(periodSeconds),
|
|
"StartTime": strconv.FormatInt(startMS, 10),
|
|
"EndTime": strconv.FormatInt(endMS, 10),
|
|
"Dimensions": candidate.dimensions,
|
|
"Length": "1440",
|
|
})
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
points, err := metricPoints(result["Datapoints"])
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
var bytes float64
|
|
count := 0
|
|
for _, point := range points {
|
|
ts := int64(floatValue(point["timestamp"]))
|
|
if ts <= startMS || ts > endMS {
|
|
continue
|
|
}
|
|
rate := floatValue(point["Average"])
|
|
if rate == 0 {
|
|
rate = floatValue(point["Maximum"])
|
|
}
|
|
if rate == 0 {
|
|
rate = floatValue(point["Minimum"])
|
|
}
|
|
if rate < 0 {
|
|
rate = 0
|
|
}
|
|
bytes += rate * periodSeconds / 8
|
|
count++
|
|
}
|
|
if count > 0 {
|
|
return bytes, count, nil
|
|
}
|
|
lastErr = fmt.Errorf("CMS metric %s returned no monthly datapoints", candidate.name)
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("CMS returned no monthly datapoints")
|
|
}
|
|
return 0, 0, lastErr
|
|
}
|
|
|
|
func metricPoints(value any) ([]map[string]any, error) {
|
|
if raw, ok := value.(string); ok {
|
|
var points []map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &points); err != nil {
|
|
return nil, err
|
|
}
|
|
return points, nil
|
|
}
|
|
if raw, ok := value.([]any); ok {
|
|
points := make([]map[string]any, 0, len(raw))
|
|
for _, item := range raw {
|
|
if point, ok := item.(map[string]any); ok {
|
|
points = append(points, point)
|
|
}
|
|
}
|
|
return points, nil
|
|
}
|
|
return nil, fmt.Errorf("CMS response missing Datapoints")
|
|
}
|
|
|
|
func overseasRegion(region string) bool {
|
|
region = strings.ToLower(strings.TrimSpace(region))
|
|
if region == "" {
|
|
return false
|
|
}
|
|
// Alibaba treats Hong Kong as an overseas CDT billing region even though
|
|
// its identifier starts with cn-.
|
|
return region == "cn-hongkong" || !strings.HasPrefix(region, "cn-")
|
|
}
|
|
func floatValue(value any) float64 {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return v
|
|
case int:
|
|
return float64(v)
|
|
case string:
|
|
n, _ := strconv.ParseFloat(v, 64)
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *Service) PrepareNetwork(ctx context.Context, region, cidr, zone, clientCIDR string) (string, string, string, error) {
|
|
return s.PrepareNetworkForPort(ctx, region, cidr, zone, clientCIDR, 22)
|
|
}
|
|
|
|
func (s *Service) PrepareNetworkForPort(ctx context.Context, region, cidr, zone, clientCIDR string, port int) (string, string, string, error) {
|
|
// Network provisioning is deliberately explicit. Existing resources can be
|
|
// selected by the caller; this path creates a minimal isolated network.
|
|
vpc, err := s.VPC.Call(ctx, "CreateVpc", map[string]string{"RegionId": region, "CidrBlock": cidr, "VpcName": "ecs-controller"})
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
vpcID := stringValue(vpc["VpcId"])
|
|
vs, err := s.VPC.Call(ctx, "CreateVSwitch", map[string]string{"RegionId": region, "VpcId": vpcID, "ZoneId": zone, "CidrBlock": "192.168.0.0/24", "VSwitchName": "ecs-controller"})
|
|
if err != nil {
|
|
return vpcID, "", "", err
|
|
}
|
|
vsID := stringValue(vs["VSwitchId"])
|
|
sg, err := s.ECS.Call(ctx, "CreateSecurityGroup", map[string]string{"RegionId": region, "VpcId": vpcID, "SecurityGroupName": "ecs-controller"})
|
|
if err != nil {
|
|
return vpcID, vsID, "", err
|
|
}
|
|
sgID := stringValue(sg["SecurityGroupId"])
|
|
if port <= 0 {
|
|
port = 22
|
|
}
|
|
if clientCIDR != "" && clientCIDR != "0.0.0.0/0" {
|
|
if _, authErr := s.ECS.Call(ctx, "AuthorizeSecurityGroup", map[string]string{"RegionId": region, "SecurityGroupId": sgID, "IpProtocol": "tcp", "PortRange": strconv.Itoa(port) + "/" + strconv.Itoa(port), "SourceCidrIp": clientCIDR, "Policy": "accept", "Priority": "1", "Description": "ecs-controller remote access"}); authErr != nil {
|
|
return vpcID, vsID, sgID, authErr
|
|
}
|
|
}
|
|
return vpcID, vsID, sgID, nil
|
|
}
|
|
|
|
func (s *Service) CleanupNetwork(ctx context.Context, region, vpcID, vswitchID, securityGroupID string) error {
|
|
var firstErr error
|
|
if securityGroupID != "" {
|
|
if _, err := s.ECS.Call(ctx, "DeleteSecurityGroup", map[string]string{"RegionId": region, "SecurityGroupId": securityGroupID}); err != nil && !IsNotFound(err) {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if vswitchID != "" {
|
|
if _, err := s.VPC.Call(ctx, "DeleteVSwitch", map[string]string{"RegionId": region, "VSwitchId": vswitchID}); err != nil && !IsNotFound(err) && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if vpcID != "" {
|
|
if _, err := s.VPC.Call(ctx, "DeleteVpc", map[string]string{"RegionId": region, "VpcId": vpcID}); err != nil && !IsNotFound(err) && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func mapsAt(root map[string]any, path string) []map[string]any {
|
|
var current any = root
|
|
for _, part := range splitPath(path) {
|
|
m, ok := current.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
current = m[part]
|
|
}
|
|
if m, ok := current.(map[string]any); ok {
|
|
if items, ok := m["items"].([]any); ok {
|
|
return anyMaps(items)
|
|
}
|
|
return []map[string]any{m}
|
|
}
|
|
if items, ok := current.([]any); ok {
|
|
return anyMaps(items)
|
|
}
|
|
return nil
|
|
}
|
|
func splitPath(path string) []string {
|
|
var out []string
|
|
for _, p := range stringsSplit(path, ".") {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func stringsSplit(s, sep string) []string {
|
|
var out []string
|
|
for s != "" {
|
|
i := indexString(s, sep)
|
|
if i < 0 {
|
|
return append(out, s)
|
|
}
|
|
out = append(out, s[:i])
|
|
s = s[i+len(sep):]
|
|
}
|
|
return out
|
|
}
|
|
func indexString(s, sub string) int {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
func anyMaps(items []any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, v := range items {
|
|
if m, ok := v.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func diskOptionsFromResponse(root map[string]any) []map[string]any {
|
|
options := make([]map[string]any, 0)
|
|
seen := map[string]bool{}
|
|
add := func(category string, min, max int, raw map[string]any) {
|
|
category = strings.TrimSpace(category)
|
|
if category == "" || seen[category] || !isDiskCategory(category) {
|
|
return
|
|
}
|
|
if min <= 0 {
|
|
min = 20
|
|
}
|
|
if max <= 0 {
|
|
max = 32768
|
|
}
|
|
seen[category] = true
|
|
options = append(options, map[string]any{"value": category, "label": diskCategoryLabel(category), "min": min, "max": max, "unit": "GB", "raw": raw})
|
|
}
|
|
for _, item := range collectMaps(root, "AvailableResource") {
|
|
category := firstString(item, "Category", "Value", "SystemDiskCategory")
|
|
if nested, ok := item["SystemDisk"].(map[string]any); ok && category == "" {
|
|
category = stringValue(nested["Category"])
|
|
}
|
|
add(category, firstInt(item, "MinSystemDiskSize", "MinSize", "Min"), firstInt(item, "MaxSystemDiskSize", "MaxSize", "Max"), item)
|
|
}
|
|
for _, item := range collectMaps(root, "SupportedResource") {
|
|
add(firstString(item, "Category", "Value", "Name"), firstInt(item, "MinSize", "MinSystemDiskSize", "Min"), firstInt(item, "MaxSize", "MaxSystemDiskSize", "Max"), item)
|
|
}
|
|
for _, item := range collectMaps(root, "DiskCategory") {
|
|
category := firstString(item, "Category", "Value", "Name")
|
|
add(category, firstInt(item, "MinSize", "MinSystemDiskSize", "Min"), firstInt(item, "MaxSize", "MaxSystemDiskSize", "Max"), item)
|
|
}
|
|
return options
|
|
}
|
|
|
|
func isDiskCategory(category string) bool {
|
|
category = strings.ToLower(category)
|
|
return category == "cloud" || strings.HasPrefix(category, "cloud_") || strings.HasPrefix(category, "ephemeral") || strings.Contains(category, "disk")
|
|
}
|
|
|
|
func collectMaps(value any, key string) []map[string]any {
|
|
result := make([]map[string]any, 0)
|
|
var visit func(any)
|
|
visit = func(current any) {
|
|
switch item := current.(type) {
|
|
case map[string]any:
|
|
for name, child := range item {
|
|
if name == key {
|
|
switch matched := child.(type) {
|
|
case map[string]any:
|
|
result = append(result, matched)
|
|
case []any:
|
|
for _, entry := range matched {
|
|
if mapped, ok := entry.(map[string]any); ok {
|
|
result = append(result, mapped)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
visit(child)
|
|
}
|
|
case []any:
|
|
for _, child := range item {
|
|
visit(child)
|
|
}
|
|
}
|
|
}
|
|
visit(value)
|
|
return result
|
|
}
|
|
|
|
func firstString(m map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := stringValue(m[key]); value != "" && value != "<nil>" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstInt(m map[string]any, keys ...string) int {
|
|
for _, key := range keys {
|
|
value := intValue(m[key])
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func diskCategoryLabel(category string) string {
|
|
return map[string]string{"cloud_essd_entry": "ESSD Entry", "cloud_essd": "ESSD", "cloud_efficiency": "高效云盘", "cloud": "普通云盘"}[category]
|
|
}
|
|
|
|
func instanceFromMap(m map[string]any) Instance {
|
|
publicIP := firstIP(m, "PublicIpAddress", "EipAddress", "EIPAddress")
|
|
privateIP := firstNestedIP(m, "VpcAttributes", "PrivateIpAddress", "PrivateIpAddressSet")
|
|
return Instance{
|
|
ID: stringValue(m["InstanceId"]),
|
|
Name: stringValue(m["InstanceName"]),
|
|
Status: stringValue(m["Status"]),
|
|
PublicIP: publicIP,
|
|
PrivateIP: privateIP,
|
|
InstanceType: firstString(m, "InstanceType", "InstanceTypeId"),
|
|
CPU: firstInt(m, "Cpu", "CpuCoreCount", "CPU", "CoreCount"),
|
|
Memory: firstInt(m, "Memory", "MemorySize", "MemoryMB", "MemorySizeInMB"),
|
|
OSName: firstString(m, "OSName", "OSNameEn", "OSNameZh"),
|
|
}
|
|
}
|
|
|
|
func firstIP(root map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if ip := ipValue(root[key]); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstNestedIP(root map[string]any, parent string, keys ...string) string {
|
|
nested, ok := root[parent].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return firstIP(nested, keys...)
|
|
}
|
|
|
|
func ipValue(value any) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case []any:
|
|
for _, item := range v {
|
|
if ip := ipValue(item); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
case map[string]any:
|
|
for _, key := range []string{"IpAddress", "EipAddress", "PublicIpAddress", "Address"} {
|
|
if ip := ipValue(v[key]); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func intValue(v any) int { n, _ := strconv.Atoi(stringValue(v)); return n }
|