diff --git a/v1/providers/naver/capabilities.go b/v1/providers/naver/capabilities.go new file mode 100644 index 0000000..554f3f7 --- /dev/null +++ b/v1/providers/naver/capabilities.go @@ -0,0 +1,22 @@ +package v1 + +import ( + "context" + + cloud "github.com/brevdev/cloud/v1" +) + +func getNaverCapabilities() cloud.Capabilities { + return cloud.Capabilities{ + cloud.CapabilityCreateInstance, + cloud.CapabilityTerminateInstance, + cloud.CapabilityCreateTerminateInstance, + cloud.CapabilityRebootInstance, + cloud.CapabilityStopStartInstance, + cloud.CapabilityMachineImage, + } +} + +func (c *NaverClient) GetCapabilities(_ context.Context) (cloud.Capabilities, error) { + return getNaverCapabilities(), nil +} diff --git a/v1/providers/naver/client.go b/v1/providers/naver/client.go new file mode 100644 index 0000000..c8e63f9 --- /dev/null +++ b/v1/providers/naver/client.go @@ -0,0 +1,280 @@ +package v1 + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + cloud "github.com/brevdev/cloud/v1" +) + +const ( + CloudProviderID = "naver" + architectureX8664 = "x86_64" + defaultBaseURL = "https://ncloud.apigw.ntruss.com" + defaultBillingBaseURL = "https://billingapi.apigw.ntruss.com" + defaultRegionCode = "KR" + defaultServerImageProductCode = "SW.VSVR.OS.LNX64.UBNTU.SVR24.G003" + defaultSSHUser = "root" + defaultSSHPort = 22 + defaultInstanceTypePollMinutes = 5 +) + +type NaverCredential struct { + RefID string + AccessKey string + SecretKey string +} + +var _ cloud.CloudCredential = &NaverCredential{} + +func NewNaverCredential(refID, accessKey, secretKey string) *NaverCredential { + return &NaverCredential{ + RefID: refID, + AccessKey: accessKey, + SecretKey: secretKey, + } +} + +func (c *NaverCredential) GetReferenceID() string { + return c.RefID +} + +func (c *NaverCredential) GetAPIType() cloud.APIType { + return cloud.APITypeGlobal +} + +func (c *NaverCredential) GetCloudProviderID() cloud.CloudProviderID { + return CloudProviderID +} + +func (c *NaverCredential) GetTenantID() (string, error) { + if c.AccessKey == "" { + return "", fmt.Errorf("access key is required") + } + sum := sha256.Sum256([]byte(c.AccessKey)) + return fmt.Sprintf("%s-%x", CloudProviderID, sum), nil +} + +func (c *NaverCredential) GetCapabilities(_ context.Context) (cloud.Capabilities, error) { + return getNaverCapabilities(), nil +} + +func (c *NaverCredential) MakeClient(_ context.Context, location string) (cloud.CloudClient, error) { + return NewNaverClient(c.RefID, c.AccessKey, c.SecretKey, WithLocation(location)) +} + +type NaverClient struct { + cloud.NotImplCloudClient + refID string + accessKey string + secretKey string + baseURL string + billingURL string + httpClient *http.Client + location string + now func() time.Time +} + +var _ cloud.CloudClient = &NaverClient{} + +type options struct { + baseURL string + billingURL string + httpClient *http.Client + location string + now func() time.Time +} + +type Option func(*options) + +func WithBaseURL(baseURL string) Option { + return func(opts *options) { + opts.baseURL = strings.TrimRight(baseURL, "/") + opts.billingURL = strings.TrimRight(baseURL, "/") + } +} + +func WithBillingBaseURL(baseURL string) Option { + return func(opts *options) { + opts.billingURL = strings.TrimRight(baseURL, "/") + } +} + +func WithHTTPClient(client *http.Client) Option { + return func(opts *options) { + opts.httpClient = client + } +} + +func WithLocation(location string) Option { + return func(opts *options) { + opts.location = location + } +} + +func WithClock(now func() time.Time) Option { + return func(opts *options) { + opts.now = now + } +} + +func NewNaverClient(refID, accessKey, secretKey string, opts ...Option) (*NaverClient, error) { + if refID == "" { + return nil, fmt.Errorf("refID is required") + } + if accessKey == "" || secretKey == "" { + return nil, fmt.Errorf("accessKey and secretKey are required") + } + + options := options{ + baseURL: defaultBaseURL, + billingURL: defaultBillingBaseURL, + httpClient: http.DefaultClient, + location: defaultRegionCode, + now: time.Now, + } + for _, opt := range opts { + opt(&options) + } + if options.location == "" { + options.location = defaultRegionCode + } + if options.httpClient == nil { + options.httpClient = http.DefaultClient + } + if options.now == nil { + options.now = time.Now + } + + return &NaverClient{ + refID: refID, + accessKey: accessKey, + secretKey: secretKey, + baseURL: options.baseURL, + billingURL: options.billingURL, + httpClient: options.httpClient, + location: options.location, + now: options.now, + }, nil +} + +func (c *NaverClient) GetAPIType() cloud.APIType { + return cloud.APITypeGlobal +} + +func (c *NaverClient) GetCloudProviderID() cloud.CloudProviderID { + return CloudProviderID +} + +func (c *NaverClient) GetReferenceID() string { + return c.refID +} + +func (c *NaverClient) GetTenantID() (string, error) { + sum := sha256.Sum256([]byte(c.accessKey)) + return fmt.Sprintf("%s-%x", CloudProviderID, sum), nil +} + +func (c *NaverClient) MakeClient(_ context.Context, location string) (cloud.CloudClient, error) { + if location != "" { + c.location = location + } + return c, nil +} + +func (c *NaverClient) do(ctx context.Context, action string, params url.Values, dst any) error { + return c.doNcloud(ctx, c.baseURL, "/vserver/v2/"+action, action, params, dst) +} + +func (c *NaverClient) doBilling(ctx context.Context, action string, params url.Values, dst any) error { + return c.doNcloud(ctx, c.billingURL, "/billing/v1/"+action, action, params, dst) +} + +func (c *NaverClient) doNcloud(ctx context.Context, baseURL, path, action string, params url.Values, dst any) error { + if params == nil { + params = url.Values{} + } + params.Set("responseFormatType", "json") + + query := params.Encode() + requestURI := path + if query != "" { + requestURI += "?" + query + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+requestURI, nil) + if err != nil { + return err + } + c.sign(req, requestURI) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("naver API %s failed with status %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body))) + } + if err := json.Unmarshal(body, dst); err != nil { + return fmt.Errorf("decode naver API %s response: %w", action, err) + } + if err := responseErr(dst); err != nil { + return fmt.Errorf("naver API %s failed: %w", action, err) + } + return nil +} + +func (c *NaverClient) sign(req *http.Request, requestURI string) { + timestamp := fmt.Sprintf("%d", c.now().UnixMilli()) + message := fmt.Sprintf("%s\n%s\n%s\n%s", req.Method, requestURI, timestamp, c.accessKey) + mac := hmac.New(sha256.New, []byte(c.secretKey)) + _, _ = mac.Write([]byte(message)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + req.Header.Set("x-ncp-apigw-timestamp", timestamp) + req.Header.Set("x-ncp-iam-access-key", c.accessKey) + req.Header.Set("x-ncp-apigw-signature-v2", signature) +} + +type naverResponse interface { + apiError() error +} + +func responseErr(dst any) error { + if resp, ok := dst.(naverResponse); ok { + return resp.apiError() + } + return nil +} + +type responseMeta struct { + ReturnCode string `json:"returnCode"` + ReturnMessage string `json:"returnMessage"` +} + +func (m responseMeta) apiError() error { + if m.ReturnCode != "" && m.ReturnCode != "0" { + return fmt.Errorf("%s: %s", m.ReturnCode, m.ReturnMessage) + } + return nil +} + +type codeName struct { + Code string `json:"code"` + CodeName string `json:"codeName"` +} diff --git a/v1/providers/naver/client_test.go b/v1/providers/naver/client_test.go new file mode 100644 index 0000000..e637362 --- /dev/null +++ b/v1/providers/naver/client_test.go @@ -0,0 +1,82 @@ +package v1 + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + cloud "github.com/brevdev/cloud/v1" +) + +func TestNaverClientSignsRequests(t *testing.T) { + const ( + accessKey = "test-access" + secretKey = "test-secret" + ) + fixedNow := time.UnixMilli(1700000000123) + var sawRequest bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawRequest = true + if got := r.Header.Get("x-ncp-apigw-timestamp"); got != "1700000000123" { + t.Fatalf("timestamp header = %q, want fixed timestamp", got) + } + if got := r.Header.Get("x-ncp-iam-access-key"); got != accessKey { + t.Fatalf("access key header = %q, want %q", got, accessKey) + } + + expectedMessage := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, r.URL.RequestURI(), "1700000000123", accessKey) + mac := hmac.New(sha256.New, []byte(secretKey)) + _, _ = mac.Write([]byte(expectedMessage)) + expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + if got := r.Header.Get("x-ncp-apigw-signature-v2"); got != expectedSignature { + t.Fatalf("signature header = %q, want %q", got, expectedSignature) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"getRegionListResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"regionList":[{"regionCode":"KR","regionName":"Korea"}]}}`)) + })) + defer server.Close() + + client, err := NewNaverClient("ref-1", accessKey, secretKey, WithBaseURL(server.URL), WithClock(func() time.Time { + return fixedNow + })) + if err != nil { + t.Fatalf("NewNaverClient() error = %v", err) + } + + _, err = client.GetLocations(context.Background(), cloud.GetLocationsArgs{}) + if err != nil { + t.Fatalf("GetLocations() error = %v", err) + } + if !sawRequest { + t.Fatal("server did not receive request") + } +} + +func TestNaverCredentialCreatesClient(t *testing.T) { + cred := NewNaverCredential("ref-1", "access", "secret") + if got := cred.GetReferenceID(); got != "ref-1" { + t.Fatalf("reference ID = %q", got) + } + if got := cred.GetCloudProviderID(); got != CloudProviderID { + t.Fatalf("cloud provider ID = %q", got) + } + if got := cred.GetAPIType(); got != cloud.APITypeGlobal { + t.Fatalf("API type = %q", got) + } + + client, err := cred.MakeClient(context.Background(), "KR") + if err != nil { + t.Fatalf("MakeClient() error = %v", err) + } + if got := client.GetReferenceID(); got != "ref-1" { + t.Fatalf("client reference ID = %q", got) + } +} diff --git a/v1/providers/naver/image.go b/v1/providers/naver/image.go new file mode 100644 index 0000000..df1e22a --- /dev/null +++ b/v1/providers/naver/image.go @@ -0,0 +1,77 @@ +package v1 + +import ( + "context" + "net/url" + "slices" + "strings" + "time" + + cloud "github.com/brevdev/cloud/v1" +) + +type imageProductListResponse struct { + Response productList `json:"getServerImageProductListResponse"` +} + +func (r *imageProductListResponse) apiError() error { + return r.Response.apiError() +} + +func (c *NaverClient) GetImages(ctx context.Context, args cloud.GetImageArgs) ([]cloud.Image, error) { + params := url.Values{} + params.Set("regionCode", c.location) + if len(args.Architectures) > 0 { + for i, arch := range args.Architectures { + if arch == architectureX8664 { + params.Set(indexedParam("platformTypeCodeList", i+1), "LNX64") + } + } + } + + var resp imageProductListResponse + if err := c.do(ctx, "getServerImageProductList", params, &resp); err != nil { + return nil, err + } + + images := make([]cloud.Image, 0, len(resp.Response.ProductList)) + for _, product := range resp.Response.ProductList { + image := cloud.Image{ + ID: product.ProductCode, + Name: product.ProductName, + Description: firstNonEmpty(product.ProductDescription, product.OSInformation), + Architecture: architectureFromPlatform(product.PlatformType.Code), + CreatedAt: time.Time{}, + } + if len(args.ImageIDs) > 0 && !slices.Contains(args.ImageIDs, image.ID) { + continue + } + if len(args.NameFilters) > 0 && !matchesAnyNameFilter(image.Name, args.NameFilters) { + continue + } + if len(args.Architectures) > 0 && !slices.Contains(args.Architectures, image.Architecture) { + continue + } + images = append(images, image) + } + return images, nil +} + +func architectureFromPlatform(platform string) string { + if strings.Contains(platform, "64") { + return architectureX8664 + } + if strings.Contains(platform, "32") { + return "i386" + } + return "" +} + +func matchesAnyNameFilter(name string, filters []string) bool { + for _, filter := range filters { + if strings.Contains(strings.ToLower(name), strings.ToLower(strings.Trim(filter, "*"))) { + return true + } + } + return false +} diff --git a/v1/providers/naver/image_test.go b/v1/providers/naver/image_test.go new file mode 100644 index 0000000..707477a --- /dev/null +++ b/v1/providers/naver/image_test.go @@ -0,0 +1,39 @@ +package v1 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + cloud "github.com/brevdev/cloud/v1" +) + +func TestGetImagesConvertsAndFiltersImageProducts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/vserver/v2/getServerImageProductList" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.URL.Query().Get("platformTypeCodeList.1"); got != "LNX64" { + t.Fatalf("platform filter = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"getServerImageProductListResponse":{"returnCode":"0","returnMessage":"success","totalRows":2,"productList":[{"productCode":"UBUNTU24","productName":"ubuntu-24.04","productDescription":"Ubuntu Server 24.04","platformType":{"code":"LNX64","codeName":"Linux 64 Bit"},"osInformation":"Ubuntu 24.04 (64-bit)"},{"productCode":"WIN","productName":"windows","productDescription":"Windows","platformType":{"code":"WND64","codeName":"Windows 64 Bit"},"osInformation":"Windows"}]}}`)) + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + images, err := client.GetImages(context.Background(), cloud.GetImageArgs{ + Architectures: []string{architectureX8664}, + ImageIDs: []string{"UBUNTU24"}, + }) + if err != nil { + t.Fatalf("GetImages() error = %v", err) + } + if len(images) != 1 { + t.Fatalf("images len = %d, want 1", len(images)) + } + if images[0].ID != "UBUNTU24" || images[0].Architecture != architectureX8664 || images[0].Name != "ubuntu-24.04" { + t.Fatalf("unexpected image: %+v", images[0]) + } +} diff --git a/v1/providers/naver/instance.go b/v1/providers/naver/instance.go new file mode 100644 index 0000000..238ec45 --- /dev/null +++ b/v1/providers/naver/instance.go @@ -0,0 +1,385 @@ +package v1 + +import ( + "context" + "fmt" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/alecthomas/units" + cloud "github.com/brevdev/cloud/v1" +) + +type serverInstanceListResponse struct { + Create serverInstanceList `json:"createServerInstancesResponse"` + Get serverInstanceList `json:"getServerInstanceListResponse"` + Detail serverInstanceList `json:"getServerInstanceDetailResponse"` + Terminate responseMeta `json:"terminateServerInstancesResponse"` + Reboot responseMeta `json:"rebootServerInstancesResponse"` + Stop responseMeta `json:"stopServerInstancesResponse"` + Start responseMeta `json:"startServerInstancesResponse"` +} + +func (r *serverInstanceListResponse) apiError() error { + for _, meta := range []responseMeta{r.Create.responseMeta, r.Get.responseMeta, r.Detail.responseMeta, r.Terminate, r.Reboot, r.Stop, r.Start} { + if err := meta.apiError(); err != nil { + return err + } + } + return nil +} + +type serverInstanceList struct { + responseMeta + TotalRows int `json:"totalRows"` + ServerInstanceList []naverServerInstance `json:"serverInstanceList"` +} + +type naverServerInstance struct { + ServerInstanceNo string `json:"serverInstanceNo"` + ServerName string `json:"serverName"` + ServerDescription string `json:"serverDescription"` + CPUCount int32 `json:"cpuCount"` + MemorySize int64 `json:"memorySize"` + PlatformType codeName `json:"platformType"` + LoginKeyName string `json:"loginKeyName"` + PublicIPInstanceNo string `json:"publicIpInstanceNo"` + PublicIP string `json:"publicIp"` + PrivateIP string `json:"privateIp"` + ServerInstanceStatus codeName `json:"serverInstanceStatus"` + ServerInstanceOperation codeName `json:"serverInstanceOperation"` + ServerInstanceStatusName string `json:"serverInstanceStatusName"` + CreateDate string `json:"createDate"` + ServerImageProductCode string `json:"serverImageProductCode"` + ServerImageNo string `json:"serverImageNo"` + ServerProductCode string `json:"serverProductCode"` + ServerSpecCode string `json:"serverSpecCode"` + ZoneCode string `json:"zoneCode"` + RegionCode string `json:"regionCode"` + VPCNo string `json:"vpcNo"` + SubnetNo string `json:"subnetNo"` + BaseBlockStorageSize int64 `json:"baseBlockStorageSize"` + BaseBlockStorageDiskType codeName `json:"baseBlockStorageDiskType"` + BaseBlockStorageDiskDetailType codeName `json:"baseBlockStorageDiskDetailType"` + ServerInstanceType codeName `json:"serverInstanceType"` + IsProtectServerTermination bool `json:"isProtectServerTermination"` + IsPreInstallGPUDriver bool `json:"isPreInstallGpuDriver"` + NetworkInterfaceNoList []string `json:"networkInterfaceNoList"` + FabricClusterPoolNo string `json:"fabricClusterPoolNo"` + FabricClusterPoolName string `json:"fabricClusterPoolName"` + FabricClusterMode string `json:"fabricClusterMode"` + HypervisorType codeName `json:"hypervisorType"` + BaseBlockStorageDiskDetailTypeAlias codeName `json:"baseBlockStorageDiskDetailTypeCode"` +} + +type importLoginKeyResponse struct { + Response loginKeyList `json:"importLoginKeyResponse"` +} + +func (r *importLoginKeyResponse) apiError() error { + return r.Response.apiError() +} + +type loginKeyList struct { + responseMeta + LoginKeyList []naverLoginKey `json:"loginKeyList"` +} + +type naverLoginKey struct { + KeyName string `json:"keyName"` + Fingerprint string `json:"fingerprint"` + CreateDate string `json:"createDate"` +} + +func (c *NaverClient) CreateInstance(ctx context.Context, attrs cloud.CreateInstanceAttrs) (*cloud.Instance, error) { + if err := validateCreateInstanceAttrs(attrs); err != nil { + return nil, err + } + + location := firstNonEmpty(attrs.Location, c.location, defaultRegionCode) + keyName, err := c.createInstanceLoginKey(ctx, attrs, location) + if err != nil { + return nil, err + } + + params := createInstanceParams(attrs, location, keyName, c.refID) + var resp serverInstanceListResponse + if err := c.do(ctx, "createServerInstances", params, &resp); err != nil { + return nil, err + } + + inst, err := c.createdInstance(resp.Create.ServerInstanceList, attrs) + if err != nil { + return nil, err + } + return inst, nil +} + +func validateCreateInstanceAttrs(attrs cloud.CreateInstanceAttrs) error { + if attrs.VPCID == "" { + return fmt.Errorf("VPCID is required for NAVER VPC server creation") + } + if attrs.SubnetID == "" { + return fmt.Errorf("SubnetID is required for NAVER VPC server creation") + } + if attrs.InstanceType == "" { + return fmt.Errorf("InstanceType is required") + } + if attrs.ImageID == "" { + return fmt.Errorf("ImageID is required") + } + return nil +} + +func (c *NaverClient) createInstanceLoginKey(ctx context.Context, attrs cloud.CreateInstanceAttrs, location string) (string, error) { + keyName := "" + if attrs.KeyPairName != nil { + keyName = *attrs.KeyPairName + } + if attrs.PublicKey != "" { + imported, err := c.importLoginKey(ctx, location, naverResourceName("brev-"+attrs.RefID), attrs.PublicKey) + if err != nil { + return "", err + } + keyName = imported + } + if keyName == "" { + return "", fmt.Errorf("KeyPairName or PublicKey is required") + } + return keyName, nil +} + +func createInstanceParams(attrs cloud.CreateInstanceAttrs, location, keyName, refID string) url.Values { + params := url.Values{} + params.Set("regionCode", location) + params.Set("vpcNo", attrs.VPCID) + params.Set("subnetNo", attrs.SubnetID) + params.Set("serverName", naverResourceName(firstNonEmpty(attrs.Name, attrs.RefID, refID))) + params.Set("serverCreateCount", "1") + params.Set("networkInterfaceList.1.networkInterfaceOrder", "0") + params.Set("networkInterfaceList.1.subnetNo", attrs.SubnetID) + params.Set("loginKeyName", keyName) + params.Set("isProtectServerTermination", "false") + params.Set("serverImageProductCode", attrs.ImageID) + params.Set("serverProductCode", attrs.InstanceType) + if attrs.UserDataBase64 != "" { + params.Set("userData", attrs.UserDataBase64) + } + return params +} + +func (c *NaverClient) createdInstance(servers []naverServerInstance, attrs cloud.CreateInstanceAttrs) (*cloud.Instance, error) { + if len(servers) != 1 { + return nil, fmt.Errorf("expected 1 server instance, got %d", len(servers)) + } + inst := c.convertServerInstance(servers[0]) + inst.RefID = attrs.RefID + inst.CloudCredRefID = c.refID + inst.Tags = attrs.Tags + return inst, nil +} + +func (c *NaverClient) GetInstance(ctx context.Context, id cloud.CloudProviderInstanceID) (*cloud.Instance, error) { + params := url.Values{} + if c.location != "" { + params.Set("regionCode", c.location) + } + params.Set("serverInstanceNo", string(id)) + + var resp serverInstanceListResponse + if err := c.do(ctx, "getServerInstanceDetail", params, &resp); err != nil { + return nil, err + } + if len(resp.Detail.ServerInstanceList) == 0 { + return nil, fmt.Errorf("server instance %s not found", id) + } + return c.convertServerInstance(resp.Detail.ServerInstanceList[0]), nil +} + +func (c *NaverClient) ListInstances(ctx context.Context, args cloud.ListInstancesArgs) ([]cloud.Instance, error) { + params := url.Values{} + if c.location != "" { + params.Set("regionCode", c.location) + } + for i, id := range args.InstanceIDs { + params.Set(indexedParam("serverInstanceNoList", i+1), string(id)) + } + + var resp serverInstanceListResponse + if err := c.do(ctx, "getServerInstanceList", params, &resp); err != nil { + return nil, err + } + instances := make([]cloud.Instance, 0, len(resp.Get.ServerInstanceList)) + for _, server := range resp.Get.ServerInstanceList { + if len(args.Locations) > 0 && !args.Locations.IsAllowed(server.RegionCode) { + continue + } + if len(args.InstanceIDs) > 0 && !slices.Contains(args.InstanceIDs, cloud.CloudProviderInstanceID(server.ServerInstanceNo)) { + continue + } + instances = append(instances, *c.convertServerInstance(server)) + } + return instances, nil +} + +func (c *NaverClient) TerminateInstance(ctx context.Context, id cloud.CloudProviderInstanceID) error { + return c.instanceAction(ctx, "terminateServerInstances", id) +} + +func (c *NaverClient) RebootInstance(ctx context.Context, id cloud.CloudProviderInstanceID) error { + return c.instanceAction(ctx, "rebootServerInstances", id) +} + +func (c *NaverClient) StopInstance(ctx context.Context, id cloud.CloudProviderInstanceID) error { + return c.instanceAction(ctx, "stopServerInstances", id) +} + +func (c *NaverClient) StartInstance(ctx context.Context, id cloud.CloudProviderInstanceID) error { + return c.instanceAction(ctx, "startServerInstances", id) +} + +func (c *NaverClient) instanceAction(ctx context.Context, action string, id cloud.CloudProviderInstanceID) error { + params := url.Values{} + if c.location != "" { + params.Set("regionCode", c.location) + } + params.Set("serverInstanceNoList.1", string(id)) + var resp serverInstanceListResponse + return c.do(ctx, action, params, &resp) +} + +func (c *NaverClient) importLoginKey(ctx context.Context, location, keyName, publicKey string) (string, error) { + params := url.Values{} + params.Set("regionCode", location) + params.Set("keyName", keyName) + params.Set("publicKey", publicKey) + + var resp importLoginKeyResponse + if err := c.do(ctx, "importLoginKey", params, &resp); err != nil { + return "", err + } + if len(resp.Response.LoginKeyList) == 0 { + return "", fmt.Errorf("importLoginKey returned no login keys") + } + return resp.Response.LoginKeyList[0].KeyName, nil +} + +func (c *NaverClient) convertServerInstance(server naverServerInstance) *cloud.Instance { + createdAt, _ := parseNaverTime(server.CreateDate) + instanceType := firstNonEmpty(server.ServerProductCode, server.ServerSpecCode) + imageID := firstNonEmpty(server.ServerImageProductCode, server.ServerImageNo) + diskBytes := server.BaseBlockStorageSize + volumeType := firstNonEmpty(server.BaseBlockStorageDiskDetailType.Code, server.BaseBlockStorageDiskType.Code) + inst := &cloud.Instance{ + Name: server.ServerName, + CloudID: cloud.CloudProviderInstanceID(server.ServerInstanceNo), + CloudCredRefID: c.refID, + CreatedAt: createdAt, + PublicIP: server.PublicIP, + PublicDNS: server.PublicIP, + PrivateIP: server.PrivateIP, + ImageID: imageID, + InstanceType: instanceType, + DiskSize: units.Base2Bytes(diskBytes), + DiskSizeBytes: cloud.NewBytes(cloud.BytesValue(diskBytes), cloud.Byte), + VolumeType: volumeType, + SSHUser: defaultSSHUser, + SSHPort: defaultSSHPort, + VPCID: server.VPCNo, + SubnetID: server.SubnetNo, + Location: server.RegionCode, + SubLocation: server.ZoneCode, + Stoppable: true, + Rebootable: true, + FirewallRules: cloud.FirewallRules{}, + InstanceTypeID: "", + AdditionalDisks: nil, + IPAllocationID: optionalString(server.PublicIPInstanceNo), + PubKeyFingerprint: "", + Status: cloud.Status{ + LifecycleStatus: naverStatusToLifecycle(server.ServerInstanceStatus.Code, server.ServerInstanceStatusName, server.ServerInstanceOperation.Code), + }, + } + inst.InstanceTypeID = cloud.MakeGenericInstanceTypeIDFromInstance(*inst) + return inst +} + +func naverStatusToLifecycle(code, name, operation string) cloud.LifecycleStatus { + normalized := strings.ToLower(strings.Join([]string{code, name, operation}, " ")) + switch { + case strings.Contains(normalized, "termt") || strings.Contains(normalized, "terminating"): + return cloud.LifecycleStatusTerminating + case strings.Contains(normalized, "terminated"): + return cloud.LifecycleStatusTerminated + case strings.Contains(normalized, "nstop") || strings.Contains(normalized, "stopped"): + return cloud.LifecycleStatusStopped + case strings.Contains(normalized, "stop"): + return cloud.LifecycleStatusStopping + case strings.Contains(normalized, "run") || strings.Contains(normalized, "running"): + return cloud.LifecycleStatusRunning + case strings.Contains(normalized, "fail") || strings.Contains(normalized, "error"): + return cloud.LifecycleStatusFailed + default: + return cloud.LifecycleStatusPending + } +} + +func parseNaverTime(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + for _, layout := range []string{"2006-01-02T15:04:05-0700", time.RFC3339} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported NAVER time format %q", value) +} + +func naverResourceName(value string) string { + value = strings.ToLower(value) + value = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(value, "-") + value = strings.Trim(value, "-") + if value == "" || value[0] < 'a' || value[0] > 'z' { + value = "n-" + value + } + if len(value) > 30 { + value = value[:30] + } + value = strings.Trim(value, "-") + if len(value) < 3 { + value += strings.Repeat("0", 3-len(value)) + } + return value +} + +func indexedParam(prefix string, index int) string { + return fmt.Sprintf("%s.%d", prefix, index) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func (c *NaverClient) MergeInstanceForUpdate(_ cloud.Instance, newInst cloud.Instance) cloud.Instance { + return newInst +} + +func (c *NaverClient) MergeInstanceTypeForUpdate(_ cloud.InstanceType, newIt cloud.InstanceType) cloud.InstanceType { + return newIt +} diff --git a/v1/providers/naver/instance_test.go b/v1/providers/naver/instance_test.go new file mode 100644 index 0000000..a03cd13 --- /dev/null +++ b/v1/providers/naver/instance_test.go @@ -0,0 +1,120 @@ +package v1 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + cloud "github.com/brevdev/cloud/v1" +) + +func TestCreateInstanceImportsKeyAndCreatesServer(t *testing.T) { + var sawImport bool + var sawCreate bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vserver/v2/importLoginKey": + sawImport = true + if got := r.URL.Query().Get("publicKey"); got != "ssh-rsa AAAA test" { + t.Fatalf("publicKey = %q", got) + } + _, _ = w.Write([]byte(`{"importLoginKeyResponse":{"returnCode":"0","returnMessage":"success","loginKeyList":[{"keyName":"brev-ref-1","fingerprint":"fp"}]}}`)) + case "/vserver/v2/createServerInstances": + sawCreate = true + query := r.URL.Query() + assertQuery(t, query.Get("regionCode"), "KR") + assertQuery(t, query.Get("vpcNo"), "123") + assertQuery(t, query.Get("subnetNo"), "456") + assertQuery(t, query.Get("serverImageProductCode"), "UBUNTU24") + assertQuery(t, query.Get("serverProductCode"), "GPU-T4") + assertQuery(t, query.Get("loginKeyName"), "brev-ref-1") + assertQuery(t, query.Get("networkInterfaceList.1.networkInterfaceOrder"), "0") + _, _ = w.Write([]byte(`{"createServerInstancesResponse":{"returnCode":"0","returnMessage":"success","serverInstanceList":[{"serverInstanceNo":"999","serverName":"brev-test","cpuCount":4,"memorySize":17179869184,"publicIp":"203.0.113.10","serverInstanceStatus":{"code":"INIT","codeName":"Server init state"},"serverInstanceStatusName":"init","createDate":"2025-06-11T17:00:14+0900","serverImageProductCode":"UBUNTU24","serverProductCode":"GPU-T4","zoneCode":"KR-1","regionCode":"KR","vpcNo":"123","subnetNo":"456","baseBlockStorageSize":53687091200,"baseBlockStorageDiskDetailType":{"code":"SSD","codeName":"SSD"}}]}}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + instance, err := client.CreateInstance(context.Background(), cloud.CreateInstanceAttrs{ + Location: "KR", + Name: "brev-test", + RefID: "ref-1", + VPCID: "123", + SubnetID: "456", + PublicKey: "ssh-rsa AAAA test", + ImageID: "UBUNTU24", + InstanceType: "GPU-T4", + }) + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + if !sawImport || !sawCreate { + t.Fatalf("sawImport=%v sawCreate=%v", sawImport, sawCreate) + } + if instance.CloudID != "999" || instance.Status.LifecycleStatus != cloud.LifecycleStatusPending || instance.PublicIP != "203.0.113.10" { + t.Fatalf("unexpected instance: %+v", instance) + } + if instance.SSHUser != defaultSSHUser || instance.SSHPort != defaultSSHPort { + t.Fatalf("unexpected SSH settings: %+v", instance) + } +} + +func TestListGetAndTerminateInstances(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vserver/v2/getServerInstanceDetail": + if got := r.URL.Query().Get("serverInstanceNo"); got != "999" { + t.Fatalf("serverInstanceNo = %q", got) + } + _, _ = w.Write([]byte(`{"getServerInstanceDetailResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"serverInstanceList":[{"serverInstanceNo":"999","serverName":"brev-test","cpuCount":4,"memorySize":17179869184,"publicIp":"203.0.113.10","serverInstanceStatus":{"code":"RUN","codeName":"Server RUN status"},"serverInstanceStatusName":"running","createDate":"2025-06-11T17:00:14+0900","serverImageProductCode":"UBUNTU24","serverProductCode":"GPU-T4","zoneCode":"KR-1","regionCode":"KR","vpcNo":"123","subnetNo":"456","baseBlockStorageSize":53687091200}]}}`)) + case "/vserver/v2/getServerInstanceList": + if got := r.URL.Query().Get("serverInstanceNoList.1"); got != "999" { + t.Fatalf("serverInstanceNoList.1 = %q", got) + } + _, _ = w.Write([]byte(`{"getServerInstanceListResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"serverInstanceList":[{"serverInstanceNo":"999","serverName":"brev-test","cpuCount":4,"memorySize":17179869184,"publicIp":"203.0.113.10","serverInstanceStatus":{"code":"RUN","codeName":"Server RUN status"},"serverInstanceStatusName":"running","createDate":"2025-06-11T17:00:14+0900","serverImageProductCode":"UBUNTU24","serverProductCode":"GPU-T4","zoneCode":"KR-1","regionCode":"KR","vpcNo":"123","subnetNo":"456","baseBlockStorageSize":53687091200}]}}`)) + case "/vserver/v2/terminateServerInstances": + if got := r.URL.Query().Get("serverInstanceNoList.1"); got != "999" { + t.Fatalf("terminate serverInstanceNoList.1 = %q", got) + } + _, _ = w.Write([]byte(`{"terminateServerInstancesResponse":{"returnCode":"0","returnMessage":"success"}}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + got, err := client.GetInstance(context.Background(), "999") + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.CloudID != "999" || got.Status.LifecycleStatus != cloud.LifecycleStatusRunning { + t.Fatalf("unexpected get instance: %+v", got) + } + + list, err := client.ListInstances(context.Background(), cloud.ListInstancesArgs{ + InstanceIDs: []cloud.CloudProviderInstanceID{"999"}, + }) + if err != nil { + t.Fatalf("ListInstances() error = %v", err) + } + if len(list) != 1 || list[0].CloudID != "999" { + t.Fatalf("unexpected list: %+v", list) + } + + if err := client.TerminateInstance(context.Background(), "999"); err != nil { + t.Fatalf("TerminateInstance() error = %v", err) + } +} + +func assertQuery(t *testing.T, got, want string) { + t.Helper() + if got != want { + t.Fatalf("query = %q, want %q", got, want) + } +} diff --git a/v1/providers/naver/instancetype.go b/v1/providers/naver/instancetype.go new file mode 100644 index 0000000..e6805a7 --- /dev/null +++ b/v1/providers/naver/instancetype.go @@ -0,0 +1,288 @@ +package v1 + +import ( + "context" + "encoding/json" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/alecthomas/units" + "github.com/bojanz/currency" + cloud "github.com/brevdev/cloud/v1" +) + +const ( + defaultPayCurrencyCode = "USD" + ncloudServerProductKindVPC = "VSVR" + ncloudMeterRatePriceType = "MTRAT" + ncloudHourlyUsageUnit = "USAGE_HH" +) + +type serverProductListResponse struct { + Response productList `json:"getServerProductListResponse"` +} + +func (r *serverProductListResponse) apiError() error { + return r.Response.apiError() +} + +type productList struct { + responseMeta + TotalRows int `json:"totalRows"` + ProductList []naverProduct `json:"productList"` +} + +type naverProduct struct { + ProductCode string `json:"productCode"` + ProductName string `json:"productName"` + ProductType codeName `json:"productType"` + ProductDescription string `json:"productDescription"` + InfraResourceType codeName `json:"infraResourceType"` + CPUCount int32 `json:"cpuCount"` + MemorySize int64 `json:"memorySize"` + BaseBlockStorageSize int64 `json:"baseBlockStorageSize"` + OSInformation string `json:"osInformation"` + DiskType codeName `json:"diskType"` + PlatformType codeName `json:"platformType"` + GenerationCode string `json:"generationCode"` +} + +type productPriceListResponse struct { + Response naverProductPriceList `json:"getProductPriceListResponse"` +} + +func (r *productPriceListResponse) apiError() error { + return r.Response.apiError() +} + +type naverProductPriceList struct { + responseMeta + TotalRows int `json:"totalRows"` + ProductPriceList []naverProductPrice `json:"productPriceList"` +} + +type naverProductPrice struct { + ProductCode string `json:"productCode"` + PriceList []naverPrice `json:"priceList"` +} + +type naverPrice struct { + PriceType codeName `json:"priceType"` + Unit codeName `json:"unit"` + Price naverPriceAmount `json:"price"` + PayCurrency naverPayCurrency `json:"payCurrency"` +} + +type naverPayCurrency struct { + Code string `json:"code"` +} + +type naverPriceAmount string + +func (a *naverPriceAmount) UnmarshalJSON(data []byte) error { + var value string + if err := json.Unmarshal(data, &value); err == nil { + *a = naverPriceAmount(value) + return nil + } + + var number json.Number + if err := json.Unmarshal(data, &number); err != nil { + return err + } + *a = naverPriceAmount(number.String()) + return nil +} + +func (c *NaverClient) GetInstanceTypePollTime() time.Duration { + return defaultInstanceTypePollMinutes * time.Minute +} + +func (c *NaverClient) GetInstanceTypes(ctx context.Context, args cloud.GetInstanceTypeArgs) ([]cloud.InstanceType, error) { + locations, err := c.instanceTypeLocations(ctx, args.Locations) + if err != nil { + return nil, err + } + + var out []cloud.InstanceType + for _, location := range locations { + instanceTypes, err := c.instanceTypesForLocation(ctx, location, args) + if err != nil { + return nil, err + } + out = append(out, instanceTypes...) + } + return out, nil +} + +func (c *NaverClient) instanceTypeLocations(ctx context.Context, locations cloud.LocationsFilter) (cloud.LocationsFilter, error) { + if len(locations) == 0 { + return cloud.LocationsFilter{c.location}, nil + } + if !locations.IsAll() { + return locations, nil + } + + locs, err := c.GetLocations(ctx, cloud.GetLocationsArgs{}) + if err != nil { + return nil, err + } + resolved := make(cloud.LocationsFilter, 0, len(locs)) + for _, loc := range locs { + resolved = append(resolved, loc.Name) + } + return resolved, nil +} + +func (c *NaverClient) instanceTypesForLocation(ctx context.Context, location string, args cloud.GetInstanceTypeArgs) ([]cloud.InstanceType, error) { + params := url.Values{} + params.Set("regionCode", location) + params.Set("serverImageProductCode", defaultServerImageProductCode) + + var resp serverProductListResponse + if err := c.do(ctx, "getServerProductList", params, &resp); err != nil { + return nil, err + } + + prices := c.productPrices(ctx, location) + out := make([]cloud.InstanceType, 0, len(resp.Response.ProductList)) + for _, product := range resp.Response.ProductList { + it := product.toInstanceType(location, prices[product.ProductCode]) + if includeInstanceType(it, args) { + out = append(out, it) + } + } + return out, nil +} + +func includeInstanceType(it cloud.InstanceType, args cloud.GetInstanceTypeArgs) bool { + if len(args.InstanceTypes) > 0 && !slices.Contains(args.InstanceTypes, it.Type) { + return false + } + if args.CloudFilter != nil && !args.CloudFilter.IsAllowed(it.Cloud) { + return false + } + if args.ArchitectureFilter != nil && !args.ArchitectureFilter.IsAllowed(cloud.ArchitectureX86_64) { + return false + } + return allowsGPUManufacturer(it.SupportedGPUs, args.GPUManufactererFilter) +} + +func allowsGPUManufacturer(gpus []cloud.GPU, filter *cloud.GPUManufacturerFilter) bool { + if filter == nil || len(gpus) == 0 { + return true + } + for _, gpu := range gpus { + if filter.IsAllowed(gpu.Manufacturer) { + return true + } + } + return false +} + +func (c *NaverClient) productPrices(ctx context.Context, location string) map[string]*currency.Amount { + params := url.Values{} + params.Set("regionCode", location) + params.Set("productItemKindCode", ncloudServerProductKindVPC) + params.Set("payCurrencyCode", defaultPayCurrencyCode) + params.Set("pageSize", "1000") + + var resp productPriceListResponse + if err := c.doBilling(ctx, "product/getProductPriceList", params, &resp); err != nil { + return nil + } + + prices := make(map[string]*currency.Amount, len(resp.Response.ProductPriceList)) + for _, productPrice := range resp.Response.ProductPriceList { + price := hourlyPrice(productPrice.PriceList) + if price != nil { + prices[productPrice.ProductCode] = price + } + } + return prices +} + +func hourlyPrice(prices []naverPrice) *currency.Amount { + for _, price := range prices { + if price.PriceType.Code != ncloudMeterRatePriceType || price.Unit.Code != ncloudHourlyUsageUnit { + continue + } + + amount, err := currency.NewAmount(string(price.Price), price.PayCurrency.Code) + if err != nil { + return nil + } + return &amount + } + return nil +} + +func (p naverProduct) toInstanceType(location string, basePrice *currency.Amount) cloud.InstanceType { + storage := cloud.Storage{ + Type: firstNonEmpty(p.DiskType.Code, "NET"), + Count: 1, + Size: units.Base2Bytes(p.BaseBlockStorageSize), + SizeBytes: cloud.NewBytes(cloud.BytesValue(p.BaseBlockStorageSize), cloud.Byte), + } + it := cloud.InstanceType{ + Location: location, + Type: p.ProductCode, + SupportedGPUs: parseGPU(p), + SupportedStorage: []cloud.Storage{storage}, + SupportedUsageClasses: []string{"on-demand"}, + Memory: units.Base2Bytes(p.MemorySize), + MemoryBytes: cloud.NewBytes(cloud.BytesValue(p.MemorySize), cloud.Byte), + MaximumNetworkInterfaces: 3, + NetworkPerformance: "", + VCPU: p.CPUCount, + SupportedArchitectures: []cloud.Architecture{cloud.ArchitectureX86_64}, + Stoppable: true, + Rebootable: true, + IsAvailable: true, + BasePrice: basePrice, + Provider: CloudProviderID, + Cloud: CloudProviderID, + } + it.ID = cloud.MakeGenericInstanceTypeID(it) + return it +} + +func parseGPU(product naverProduct) []cloud.GPU { + text := strings.Join([]string{product.ProductCode, product.ProductName, product.ProductDescription, product.ProductType.Code, product.ProductType.CodeName}, " ") + if !strings.Contains(strings.ToUpper(text), "GPU") && !containsKnownGPU(text) { + return nil + } + + name := "GPU" + count := int32(1) + gpuRe := regexp.MustCompile(`(?i)(?:NVIDIA\s+)?(H200|H100|A100|A10|L40S|V100|T4|RTX[0-9A-Z]+)\s*(?:N\d+)?\s*(\d+)?\s*EA?`) + if match := gpuRe.FindStringSubmatch(text); len(match) > 0 { + name = strings.ToUpper(match[1]) + if len(match) > 2 && match[2] != "" { + if parsed, err := strconv.ParseInt(match[2], 10, 32); err == nil && parsed > 0 { + count = int32(parsed) + } + } + } + + return []cloud.GPU{{ + Count: count, + Name: name, + Type: name, + Manufacturer: cloud.ManufacturerNVIDIA, + }} +} + +func containsKnownGPU(text string) bool { + upper := strings.ToUpper(text) + for _, token := range []string{"H200", "H100", "A100", "A10", "L40S", "V100", "T4"} { + if strings.Contains(upper, token) { + return true + } + } + return false +} diff --git a/v1/providers/naver/instancetype_test.go b/v1/providers/naver/instancetype_test.go new file mode 100644 index 0000000..7f95fb1 --- /dev/null +++ b/v1/providers/naver/instancetype_test.go @@ -0,0 +1,111 @@ +package v1 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + cloud "github.com/brevdev/cloud/v1" +) + +func TestGetInstanceTypesConvertsProducts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vserver/v2/getServerProductList": + writeServerProductList(t, w, r) + case "/billing/v1/product/getProductPriceList": + writeProductPriceList(t, w, r) + default: + t.Fatalf("path = %q", r.URL.Path) + } + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + types, err := client.GetInstanceTypes(context.Background(), cloud.GetInstanceTypeArgs{ + Locations: cloud.LocationsFilter{"KR"}, + InstanceTypes: []string{"SVR.VSVR.GPU.T4.C004.M016.NET.SSD.B050.G002"}, + }) + if err != nil { + t.Fatalf("GetInstanceTypes() error = %v", err) + } + if len(types) != 1 { + t.Fatalf("instance types len = %d, want 1", len(types)) + } + assertPricedGPUInstanceType(t, types[0]) +} + +func assertPricedGPUInstanceType(t *testing.T, it cloud.InstanceType) { + t.Helper() + if it.Provider != CloudProviderID || it.Cloud != CloudProviderID || it.Location != "KR" { + t.Fatalf("unexpected provider fields: %+v", it) + } + if it.Type != "SVR.VSVR.GPU.T4.C004.M016.NET.SSD.B050.G002" || it.VCPU != 4 { + t.Fatalf("unexpected instance type: %+v", it) + } + if len(it.SupportedGPUs) != 1 || it.SupportedGPUs[0].Name != "T4" || it.SupportedGPUs[0].Count != 1 { + t.Fatalf("unexpected GPU conversion: %+v", it.SupportedGPUs) + } + if it.BasePrice == nil || it.BasePrice.CurrencyCode() != "USD" || it.BasePrice.Number() != "0.5" { + t.Fatalf("unexpected base price: %v", it.BasePrice) + } + if it.ID == "" { + t.Fatal("instance type ID is empty") + } +} + +func writeServerProductList(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if got := r.URL.Query().Get("regionCode"); got != "KR" { + t.Fatalf("regionCode = %q", got) + } + if got := r.URL.Query().Get("serverImageProductCode"); got != defaultServerImageProductCode { + t.Fatalf("serverImageProductCode = %q", got) + } + _, _ = 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"}]}}`)) +} + +func writeProductPriceList(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if got := r.URL.Query().Get("regionCode"); got != "KR" { + t.Fatalf("price regionCode = %q", got) + } + if got := r.URL.Query().Get("productItemKindCode"); got != ncloudServerProductKindVPC { + t.Fatalf("productItemKindCode = %q", got) + } + if got := r.URL.Query().Get("payCurrencyCode"); got != defaultPayCurrencyCode { + t.Fatalf("payCurrencyCode = %q", got) + } + _, _ = 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"}}]}]}}`)) +} + +func TestGetInstanceTypesIgnoresPriceLookupFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/vserver/v2/getServerProductList": + _, _ = 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"}]}}`)) + case "/billing/v1/product/getProductPriceList": + http.Error(w, "billing unavailable", http.StatusInternalServerError) + default: + t.Fatalf("path = %q", r.URL.Path) + } + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + types, err := client.GetInstanceTypes(context.Background(), cloud.GetInstanceTypeArgs{ + Locations: cloud.LocationsFilter{"KR"}, + }) + if err != nil { + t.Fatalf("GetInstanceTypes() error = %v", err) + } + if len(types) != 1 { + t.Fatalf("instance types len = %d, want 1", len(types)) + } + if types[0].BasePrice != nil { + t.Fatalf("BasePrice = %v, want nil after price lookup failure", types[0].BasePrice) + } +} diff --git a/v1/providers/naver/location.go b/v1/providers/naver/location.go new file mode 100644 index 0000000..81c7c83 --- /dev/null +++ b/v1/providers/naver/location.go @@ -0,0 +1,65 @@ +package v1 + +import ( + "context" + + cloud "github.com/brevdev/cloud/v1" +) + +type regionListResponse struct { + Response regionList `json:"getRegionListResponse"` +} + +func (r *regionListResponse) apiError() error { + return r.Response.apiError() +} + +type regionList struct { + responseMeta + TotalRows int `json:"totalRows"` + RegionList []naverRegion `json:"regionList"` +} + +type naverRegion struct { + RegionNo string `json:"regionNo"` + RegionCode string `json:"regionCode"` + RegionName string `json:"regionName"` +} + +const countryJPN = "JPN" + +func (c *NaverClient) GetLocations(ctx context.Context, _ cloud.GetLocationsArgs) ([]cloud.Location, error) { + var resp regionListResponse + if err := c.do(ctx, "getRegionList", nil, &resp); err != nil { + return nil, err + } + + locations := make([]cloud.Location, 0, len(resp.Response.RegionList)) + for i, region := range resp.Response.RegionList { + locations = append(locations, cloud.Location{ + Name: region.RegionCode, + Description: region.RegionName, + Available: true, + Priority: i, + Country: countryForRegion(region.RegionCode), + }) + } + return locations, nil +} + +func countryForRegion(regionCode string) string { + switch regionCode { + case "KR": + return "KOR" + case countryJPN: + return countryJPN + case "SGN": + return "SGP" + case "USWN", "USEN": + return "USA" + case "DEN": + return "DEU" + default: + return "" + } +} diff --git a/v1/providers/naver/location_test.go b/v1/providers/naver/location_test.go new file mode 100644 index 0000000..a051bf3 --- /dev/null +++ b/v1/providers/naver/location_test.go @@ -0,0 +1,51 @@ +package v1 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + cloud "github.com/brevdev/cloud/v1" +) + +func TestGetLocationsConvertsRegions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/vserver/v2/getRegionList" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.URL.Query().Get("responseFormatType"); got != "json" { + t.Fatalf("responseFormatType = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"getRegionListResponse":{"returnCode":"0","returnMessage":"success","totalRows":2,"regionList":[{"regionCode":"KR","regionName":"Korea"},{"regionCode":"JPN","regionName":"Japan"}]}}`)) + })) + defer server.Close() + + client := newTestNaverClient(t, server.URL) + locations, err := client.GetLocations(context.Background(), cloud.GetLocationsArgs{}) + if err != nil { + t.Fatalf("GetLocations() error = %v", err) + } + if len(locations) != 2 { + t.Fatalf("locations len = %d, want 2", len(locations)) + } + if locations[0].Name != "KR" || locations[0].Description != "Korea" || !locations[0].Available || locations[0].Country != "KOR" { + t.Fatalf("unexpected KR location: %+v", locations[0]) + } + if locations[1].Name != "JPN" || locations[1].Country != "JPN" { + t.Fatalf("unexpected JPN location: %+v", locations[1]) + } +} + +func newTestNaverClient(t *testing.T, baseURL string) *NaverClient { + t.Helper() + client, err := NewNaverClient("ref-1", "access", "secret", WithBaseURL(baseURL), WithClock(func() time.Time { + return time.UnixMilli(1700000000123) + })) + if err != nil { + t.Fatalf("NewNaverClient() error = %v", err) + } + return client +}