From 0a1b3ac8c04d1fc94a01b2caf595bb5ca8b45f6d Mon Sep 17 00:00:00 2001 From: shiran Date: Mon, 31 Aug 2026 16:40:43 +0800 Subject: [PATCH] feat: add instance change pricing and catalog metadata --- catalog.go | 5 ++ client.go | 2 +- client_test.go | 65 ++++++++++++++++++++++++ instances.go | 9 ++-- resources.go | 40 +++++++++------ types.go | 131 +++++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 215 insertions(+), 37 deletions(-) diff --git a/catalog.go b/catalog.go index 10e0999..1ee5b82 100644 --- a/catalog.go +++ b/catalog.go @@ -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 != "" { diff --git a/client.go b/client.go index e15df00..e5271ab 100644 --- a/client.go +++ b/client.go @@ -16,7 +16,7 @@ import ( "time" ) -const Version = "0.1.0" +const Version = "0.3.0" type Logger interface { Printf(format string, args ...any) diff --git a/client_test.go b/client_test.go index 0b3247b..cb82bab 100644 --- a/client_test.go +++ b/client_test.go @@ -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) + } +} diff --git a/instances.go b/instances.go index 2d3cbee..04782b5 100644 --- a/instances.go +++ b/instances.go @@ -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"` diff --git a/resources.go b/resources.go index d10761d..8de5584 100644 --- a/resources.go +++ b/resources.go @@ -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...) +} diff --git a/types.go b/types.go index ef3ddcf..5eeddad 100644 --- a/types.go +++ b/types.go @@ -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"`