Skip to content

Commit 313966b

Browse files
committed
feat(naver): add Naver cloud provider [LOCAL-20260421175940]
1 parent ecfee0e commit 313966b

11 files changed

Lines changed: 1288 additions & 0 deletions

v1/providers/naver/capabilities.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package v1
2+
3+
import (
4+
"context"
5+
6+
cloud "github.com/brevdev/cloud/v1"
7+
)
8+
9+
func getNaverCapabilities() cloud.Capabilities {
10+
return cloud.Capabilities{
11+
cloud.CapabilityCreateInstance,
12+
cloud.CapabilityTerminateInstance,
13+
cloud.CapabilityCreateTerminateInstance,
14+
cloud.CapabilityRebootInstance,
15+
cloud.CapabilityStopStartInstance,
16+
cloud.CapabilityMachineImage,
17+
}
18+
}
19+
20+
func (c *NaverClient) GetCapabilities(_ context.Context) (cloud.Capabilities, error) {
21+
return getNaverCapabilities(), nil
22+
}

v1/providers/naver/client.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package v1
2+
3+
import (
4+
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"strings"
14+
"time"
15+
16+
cloud "github.com/brevdev/cloud/v1"
17+
)
18+
19+
const (
20+
CloudProviderID = "naver"
21+
defaultBaseURL = "https://ncloud.apigw.ntruss.com"
22+
defaultRegionCode = "KR"
23+
defaultServerImageProductCode = "SW.VSVR.OS.LNX64.UBNTU.SVR24.G003"
24+
defaultSSHUser = "root"
25+
defaultSSHPort = 22
26+
defaultInstanceTypePollMinutes = 5
27+
)
28+
29+
type NaverCredential struct {
30+
RefID string
31+
AccessKey string
32+
SecretKey string
33+
}
34+
35+
var _ cloud.CloudCredential = &NaverCredential{}
36+
37+
func NewNaverCredential(refID, accessKey, secretKey string) *NaverCredential {
38+
return &NaverCredential{
39+
RefID: refID,
40+
AccessKey: accessKey,
41+
SecretKey: secretKey,
42+
}
43+
}
44+
45+
func (c *NaverCredential) GetReferenceID() string {
46+
return c.RefID
47+
}
48+
49+
func (c *NaverCredential) GetAPIType() cloud.APIType {
50+
return cloud.APITypeGlobal
51+
}
52+
53+
func (c *NaverCredential) GetCloudProviderID() cloud.CloudProviderID {
54+
return CloudProviderID
55+
}
56+
57+
func (c *NaverCredential) GetTenantID() (string, error) {
58+
if c.AccessKey == "" {
59+
return "", fmt.Errorf("access key is required")
60+
}
61+
sum := sha256.Sum256([]byte(c.AccessKey))
62+
return fmt.Sprintf("%s-%x", CloudProviderID, sum), nil
63+
}
64+
65+
func (c *NaverCredential) GetCapabilities(_ context.Context) (cloud.Capabilities, error) {
66+
return getNaverCapabilities(), nil
67+
}
68+
69+
func (c *NaverCredential) MakeClient(_ context.Context, location string) (cloud.CloudClient, error) {
70+
return NewNaverClient(c.RefID, c.AccessKey, c.SecretKey, WithLocation(location))
71+
}
72+
73+
type NaverClient struct {
74+
cloud.NotImplCloudClient
75+
refID string
76+
accessKey string
77+
secretKey string
78+
baseURL string
79+
httpClient *http.Client
80+
location string
81+
now func() time.Time
82+
}
83+
84+
var _ cloud.CloudClient = &NaverClient{}
85+
86+
type options struct {
87+
baseURL string
88+
httpClient *http.Client
89+
location string
90+
now func() time.Time
91+
}
92+
93+
type Option func(*options)
94+
95+
func WithBaseURL(baseURL string) Option {
96+
return func(opts *options) {
97+
opts.baseURL = strings.TrimRight(baseURL, "/")
98+
}
99+
}
100+
101+
func WithHTTPClient(client *http.Client) Option {
102+
return func(opts *options) {
103+
opts.httpClient = client
104+
}
105+
}
106+
107+
func WithLocation(location string) Option {
108+
return func(opts *options) {
109+
opts.location = location
110+
}
111+
}
112+
113+
func WithClock(now func() time.Time) Option {
114+
return func(opts *options) {
115+
opts.now = now
116+
}
117+
}
118+
119+
func NewNaverClient(refID, accessKey, secretKey string, opts ...Option) (*NaverClient, error) {
120+
if refID == "" {
121+
return nil, fmt.Errorf("refID is required")
122+
}
123+
if accessKey == "" || secretKey == "" {
124+
return nil, fmt.Errorf("accessKey and secretKey are required")
125+
}
126+
127+
options := options{
128+
baseURL: defaultBaseURL,
129+
httpClient: http.DefaultClient,
130+
location: defaultRegionCode,
131+
now: time.Now,
132+
}
133+
for _, opt := range opts {
134+
opt(&options)
135+
}
136+
if options.location == "" {
137+
options.location = defaultRegionCode
138+
}
139+
if options.httpClient == nil {
140+
options.httpClient = http.DefaultClient
141+
}
142+
if options.now == nil {
143+
options.now = time.Now
144+
}
145+
146+
return &NaverClient{
147+
refID: refID,
148+
accessKey: accessKey,
149+
secretKey: secretKey,
150+
baseURL: options.baseURL,
151+
httpClient: options.httpClient,
152+
location: options.location,
153+
now: options.now,
154+
}, nil
155+
}
156+
157+
func (c *NaverClient) GetAPIType() cloud.APIType {
158+
return cloud.APITypeGlobal
159+
}
160+
161+
func (c *NaverClient) GetCloudProviderID() cloud.CloudProviderID {
162+
return CloudProviderID
163+
}
164+
165+
func (c *NaverClient) GetReferenceID() string {
166+
return c.refID
167+
}
168+
169+
func (c *NaverClient) GetTenantID() (string, error) {
170+
sum := sha256.Sum256([]byte(c.accessKey))
171+
return fmt.Sprintf("%s-%x", CloudProviderID, sum), nil
172+
}
173+
174+
func (c *NaverClient) MakeClient(_ context.Context, location string) (cloud.CloudClient, error) {
175+
if location != "" {
176+
c.location = location
177+
}
178+
return c, nil
179+
}
180+
181+
func (c *NaverClient) do(ctx context.Context, action string, params url.Values, dst any) error {
182+
if params == nil {
183+
params = url.Values{}
184+
}
185+
params.Set("responseFormatType", "json")
186+
187+
path := "/vserver/v2/" + action
188+
query := params.Encode()
189+
requestURI := path
190+
if query != "" {
191+
requestURI += "?" + query
192+
}
193+
194+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+requestURI, nil)
195+
if err != nil {
196+
return err
197+
}
198+
c.sign(req, requestURI)
199+
200+
resp, err := c.httpClient.Do(req)
201+
if err != nil {
202+
return err
203+
}
204+
defer func() { _ = resp.Body.Close() }()
205+
206+
body, err := io.ReadAll(resp.Body)
207+
if err != nil {
208+
return err
209+
}
210+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
211+
return fmt.Errorf("naver API %s failed with status %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body)))
212+
}
213+
if err := json.Unmarshal(body, dst); err != nil {
214+
return fmt.Errorf("decode naver API %s response: %w", action, err)
215+
}
216+
if err := responseErr(dst); err != nil {
217+
return fmt.Errorf("naver API %s failed: %w", action, err)
218+
}
219+
return nil
220+
}
221+
222+
func (c *NaverClient) sign(req *http.Request, requestURI string) {
223+
timestamp := fmt.Sprintf("%d", c.now().UnixMilli())
224+
message := fmt.Sprintf("%s\n%s\n%s\n%s", req.Method, requestURI, timestamp, c.accessKey)
225+
mac := hmac.New(sha256.New, []byte(c.secretKey))
226+
_, _ = mac.Write([]byte(message))
227+
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
228+
229+
req.Header.Set("x-ncp-apigw-timestamp", timestamp)
230+
req.Header.Set("x-ncp-iam-access-key", c.accessKey)
231+
req.Header.Set("x-ncp-apigw-signature-v2", signature)
232+
}
233+
234+
type naverResponse interface {
235+
apiError() error
236+
}
237+
238+
func responseErr(dst any) error {
239+
if resp, ok := dst.(naverResponse); ok {
240+
return resp.apiError()
241+
}
242+
return nil
243+
}
244+
245+
type responseMeta struct {
246+
ReturnCode string `json:"returnCode"`
247+
ReturnMessage string `json:"returnMessage"`
248+
}
249+
250+
func (m responseMeta) apiError() error {
251+
if m.ReturnCode != "" && m.ReturnCode != "0" {
252+
return fmt.Errorf("%s: %s", m.ReturnCode, m.ReturnMessage)
253+
}
254+
return nil
255+
}
256+
257+
type codeName struct {
258+
Code string `json:"code"`
259+
CodeName string `json:"codeName"`
260+
}

v1/providers/naver/client_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package v1
2+
3+
import (
4+
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"fmt"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
"time"
13+
14+
cloud "github.com/brevdev/cloud/v1"
15+
)
16+
17+
func TestNaverClientSignsRequests(t *testing.T) {
18+
const (
19+
accessKey = "test-access"
20+
secretKey = "test-secret"
21+
)
22+
fixedNow := time.UnixMilli(1700000000123)
23+
var sawRequest bool
24+
25+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+
sawRequest = true
27+
if got := r.Header.Get("x-ncp-apigw-timestamp"); got != "1700000000123" {
28+
t.Fatalf("timestamp header = %q, want fixed timestamp", got)
29+
}
30+
if got := r.Header.Get("x-ncp-iam-access-key"); got != accessKey {
31+
t.Fatalf("access key header = %q, want %q", got, accessKey)
32+
}
33+
34+
expectedMessage := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, r.URL.RequestURI(), "1700000000123", accessKey)
35+
mac := hmac.New(sha256.New, []byte(secretKey))
36+
_, _ = mac.Write([]byte(expectedMessage))
37+
expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
38+
if got := r.Header.Get("x-ncp-apigw-signature-v2"); got != expectedSignature {
39+
t.Fatalf("signature header = %q, want %q", got, expectedSignature)
40+
}
41+
42+
w.Header().Set("Content-Type", "application/json")
43+
_, _ = w.Write([]byte(`{"getRegionListResponse":{"returnCode":"0","returnMessage":"success","totalRows":1,"regionList":[{"regionCode":"KR","regionName":"Korea"}]}}`))
44+
}))
45+
defer server.Close()
46+
47+
client, err := NewNaverClient("ref-1", accessKey, secretKey, WithBaseURL(server.URL), WithClock(func() time.Time {
48+
return fixedNow
49+
}))
50+
if err != nil {
51+
t.Fatalf("NewNaverClient() error = %v", err)
52+
}
53+
54+
_, err = client.GetLocations(context.Background(), cloud.GetLocationsArgs{})
55+
if err != nil {
56+
t.Fatalf("GetLocations() error = %v", err)
57+
}
58+
if !sawRequest {
59+
t.Fatal("server did not receive request")
60+
}
61+
}
62+
63+
func TestNaverCredentialCreatesClient(t *testing.T) {
64+
cred := NewNaverCredential("ref-1", "access", "secret")
65+
if got := cred.GetReferenceID(); got != "ref-1" {
66+
t.Fatalf("reference ID = %q", got)
67+
}
68+
if got := cred.GetCloudProviderID(); got != CloudProviderID {
69+
t.Fatalf("cloud provider ID = %q", got)
70+
}
71+
if got := cred.GetAPIType(); got != cloud.APITypeGlobal {
72+
t.Fatalf("API type = %q", got)
73+
}
74+
75+
client, err := cred.MakeClient(context.Background(), "KR")
76+
if err != nil {
77+
t.Fatalf("MakeClient() error = %v", err)
78+
}
79+
if got := client.GetReferenceID(); got != "ref-1" {
80+
t.Fatalf("client reference ID = %q", got)
81+
}
82+
}

0 commit comments

Comments
 (0)