Skip to content

Commit 4146f43

Browse files
committed
feat(naver): enrich instance types with pricing
1 parent 8e52304 commit 4146f43

3 files changed

Lines changed: 187 additions & 14 deletions

File tree

v1/providers/naver/client.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const (
2020
CloudProviderID = "naver"
2121
architectureX8664 = "x86_64"
2222
defaultBaseURL = "https://ncloud.apigw.ntruss.com"
23+
defaultBillingBaseURL = "https://billingapi.apigw.ntruss.com"
2324
defaultRegionCode = "KR"
2425
defaultServerImageProductCode = "SW.VSVR.OS.LNX64.UBNTU.SVR24.G003"
2526
defaultSSHUser = "root"
@@ -77,6 +78,7 @@ type NaverClient struct {
7778
accessKey string
7879
secretKey string
7980
baseURL string
81+
billingURL string
8082
httpClient *http.Client
8183
location string
8284
now func() time.Time
@@ -86,6 +88,7 @@ var _ cloud.CloudClient = &NaverClient{}
8688

8789
type options struct {
8890
baseURL string
91+
billingURL string
8992
httpClient *http.Client
9093
location string
9194
now func() time.Time
@@ -96,6 +99,13 @@ type Option func(*options)
9699
func WithBaseURL(baseURL string) Option {
97100
return func(opts *options) {
98101
opts.baseURL = strings.TrimRight(baseURL, "/")
102+
opts.billingURL = strings.TrimRight(baseURL, "/")
103+
}
104+
}
105+
106+
func WithBillingBaseURL(baseURL string) Option {
107+
return func(opts *options) {
108+
opts.billingURL = strings.TrimRight(baseURL, "/")
99109
}
100110
}
101111

@@ -127,6 +137,7 @@ func NewNaverClient(refID, accessKey, secretKey string, opts ...Option) (*NaverC
127137

128138
options := options{
129139
baseURL: defaultBaseURL,
140+
billingURL: defaultBillingBaseURL,
130141
httpClient: http.DefaultClient,
131142
location: defaultRegionCode,
132143
now: time.Now,
@@ -149,6 +160,7 @@ func NewNaverClient(refID, accessKey, secretKey string, opts ...Option) (*NaverC
149160
accessKey: accessKey,
150161
secretKey: secretKey,
151162
baseURL: options.baseURL,
163+
billingURL: options.billingURL,
152164
httpClient: options.httpClient,
153165
location: options.location,
154166
now: options.now,
@@ -180,19 +192,26 @@ func (c *NaverClient) MakeClient(_ context.Context, location string) (cloud.Clou
180192
}
181193

182194
func (c *NaverClient) do(ctx context.Context, action string, params url.Values, dst any) error {
195+
return c.doNcloud(ctx, c.baseURL, "/vserver/v2/"+action, action, params, dst)
196+
}
197+
198+
func (c *NaverClient) doBilling(ctx context.Context, action string, params url.Values, dst any) error {
199+
return c.doNcloud(ctx, c.billingURL, "/billing/v1/"+action, action, params, dst)
200+
}
201+
202+
func (c *NaverClient) doNcloud(ctx context.Context, baseURL, path, action string, params url.Values, dst any) error {
183203
if params == nil {
184204
params = url.Values{}
185205
}
186206
params.Set("responseFormatType", "json")
187207

188-
path := "/vserver/v2/" + action
189208
query := params.Encode()
190209
requestURI := path
191210
if query != "" {
192211
requestURI += "?" + query
193212
}
194213

195-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+requestURI, nil)
214+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+requestURI, nil)
196215
if err != nil {
197216
return err
198217
}

v1/providers/naver/instancetype.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package v1
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/url"
67
"regexp"
78
"slices"
@@ -10,9 +11,17 @@ import (
1011
"time"
1112

1213
"github.com/alecthomas/units"
14+
"github.com/bojanz/currency"
1315
cloud "github.com/brevdev/cloud/v1"
1416
)
1517

18+
const (
19+
defaultPayCurrencyCode = "USD"
20+
ncloudServerProductKindVPC = "VSVR"
21+
ncloudMeterRatePriceType = "MTRAT"
22+
ncloudHourlyUsageUnit = "USAGE_HH"
23+
)
24+
1625
type serverProductListResponse struct {
1726
Response productList `json:"getServerProductListResponse"`
1827
}
@@ -42,6 +51,53 @@ type naverProduct struct {
4251
GenerationCode string `json:"generationCode"`
4352
}
4453

54+
type productPriceListResponse struct {
55+
Response naverProductPriceList `json:"getProductPriceListResponse"`
56+
}
57+
58+
func (r *productPriceListResponse) apiError() error {
59+
return r.Response.apiError()
60+
}
61+
62+
type naverProductPriceList struct {
63+
responseMeta
64+
TotalRows int `json:"totalRows"`
65+
ProductPriceList []naverProductPrice `json:"productPriceList"`
66+
}
67+
68+
type naverProductPrice struct {
69+
ProductCode string `json:"productCode"`
70+
PriceList []naverPrice `json:"priceList"`
71+
}
72+
73+
type naverPrice struct {
74+
PriceType codeName `json:"priceType"`
75+
Unit codeName `json:"unit"`
76+
Price naverPriceAmount `json:"price"`
77+
PayCurrency naverPayCurrency `json:"payCurrency"`
78+
}
79+
80+
type naverPayCurrency struct {
81+
Code string `json:"code"`
82+
}
83+
84+
type naverPriceAmount string
85+
86+
func (a *naverPriceAmount) UnmarshalJSON(data []byte) error {
87+
var value string
88+
if err := json.Unmarshal(data, &value); err == nil {
89+
*a = naverPriceAmount(value)
90+
return nil
91+
}
92+
93+
var number json.Number
94+
if err := json.Unmarshal(data, &number); err != nil {
95+
return err
96+
}
97+
*a = naverPriceAmount(number.String())
98+
return nil
99+
}
100+
45101
func (c *NaverClient) GetInstanceTypePollTime() time.Duration {
46102
return defaultInstanceTypePollMinutes * time.Minute
47103
}
@@ -92,9 +148,10 @@ func (c *NaverClient) instanceTypesForLocation(ctx context.Context, location str
92148
return nil, err
93149
}
94150

151+
prices := c.productPrices(ctx, location)
95152
out := make([]cloud.InstanceType, 0, len(resp.Response.ProductList))
96153
for _, product := range resp.Response.ProductList {
97-
it := product.toInstanceType(location)
154+
it := product.toInstanceType(location, prices[product.ProductCode])
98155
if includeInstanceType(it, args) {
99156
out = append(out, it)
100157
}
@@ -127,7 +184,44 @@ func allowsGPUManufacturer(gpus []cloud.GPU, filter *cloud.GPUManufacturerFilter
127184
return false
128185
}
129186

130-
func (p naverProduct) toInstanceType(location string) cloud.InstanceType {
187+
func (c *NaverClient) productPrices(ctx context.Context, location string) map[string]*currency.Amount {
188+
params := url.Values{}
189+
params.Set("regionCode", location)
190+
params.Set("productItemKindCode", ncloudServerProductKindVPC)
191+
params.Set("payCurrencyCode", defaultPayCurrencyCode)
192+
params.Set("pageSize", "1000")
193+
194+
var resp productPriceListResponse
195+
if err := c.doBilling(ctx, "product/getProductPriceList", params, &resp); err != nil {
196+
return nil
197+
}
198+
199+
prices := make(map[string]*currency.Amount, len(resp.Response.ProductPriceList))
200+
for _, productPrice := range resp.Response.ProductPriceList {
201+
price := hourlyPrice(productPrice.PriceList)
202+
if price != nil {
203+
prices[productPrice.ProductCode] = price
204+
}
205+
}
206+
return prices
207+
}
208+
209+
func hourlyPrice(prices []naverPrice) *currency.Amount {
210+
for _, price := range prices {
211+
if price.PriceType.Code != ncloudMeterRatePriceType || price.Unit.Code != ncloudHourlyUsageUnit {
212+
continue
213+
}
214+
215+
amount, err := currency.NewAmount(string(price.Price), price.PayCurrency.Code)
216+
if err != nil {
217+
return nil
218+
}
219+
return &amount
220+
}
221+
return nil
222+
}
223+
224+
func (p naverProduct) toInstanceType(location string, basePrice *currency.Amount) cloud.InstanceType {
131225
storage := cloud.Storage{
132226
Type: firstNonEmpty(p.DiskType.Code, "NET"),
133227
Count: 1,
@@ -149,6 +243,7 @@ func (p naverProduct) toInstanceType(location string) cloud.InstanceType {
149243
Stoppable: true,
150244
Rebootable: true,
151245
IsAvailable: true,
246+
BasePrice: basePrice,
152247
Provider: CloudProviderID,
153248
Cloud: CloudProviderID,
154249
}

v1/providers/naver/instancetype_test.go

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,15 @@ import (
1111

1212
func TestGetInstanceTypesConvertsProducts(t *testing.T) {
1313
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14-
if r.URL.Path != "/vserver/v2/getServerProductList" {
14+
w.Header().Set("Content-Type", "application/json")
15+
switch r.URL.Path {
16+
case "/vserver/v2/getServerProductList":
17+
writeServerProductList(t, w, r)
18+
case "/billing/v1/product/getProductPriceList":
19+
writeProductPriceList(t, w, r)
20+
default:
1521
t.Fatalf("path = %q", r.URL.Path)
1622
}
17-
if got := r.URL.Query().Get("regionCode"); got != "KR" {
18-
t.Fatalf("regionCode = %q", got)
19-
}
20-
if got := r.URL.Query().Get("serverImageProductCode"); got != defaultServerImageProductCode {
21-
t.Fatalf("serverImageProductCode = %q", got)
22-
}
23-
w.Header().Set("Content-Type", "application/json")
24-
_, _ = w.Write([]byte(`{"getServerProductListResponse":{"returnCode":"0","returnMessage":"success","totalRows":2,"productList":[{"productCode":"SVR.VSVR.GPU.T4.C004.M016.NET.SSD.B050.G002","productName":"vCPU 4EA, Memory 16GB, NVIDIA T4 1EA, [SSD]Disk 50GB","productType":{"code":"GPU","codeName":"GPU"},"productDescription":"vCPU 4EA, Memory 16GB, NVIDIA T4 1EA, [SSD]Disk 50GB","cpuCount":4,"memorySize":17179869184,"baseBlockStorageSize":53687091200,"diskType":{"code":"NET","codeName":"Network storage"},"generationCode":"G2"},{"productCode":"SVR.VSVR.STAND.C002.M008.NET.SSD.B050.G002","productName":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","productType":{"code":"STAND","codeName":"Standard"},"productDescription":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","cpuCount":2,"memorySize":8589934592,"baseBlockStorageSize":53687091200,"diskType":{"code":"NET","codeName":"Network storage"},"generationCode":"G2"}]}}`))
2523
}))
2624
defer server.Close()
2725

@@ -36,7 +34,11 @@ func TestGetInstanceTypesConvertsProducts(t *testing.T) {
3634
if len(types) != 1 {
3735
t.Fatalf("instance types len = %d, want 1", len(types))
3836
}
39-
it := types[0]
37+
assertPricedGPUInstanceType(t, types[0])
38+
}
39+
40+
func assertPricedGPUInstanceType(t *testing.T, it cloud.InstanceType) {
41+
t.Helper()
4042
if it.Provider != CloudProviderID || it.Cloud != CloudProviderID || it.Location != "KR" {
4143
t.Fatalf("unexpected provider fields: %+v", it)
4244
}
@@ -46,7 +48,64 @@ func TestGetInstanceTypesConvertsProducts(t *testing.T) {
4648
if len(it.SupportedGPUs) != 1 || it.SupportedGPUs[0].Name != "T4" || it.SupportedGPUs[0].Count != 1 {
4749
t.Fatalf("unexpected GPU conversion: %+v", it.SupportedGPUs)
4850
}
51+
if it.BasePrice == nil || it.BasePrice.CurrencyCode() != "USD" || it.BasePrice.Number() != "0.5" {
52+
t.Fatalf("unexpected base price: %v", it.BasePrice)
53+
}
4954
if it.ID == "" {
5055
t.Fatal("instance type ID is empty")
5156
}
5257
}
58+
59+
func writeServerProductList(t *testing.T, w http.ResponseWriter, r *http.Request) {
60+
t.Helper()
61+
if got := r.URL.Query().Get("regionCode"); got != "KR" {
62+
t.Fatalf("regionCode = %q", got)
63+
}
64+
if got := r.URL.Query().Get("serverImageProductCode"); got != defaultServerImageProductCode {
65+
t.Fatalf("serverImageProductCode = %q", got)
66+
}
67+
_, _ = w.Write([]byte(`{"getServerProductListResponse":{"returnCode":"0","returnMessage":"success","totalRows":2,"productList":[{"productCode":"SVR.VSVR.GPU.T4.C004.M016.NET.SSD.B050.G002","productName":"vCPU 4EA, Memory 16GB, NVIDIA T4 1EA, [SSD]Disk 50GB","productType":{"code":"GPU","codeName":"GPU"},"productDescription":"vCPU 4EA, Memory 16GB, NVIDIA T4 1EA, [SSD]Disk 50GB","cpuCount":4,"memorySize":17179869184,"baseBlockStorageSize":53687091200,"diskType":{"code":"NET","codeName":"Network storage"},"generationCode":"G2"},{"productCode":"SVR.VSVR.STAND.C002.M008.NET.SSD.B050.G002","productName":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","productType":{"code":"STAND","codeName":"Standard"},"productDescription":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","cpuCount":2,"memorySize":8589934592,"baseBlockStorageSize":53687091200,"diskType":{"code":"NET","codeName":"Network storage"},"generationCode":"G2"}]}}`))
68+
}
69+
70+
func writeProductPriceList(t *testing.T, w http.ResponseWriter, r *http.Request) {
71+
t.Helper()
72+
if got := r.URL.Query().Get("regionCode"); got != "KR" {
73+
t.Fatalf("price regionCode = %q", got)
74+
}
75+
if got := r.URL.Query().Get("productItemKindCode"); got != ncloudServerProductKindVPC {
76+
t.Fatalf("productItemKindCode = %q", got)
77+
}
78+
if got := r.URL.Query().Get("payCurrencyCode"); got != defaultPayCurrencyCode {
79+
t.Fatalf("payCurrencyCode = %q", got)
80+
}
81+
_, _ = w.Write([]byte(`{"getProductPriceListResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"productPriceList":[{"productCode":"SVR.VSVR.GPU.T4.C004.M016.NET.SSD.B050.G002","priceList":[{"priceType":{"code":"FXSUM","codeName":"Monthly flat rate"},"unit":{"code":"USAGE_TIME","codeName":"Usage time"},"price":1500,"payCurrency":{"code":"USD","codeName":"US Dollar"}},{"priceType":{"code":"MTRAT","codeName":"Meter rate"},"unit":{"code":"USAGE_HH","codeName":"Usage time (per hour)"},"price":0.5,"payCurrency":{"code":"USD","codeName":"US Dollar"}}]}]}}`))
82+
}
83+
84+
func TestGetInstanceTypesIgnoresPriceLookupFailure(t *testing.T) {
85+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86+
w.Header().Set("Content-Type", "application/json")
87+
switch r.URL.Path {
88+
case "/vserver/v2/getServerProductList":
89+
_, _ = w.Write([]byte(`{"getServerProductListResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"productList":[{"productCode":"SVR.VSVR.STAND.C002.M008.NET.SSD.B050.G002","productName":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","productType":{"code":"STAND","codeName":"Standard"},"productDescription":"vCPU 2EA, Memory 8GB, [SSD]Disk 50GB","cpuCount":2,"memorySize":8589934592,"baseBlockStorageSize":53687091200,"diskType":{"code":"NET","codeName":"Network storage"},"generationCode":"G2"}]}}`))
90+
case "/billing/v1/product/getProductPriceList":
91+
http.Error(w, "billing unavailable", http.StatusInternalServerError)
92+
default:
93+
t.Fatalf("path = %q", r.URL.Path)
94+
}
95+
}))
96+
defer server.Close()
97+
98+
client := newTestNaverClient(t, server.URL)
99+
types, err := client.GetInstanceTypes(context.Background(), cloud.GetInstanceTypeArgs{
100+
Locations: cloud.LocationsFilter{"KR"},
101+
})
102+
if err != nil {
103+
t.Fatalf("GetInstanceTypes() error = %v", err)
104+
}
105+
if len(types) != 1 {
106+
t.Fatalf("instance types len = %d, want 1", len(types))
107+
}
108+
if types[0].BasePrice != nil {
109+
t.Fatalf("BasePrice = %v, want nil after price lookup failure", types[0].BasePrice)
110+
}
111+
}

0 commit comments

Comments
 (0)