feat: add instance change pricing and catalog metadata
test / go (push) Successful in 51s

This commit is contained in:
shiran
2026-08-31 16:40:43 +08:00
parent 8392db9ae3
commit 0a1b3ac8c0
6 changed files with 215 additions and 37 deletions
+5
View File
@@ -50,6 +50,11 @@ func (s *PricingService) EstimateExistingInstance(ctx context.Context, region, i
err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/price-estimate", map[string]any{}, &result, "")
return &result, err
}
func (s *PricingService) EstimateInstanceChange(ctx context.Context, region, instanceID string, input InstanceChangePriceRequest) (*InstanceChangePriceEstimate, error) {
var result InstanceChangePriceEstimate
err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/change-price-estimate", input, &result, newID())
return &result, err
}
func catalogPath(region, resource string, options ListOptions) string {
values := url.Values{}
if options.Query != "" {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"time"
)
const Version = "0.1.0"
const Version = "0.3.0"
type Logger interface {
Printf(format string, args ...any)
+65
View File
@@ -21,6 +21,24 @@ func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Serv
return client, server
}
func TestCreateOptionsDecodeDetailedImageAndInstanceTypeMetadata(t *testing.T) {
var options CreateOptions
raw := `{
"region":"ap-northeast-1",
"images":[{"catalog_id":"image-row","region":"ap-northeast-1","category_id":"linux","image_id":"ami-1","source_scope":"quick-start","source_name":"al2023","display_name":"Amazon Linux","availability_status":"available","description":"Linux image","architecture":"x86_64","platform_details":"Linux/UNIX","os_family":"linux","recommended_user":"ec2-user","root_volume_size_gib":8,"root_volume_type":"gp3","virtualization_type":"hvm","ena_support":true,"public":true}],
"instance_types":[{"id":"type-row","region":"ap-northeast-1","category_id":"general","instance_type":"g4dn.xlarge","vcpus":4,"memory_mib":16384,"architectures":["x86_64"],"network_performance":"Up to 25 Gigabit","network_baseline_gbps":5,"network_peak_gbps":25,"gpu_count":1,"gpu_memory_mib":16384,"gpus":[{"manufacturer":"NVIDIA","name":"T4","count":1,"memory_mib":16384}],"current_generation":true}]
}`
if err := json.Unmarshal([]byte(raw), &options); err != nil {
t.Fatal(err)
}
if len(options.Images) != 1 || options.Images[0].Architecture != "x86_64" || options.Images[0].RecommendedUser != "ec2-user" || options.Images[0].RootVolumeSizeGiB != 8 || !options.Images[0].ENASupport {
t.Fatalf("detailed image metadata was not decoded: %#v", options.Images)
}
if len(options.InstanceTypes) != 1 || options.InstanceTypes[0].NetworkPeakGbps != 25 || options.InstanceTypes[0].GPUMemoryMiB != 16384 || len(options.InstanceTypes[0].GPUs) != 1 || options.InstanceTypes[0].GPUs[0].Name != "T4" {
t.Fatalf("detailed instance type metadata was not decoded: %#v", options.InstanceTypes)
}
}
func TestClientSendsAuthAndStableIdempotencyOnRetry(t *testing.T) {
var mutex sync.Mutex
attempts := 0
@@ -138,3 +156,50 @@ func TestRuntimePricingAndInstanceEIPOperations(t *testing.T) {
}
}
}
func TestInstanceChangePricingAndAsyncTraffic(t *testing.T) {
paths := []string{}
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/sdk/v1/regions/us-east-1/instances/local-1/change-price-estimate" {
var body InstanceChangePriceRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind != InstanceChangeTrafficLimit || body.LimitGiB == nil || *body.LimitGiB != 200 {
t.Fatalf("unexpected quote body %#v err=%v", body, err)
}
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"kind":"traffic_limit","currency":"USD","charge_price":5.5,"configuration_fingerprint":"fp"}}`))
return
}
if r.Header.Get("Idempotency-Key") != "traffic-order-1" {
t.Fatalf("missing stable idempotency key")
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"task_id":"task-traffic","status":"queued"}}`))
})
limit := 200.0
estimate, err := client.Pricing.EstimateInstanceChange(context.Background(), "us-east-1", "local-1", InstanceChangePriceRequest{Kind: InstanceChangeTrafficLimit, PeriodEnd: time.Now().AddDate(0, 1, 0), LimitGiB: &limit})
if err != nil || estimate.ConfigurationFingerprint != "fp" || estimate.ChargePrice != 5.5 {
t.Fatalf("estimate=%#v err=%v", estimate, err)
}
operation, err := client.Traffic.UpdateAsync(context.Background(), "us-east-1", "local-1", TrafficLimitRequest{LimitGiB: &limit, ExpectedConfigurationFingerprint: "fp"}, WithIdempotencyKey("traffic-order-1"))
if err != nil || operation.TaskID != "task-traffic" {
t.Fatalf("operation=%#v err=%v", operation, err)
}
want := []string{"POST /api/sdk/v1/regions/us-east-1/instances/local-1/change-price-estimate", "POST /api/sdk/v1/regions/us-east-1/instances/local-1/traffic-limit/update"}
if len(paths) != len(want) || paths[0] != want[0] || paths[1] != want[1] {
t.Fatalf("paths=%v want=%v", paths, want)
}
}
func TestInstanceDetailsExposeCatalogMetadata(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"instance":{"instance_id":"i-1"},"instance_type_details":{"instance_type":"m7i.large","vcpus":2,"catalog_match":true},"image_details":{"image_id":"ami-1","display_name":"Ubuntu 24.04","catalog_match":true}}}`))
})
details, err := client.Instances.GetDetails(context.Background(), "us-east-1", "local-1")
if err != nil {
t.Fatal(err)
}
if details.InstanceTypeDetails()["instance_type"] != "m7i.large" || details.ImageDetails()["display_name"] != "Ubuntu 24.04" {
t.Fatalf("catalog details were not decoded: %#v", details)
}
}
+5 -4
View File
@@ -18,10 +18,11 @@ type ResizeInstanceRequest struct {
InstanceType string `json:"instance_type"`
}
type RebuildInstanceRequest struct {
ImageID string `json:"image_id"`
InstanceType string `json:"instance_type,omitempty"`
AssociateEIP bool `json:"associate_eip,omitempty"`
InstallAgent *bool `json:"install_agent,omitempty"`
ImageID string `json:"image_id"`
InstanceType string `json:"instance_type,omitempty"`
AssociateEIP bool `json:"associate_eip,omitempty"`
InstallAgent *bool `json:"install_agent,omitempty"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
type RefreshDetailsRequest struct {
Sections []string `json:"sections,omitempty"`
+24 -16
View File
@@ -37,7 +37,8 @@ type EIPInfo struct {
Available []map[string]any `json:"available"`
}
type EnsureEIPRequest struct {
AllocationID string `json:"allocation_id,omitempty"`
AllocationID string `json:"allocation_id,omitempty"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
type ReverseDNSRequest struct {
DomainName string `json:"domain_name,omitempty"`
@@ -63,25 +64,27 @@ func (s *EIPService) SetReverseDNS(ctx context.Context, region, instanceID strin
type Volume map[string]any
type CreateVolumeRequest struct {
Name string `json:"name,omitempty"`
DeviceName string `json:"device_name"`
SizeGiB int32 `json:"size_gib"`
Type string `json:"type,omitempty"`
IOPS int32 `json:"iops,omitempty"`
Throughput int32 `json:"throughput,omitempty"`
Encrypted bool `json:"encrypted"`
KMSKeyID string `json:"kms_key_id,omitempty"`
DeleteOnTermination bool `json:"delete_on_termination"`
Name string `json:"name,omitempty"`
DeviceName string `json:"device_name"`
SizeGiB int32 `json:"size_gib"`
Type string `json:"type,omitempty"`
IOPS int32 `json:"iops,omitempty"`
Throughput int32 `json:"throughput,omitempty"`
Encrypted bool `json:"encrypted"`
KMSKeyID string `json:"kms_key_id,omitempty"`
DeleteOnTermination bool `json:"delete_on_termination"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
type AttachVolumeRequest struct {
DeviceName string `json:"device_name"`
DeleteOnTermination bool `json:"delete_on_termination"`
}
type ModifyVolumeRequest struct {
SizeGiB int32 `json:"size_gib,omitempty"`
Type string `json:"type,omitempty"`
IOPS int32 `json:"iops,omitempty"`
Throughput int32 `json:"throughput,omitempty"`
SizeGiB int32 `json:"size_gib,omitempty"`
Type string `json:"type,omitempty"`
IOPS int32 `json:"iops,omitempty"`
Throughput int32 `json:"throughput,omitempty"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
func (s *VolumeService) List(ctx context.Context, region, instanceID string) ([]Volume, error) {
@@ -109,8 +112,9 @@ func (s *VolumeService) Delete(ctx context.Context, region, instanceID, volumeID
type TrafficLimit map[string]any
type TrafficLimitRequest struct {
Enabled *bool `json:"enabled,omitempty"`
LimitGiB *float64 `json:"limit_gib,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LimitGiB *float64 `json:"limit_gib,omitempty"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
func (s *TrafficService) Get(ctx context.Context, region, instanceID string) (TrafficLimit, error) {
@@ -123,3 +127,7 @@ func (s *TrafficService) Update(ctx context.Context, region, instanceID string,
err := s.client.do(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/traffic-limit", input, &result, newID())
return result, err
}
func (s *TrafficService) UpdateAsync(ctx context.Context, region, instanceID string, input TrafficLimitRequest, options ...RequestOption) (*Operation, error) {
return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/traffic-limit/update", input, options...)
}
+115 -16
View File
@@ -54,25 +54,66 @@ type Image struct {
DisplayName string `json:"display_name"`
DisplayIntro string `json:"display_intro"`
AvailabilityStatus string `json:"availability_status"`
Description string `json:"description,omitempty"`
Architecture string `json:"architecture"`
Platform string `json:"platform,omitempty"`
PlatformDetails string `json:"platform_details,omitempty"`
OSFamily string `json:"os_family,omitempty"`
RecommendedUser string `json:"recommended_user,omitempty"`
CreationDate string `json:"creation_date,omitempty"`
DeprecationTime string `json:"deprecation_time,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
OwnerAlias string `json:"owner_alias,omitempty"`
RootDeviceType string `json:"root_device_type,omitempty"`
RootDeviceName string `json:"root_device_name,omitempty"`
RootSnapshotID string `json:"root_snapshot_id,omitempty"`
RootVolumeSizeGiB int32 `json:"root_volume_size_gib,omitempty"`
RootVolumeType string `json:"root_volume_type,omitempty"`
VirtualizationType string `json:"virtualization_type,omitempty"`
Hypervisor string `json:"hypervisor,omitempty"`
BootMode string `json:"boot_mode,omitempty"`
IMDSSupport string `json:"imds_support,omitempty"`
UsageOperation string `json:"usage_operation,omitempty"`
ENASupport bool `json:"ena_support"`
Public bool `json:"public"`
ProductCodes []string `json:"product_codes,omitempty"`
Metadata map[string]any `json:"metadata"`
}
type GPUDeviceInfo struct {
Manufacturer string `json:"manufacturer,omitempty"`
Name string `json:"name,omitempty"`
Count int32 `json:"count"`
LogicalCount int32 `json:"logical_count,omitempty"`
PartitionSize float64 `json:"partition_size,omitempty"`
MemoryMiB int32 `json:"memory_mib,omitempty"`
SupportedWorkloads []string `json:"supported_workloads,omitempty"`
}
type InstanceType struct {
ID string `json:"id"`
Region string `json:"region"`
CategoryID string `json:"category_id"`
InstanceType string `json:"instance_type"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
VCPUs int32 `json:"vcpus"`
MemoryMiB int64 `json:"memory_mib"`
Architectures []string `json:"architectures"`
NetworkPerformance string `json:"network_performance"`
GPUCount float64 `json:"gpu_count"`
OSPrices map[string]float64 `json:"os_prices"`
HourlyRate float64 `json:"hourly_rate"`
Currency string `json:"currency"`
PriceMode string `json:"price_mode"`
PriceValue float64 `json:"price_value"`
ID string `json:"id"`
Region string `json:"region"`
CategoryID string `json:"category_id"`
InstanceType string `json:"instance_type"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
VCPUs int32 `json:"vcpus"`
MemoryMiB int64 `json:"memory_mib"`
Architectures []string `json:"architectures"`
NetworkPerformance string `json:"network_performance"`
NetworkBaselineGbps float64 `json:"network_baseline_gbps"`
NetworkPeakGbps float64 `json:"network_peak_gbps"`
GPUCount float64 `json:"gpu_count"`
GPUMemoryMiB int64 `json:"gpu_memory_mib"`
GPUs []GPUDeviceInfo `json:"gpus"`
CurrentGeneration bool `json:"current_generation"`
BurstablePerformance bool `json:"burstable_performance"`
FreeTierEligible bool `json:"free_tier_eligible"`
OSPrices map[string]float64 `json:"os_prices"`
HourlyRate float64 `json:"hourly_rate"`
Currency string `json:"currency"`
PriceMode string `json:"price_mode"`
PriceValue float64 `json:"price_value"`
}
type CreateOptions struct {
Region string `json:"region"`
@@ -121,6 +162,43 @@ type PriceEstimate struct {
ResourceSyncedAt time.Time `json:"resource_synced_at,omitempty"`
Raw map[string]any `json:"-"`
}
const (
InstanceChangeEIPAdd = "eip_add"
InstanceChangeVolumeCreate = "volume_create"
InstanceChangeVolumeExpand = "volume_expand"
InstanceChangeRebuildImage = "rebuild_image"
InstanceChangeTrafficLimit = "traffic_limit"
)
type InstanceChangePriceRequest struct {
Kind string `json:"kind"`
PeriodEnd time.Time `json:"period_end"`
VolumeID string `json:"volume_id,omitempty"`
Volume *CreateVolumeRequest `json:"volume,omitempty"`
SizeGiB int32 `json:"size_gib,omitempty"`
ImageID string `json:"image_id,omitempty"`
LimitGiB *float64 `json:"limit_gib,omitempty"`
}
type InstanceChangePriceEstimate struct {
Kind string `json:"kind"`
Currency string `json:"currency"`
QuotedAt time.Time `json:"quoted_at"`
ValidUntil time.Time `json:"valid_until"`
PeriodEnd time.Time `json:"period_end"`
CurrentMonthlyPrice float64 `json:"current_monthly_price"`
TargetMonthlyPrice float64 `json:"target_monthly_price"`
MonthlyDelta float64 `json:"monthly_delta"`
CurrentPeriodPrice float64 `json:"current_period_price"`
TargetPeriodPrice float64 `json:"target_period_price"`
PeriodDelta float64 `json:"period_delta"`
ChargePrice float64 `json:"charge_price"`
ConfigurationFingerprint string `json:"configuration_fingerprint"`
ResourceSyncedAt time.Time `json:"resource_synced_at"`
NormalizedRequest InstanceChangePriceRequest `json:"normalized_request"`
Breakdown map[string]any `json:"breakdown"`
}
type Instance struct {
ID string `json:"id"`
InstanceID string `json:"instance_id,omitempty"`
@@ -143,7 +221,28 @@ type Instance struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CatalogResourceDetails map[string]any
type InstanceDetails map[string]any
func (d InstanceDetails) InstanceTypeDetails() CatalogResourceDetails {
return catalogResourceDetails(d["instance_type_details"])
}
func (d InstanceDetails) ImageDetails() CatalogResourceDetails {
return catalogResourceDetails(d["image_details"])
}
func catalogResourceDetails(value any) CatalogResourceDetails {
if details, ok := value.(map[string]any); ok {
return CatalogResourceDetails(details)
}
if details, ok := value.(CatalogResourceDetails); ok {
return details
}
return nil
}
type Operation struct {
client *Client
ID string `json:"id"`