Skip to content

Commit cc4003b

Browse files
committed
fix(alibaba): add location fallback for ECS endpoint resolution
1 parent 727c2b7 commit cc4003b

28 files changed

Lines changed: 1453 additions & 288 deletions

pkg/providers/alibaba/api/client.go

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@ import (
1414
)
1515

1616
type Request struct {
17-
Product string
18-
Version string
19-
Action string
20-
Region string
21-
Method string
22-
Query url.Values
23-
Headers http.Header
24-
Idempotent bool
25-
Scheme string
26-
Host string
27-
Path string
17+
Product string
18+
Version string
19+
Action string
20+
Region string
21+
Method string
22+
Query url.Values
23+
Headers http.Header
24+
Idempotent bool
25+
Scheme string
26+
Host string
27+
Path string
28+
SkipRegionID bool
2829
}
2930

3031
type Option func(*Client)
@@ -139,8 +140,10 @@ func (c *Client) Do(ctx context.Context, req Request, resp any) error {
139140
params.Set("SignatureType", "")
140141
params.Set("SignatureNonce", c.nonce())
141142
params.Set("AccessKeyId", c.credential.AccessKeyID)
142-
if _, ok := params["RegionId"]; !ok {
143-
params.Set("RegionId", region)
143+
if !req.SkipRegionID {
144+
if _, ok := params["RegionId"]; !ok {
145+
params.Set("RegionId", region)
146+
}
144147
}
145148
if c.credential.SecurityToken != "" {
146149
params.Set("SecurityToken", c.credential.SecurityToken)
@@ -154,7 +157,7 @@ func (c *Client) Do(ctx context.Context, req Request, resp any) error {
154157
return err
155158
}
156159

157-
scheme, host, path, err := c.resolveEndpoint(req, region)
160+
scheme, host, path, err := c.resolveEndpoint(ctx, req, region)
158161
if err != nil {
159162
return err
160163
}
@@ -166,7 +169,39 @@ func (c *Client) Do(ctx context.Context, req Request, resp any) error {
166169
}
167170
headers := c.buildHeaders(req.Headers)
168171

169-
httpResp, err := c.retryPolicy.Do(ctx, req.Idempotent, func() (*http.Response, error) {
172+
body, statusCode, err := c.doRequest(ctx, req.Idempotent, method, requestURL, headers, host)
173+
if err != nil {
174+
return err
175+
}
176+
if err := DecodeError(statusCode, body); err != nil {
177+
if req.Host == "" && IsNotSupportedEndpoint(err) {
178+
if resolved := c.resolveEndpointByLocation(ctx, req.Product, region); resolved != "" && resolved != host {
179+
requestURL.Host = resolved
180+
body, statusCode, err = c.doRequest(ctx, req.Idempotent, method, requestURL, headers, resolved)
181+
if err != nil {
182+
return err
183+
}
184+
if err := DecodeError(statusCode, body); err != nil {
185+
return err
186+
}
187+
} else {
188+
return err
189+
}
190+
} else {
191+
return err
192+
}
193+
}
194+
if resp == nil || len(body) == 0 {
195+
return nil
196+
}
197+
if err := json.Unmarshal(body, resp); err != nil {
198+
return fmt.Errorf("decode alibaba response: %w", err)
199+
}
200+
return nil
201+
}
202+
203+
func (c *Client) doRequest(ctx context.Context, idempotent bool, method string, requestURL url.URL, headers http.Header, host string) ([]byte, int, error) {
204+
httpResp, err := c.retryPolicy.Do(ctx, idempotent, func() (*http.Response, error) {
170205
httpReq, err := http.NewRequestWithContext(ctx, method, requestURL.String(), nil)
171206
if err != nil {
172207
return nil, err
@@ -176,27 +211,18 @@ func (c *Client) Do(ctx context.Context, req Request, resp any) error {
176211
return c.httpClient.Do(httpReq)
177212
})
178213
if err != nil {
179-
return err
214+
return nil, 0, err
180215
}
181216
defer closeResponse(httpResp)
182217

183218
body, err := io.ReadAll(httpResp.Body)
184219
if err != nil {
185-
return fmt.Errorf("read alibaba response: %w", err)
220+
return nil, 0, fmt.Errorf("read alibaba response: %w", err)
186221
}
187-
if err := DecodeError(httpResp.StatusCode, body); err != nil {
188-
return err
189-
}
190-
if resp == nil || len(body) == 0 {
191-
return nil
192-
}
193-
if err := json.Unmarshal(body, resp); err != nil {
194-
return fmt.Errorf("decode alibaba response: %w", err)
195-
}
196-
return nil
222+
return body, httpResp.StatusCode, nil
197223
}
198224

199-
func (c *Client) resolveEndpoint(req Request, region string) (string, string, string, error) {
225+
func (c *Client) resolveEndpoint(ctx context.Context, req Request, region string) (string, string, string, error) {
200226
scheme := "https"
201227
path := "/"
202228
host := ""
@@ -219,11 +245,16 @@ func (c *Client) resolveEndpoint(req Request, region string) (string, string, st
219245
host = req.Host
220246
}
221247
if host == "" {
222-
var err error
223-
host, err = resolveEndpointHost(req.Product, region)
248+
resolution, err := resolveEndpointHost(req.Product, region)
224249
if err != nil {
225250
return "", "", "", err
226251
}
252+
host = resolution.Host
253+
if resolution.TryLocation {
254+
if resolved := c.resolveEndpointByLocation(ctx, req.Product, region); resolved != "" {
255+
host = resolved
256+
}
257+
}
227258
}
228259
return scheme, host, path, nil
229260
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
"sync"
9+
)
10+
11+
const locationReadonlyEndpoint = "location-readonly.aliyuncs.com"
12+
13+
type endpointCache struct {
14+
mu sync.RWMutex
15+
items map[string]string
16+
}
17+
18+
func (c *endpointCache) Get(key string) (string, bool) {
19+
c.mu.RLock()
20+
defer c.mu.RUnlock()
21+
value, ok := c.items[key]
22+
return value, ok
23+
}
24+
25+
func (c *endpointCache) Set(key, value string) {
26+
c.mu.Lock()
27+
defer c.mu.Unlock()
28+
if c.items == nil {
29+
c.items = map[string]string{}
30+
}
31+
c.items[key] = value
32+
}
33+
34+
var locationEndpointCache = &endpointCache{items: map[string]string{}}
35+
36+
type describeLocationEndpointsResponse struct {
37+
Endpoints locationEndpoints `json:"Endpoints"`
38+
RequestID string `json:"RequestId"`
39+
Success bool `json:"Success"`
40+
}
41+
42+
type locationEndpoints struct {
43+
Endpoint []locationEndpoint `json:"Endpoint"`
44+
}
45+
46+
type locationEndpoint struct {
47+
Endpoint string `json:"Endpoint"`
48+
}
49+
50+
func (c *Client) resolveEndpointByLocation(ctx context.Context, product, region string) string {
51+
serviceCode, ok := locationServiceCode(product)
52+
if !ok {
53+
return ""
54+
}
55+
cacheKey := strings.ToLower(strings.TrimSpace(product)) + "#" + strings.ToLower(strings.TrimSpace(region))
56+
if cached, ok := locationEndpointCache.Get(cacheKey); ok && cached != "" {
57+
return cached
58+
}
59+
60+
query := url.Values{}
61+
query.Set("Id", NormalizeRegion(region))
62+
query.Set("ServiceCode", serviceCode)
63+
query.Set("Type", "openAPI")
64+
65+
var resp describeLocationEndpointsResponse
66+
if err := c.Do(ctx, Request{
67+
Product: "Location",
68+
Version: "2015-06-12",
69+
Action: "DescribeEndpoints",
70+
Method: http.MethodGet,
71+
Query: query,
72+
Host: locationReadonlyEndpoint,
73+
Idempotent: true,
74+
SkipRegionID: true,
75+
}, &resp); err != nil {
76+
return ""
77+
}
78+
for _, endpoint := range resp.Endpoints.Endpoint {
79+
host := strings.TrimSpace(endpoint.Endpoint)
80+
if host == "" {
81+
continue
82+
}
83+
locationEndpointCache.Set(cacheKey, host)
84+
return host
85+
}
86+
return ""
87+
}
88+
89+
func locationServiceCode(product string) (string, bool) {
90+
switch strings.ToLower(strings.TrimSpace(product)) {
91+
case "ecs":
92+
return "ecs", true
93+
default:
94+
return "", false
95+
}
96+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/404tk/cloudtoolkit/pkg/providers/alibaba/auth"
12+
)
13+
14+
func TestClientDescribeECSInstancesUsesLocationResolvedEndpoint(t *testing.T) {
15+
t.Parallel()
16+
17+
client := NewClient(
18+
auth.New("ak", "sk", ""),
19+
WithHTTPClient(&http.Client{
20+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
21+
switch req.URL.Host {
22+
case locationReadonlyEndpoint:
23+
if got := req.URL.Query().Get("ServiceCode"); got != "ecs" {
24+
t.Fatalf("unexpected location service code: %s", got)
25+
}
26+
if got := req.URL.Query().Get("Id"); got != "cn-guangzhou" {
27+
t.Fatalf("unexpected location region: %s", got)
28+
}
29+
if got := req.URL.Query().Get("RegionId"); got != "" {
30+
t.Fatalf("unexpected location RegionId: %s", got)
31+
}
32+
return jsonResponse(http.StatusOK, `{"Success":true,"Endpoints":{"Endpoint":[{"Endpoint":"ecs.cn-guangzhou.aliyuncs.com"}]}}`), nil
33+
case "ecs.cn-guangzhou.aliyuncs.com":
34+
if got := req.URL.Query().Get("Action"); got != "DescribeInstances" {
35+
t.Fatalf("unexpected ecs action: %s", got)
36+
}
37+
return jsonResponse(http.StatusOK, `{"PageSize":100,"PageNumber":1,"TotalCount":0,"Instances":{"Instance":[]}}`), nil
38+
default:
39+
t.Fatalf("unexpected host: %s", req.URL.Host)
40+
return nil, nil
41+
}
42+
}),
43+
}),
44+
WithClock(func() time.Time { return time.Unix(1713376800, 0).UTC() }),
45+
WithNonce(func() string { return "nonce" }),
46+
)
47+
48+
if _, err := client.DescribeECSInstances(context.Background(), "cn-guangzhou", 1, 100); err != nil {
49+
t.Fatalf("DescribeECSInstances() error = %v", err)
50+
}
51+
}
52+
53+
func TestClientDescribeECSInstancesFallsBackToGlobalEndpointWhenLocationMisses(t *testing.T) {
54+
t.Parallel()
55+
56+
client := NewClient(
57+
auth.New("ak", "sk", ""),
58+
WithHTTPClient(&http.Client{
59+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
60+
switch req.URL.Host {
61+
case locationReadonlyEndpoint:
62+
return jsonResponse(http.StatusOK, `{"Success":true,"Endpoints":{"Endpoint":[]}}`), nil
63+
case ecsGlobalEndpoint:
64+
return jsonResponse(http.StatusOK, `{"PageSize":100,"PageNumber":1,"TotalCount":0,"Instances":{"Instance":[]}}`), nil
65+
default:
66+
t.Fatalf("unexpected host: %s", req.URL.Host)
67+
return nil, nil
68+
}
69+
}),
70+
}),
71+
WithClock(func() time.Time { return time.Unix(1713376800, 0).UTC() }),
72+
WithNonce(func() string { return "nonce" }),
73+
)
74+
75+
if _, err := client.DescribeECSInstances(context.Background(), "cn-fuzhou", 1, 100); err != nil {
76+
t.Fatalf("DescribeECSInstances() error = %v", err)
77+
}
78+
}
79+
80+
func TestClientDescribeECSInstancesUsesStaticRegionalEndpoint(t *testing.T) {
81+
t.Parallel()
82+
83+
client := NewClient(
84+
auth.New("ak", "sk", ""),
85+
WithHTTPClient(&http.Client{
86+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
87+
if req.URL.Host != "ecs.ap-northeast-1.aliyuncs.com" {
88+
t.Fatalf("unexpected host: %s", req.URL.Host)
89+
}
90+
if strings.Contains(req.URL.RawQuery, "ServiceCode=ecs") {
91+
t.Fatal("location resolver should not be used for static regional endpoint")
92+
}
93+
return jsonResponse(http.StatusOK, `{"PageSize":100,"PageNumber":1,"TotalCount":0,"Instances":{"Instance":[]}}`), nil
94+
}),
95+
}),
96+
WithClock(func() time.Time { return time.Unix(1713376800, 0).UTC() }),
97+
WithNonce(func() string { return "nonce" }),
98+
)
99+
100+
if _, err := client.DescribeECSInstances(context.Background(), "ap-northeast-1", 1, 100); err != nil {
101+
t.Fatalf("DescribeECSInstances() error = %v", err)
102+
}
103+
}
104+
105+
func TestClientDescribeECSInstancesRetriesWithLocationAfterNotSupportedEndpoint(t *testing.T) {
106+
t.Parallel()
107+
108+
var initialAttempts int
109+
client := NewClient(
110+
auth.New("ak", "sk", ""),
111+
WithHTTPClient(&http.Client{
112+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
113+
switch req.URL.Host {
114+
case "ecs-cn-hangzhou.aliyuncs.com":
115+
initialAttempts++
116+
return jsonResponse(http.StatusNotFound, `{"Code":"InvalidOperation.NotSupportedEndpoint","Message":"The specified endpoint cant operate this region.","RequestId":"req-1"}`), nil
117+
case locationReadonlyEndpoint:
118+
if got := req.URL.Query().Get("Id"); got != "ap-southeast-1" {
119+
t.Fatalf("unexpected location region: %s", got)
120+
}
121+
return jsonResponse(http.StatusOK, `{"Success":true,"Endpoints":{"Endpoint":[{"Endpoint":"ecs.ap-southeast-1.aliyuncs.com"}]}}`), nil
122+
case "ecs.ap-southeast-1.aliyuncs.com":
123+
return jsonResponse(http.StatusOK, `{"PageSize":100,"PageNumber":1,"TotalCount":0,"Instances":{"Instance":[]}}`), nil
124+
default:
125+
t.Fatalf("unexpected host: %s", req.URL.Host)
126+
return nil, nil
127+
}
128+
}),
129+
}),
130+
WithClock(func() time.Time { return time.Unix(1713376800, 0).UTC() }),
131+
WithNonce(func() string { return "nonce" }),
132+
)
133+
134+
if _, err := client.DescribeECSInstances(context.Background(), "ap-southeast-1", 1, 100); err != nil {
135+
t.Fatalf("DescribeECSInstances() error = %v", err)
136+
}
137+
if initialAttempts != 1 {
138+
t.Fatalf("unexpected initial attempts: %d", initialAttempts)
139+
}
140+
}
141+
142+
type roundTripFunc func(*http.Request) (*http.Response, error)
143+
144+
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
145+
return fn(req)
146+
}
147+
148+
func jsonResponse(status int, body string) *http.Response {
149+
return &http.Response{
150+
StatusCode: status,
151+
Status: http.StatusText(status),
152+
Header: http.Header{"Content-Type": []string{"application/json"}},
153+
Body: io.NopCloser(strings.NewReader(body)),
154+
}
155+
}

0 commit comments

Comments
 (0)