Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions v1/providers/naver/capabilities.go
Original file line number Diff line number Diff line change
@@ -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
}
280 changes: 280 additions & 0 deletions v1/providers/naver/client.go
Original file line number Diff line number Diff line change
@@ -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"`
}
82 changes: 82 additions & 0 deletions v1/providers/naver/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading