Skip to content

Commit 62d8a75

Browse files
committed
feat(jdcloud): add bucket check
1 parent 9eea636 commit 62d8a75

6 files changed

Lines changed: 486 additions & 12 deletions

File tree

pkg/providers/jdcloud/jdcloud.go

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import (
2121
)
2222

2323
type Provider struct {
24-
region string
25-
accessKey string
26-
apiClient *_api.Client
24+
credential _auth.Credential
25+
region string
26+
accessKey string
27+
apiClient *_api.Client
2728
}
2829

2930
// New creates a new provider client for JDCloud API.
@@ -53,9 +54,10 @@ func New(options schema.Options) (*Provider, error) {
5354
}
5455

5556
return &Provider{
56-
region: region,
57-
accessKey: credential.AccessKey,
58-
apiClient: apiClient,
57+
credential: credential,
58+
region: region,
59+
accessKey: credential.AccessKey,
60+
apiClient: apiClient,
5961
}, nil
6062
}
6163

@@ -96,7 +98,7 @@ func (p *Provider) Resources(ctx context.Context) (schema.Resources, error) {
9698
list.AddError("account", err)
9799
case "database":
98100
case "bucket":
99-
d := &oss.Driver{Client: p.apiClient}
101+
d := p.newOSSDriver(p.region)
100102
storages, err := d.ListBuckets(ctx)
101103
schema.AppendAssets(&list, storages)
102104
list.AddError("bucket", err)
@@ -129,6 +131,28 @@ func (p *Provider) UserManagement(action, username, password string) {
129131
}
130132
}
131133

134+
func (p *Provider) BucketDump(ctx context.Context, action, bucketName string) {
135+
driver := p.newOSSDriver(p.region)
136+
switch action {
137+
case "list":
138+
infos, err := p.bucketInfos(context.Background(), driver, bucketName)
139+
if err != nil {
140+
logger.Error("List buckets failed:", err)
141+
return
142+
}
143+
driver.ListObjects(ctx, infos)
144+
case "total":
145+
infos, err := p.bucketInfos(context.Background(), driver, bucketName)
146+
if err != nil {
147+
logger.Error("List buckets failed:", err)
148+
return
149+
}
150+
driver.TotalObjects(ctx, infos)
151+
default:
152+
logger.Error("`list all` or `total all`.")
153+
}
154+
}
155+
132156
// ExecuteCloudVMCommand routes through JDCloud Cloud Assistant (assistant.jdcloud-api.com).
133157
// Region must be a real VM region (cn-north-1 / cn-east-2 / ...); we resolve it
134158
// from the host cache populated by `cloudlist` so `shell <instance-id>` works
@@ -163,3 +187,50 @@ func (p *Provider) lookupHost(instanceID string) (schema.Host, bool) {
163187
}
164188
return schema.Host{}, false
165189
}
190+
191+
func (p *Provider) newOSSDriver(region string) *oss.Driver {
192+
return &oss.Driver{
193+
Client: p.apiClient,
194+
Credential: p.credential,
195+
Region: region,
196+
}
197+
}
198+
199+
func (p *Provider) bucketInfos(ctx context.Context, driver *oss.Driver, bucketName string) (map[string]string, error) {
200+
infos := make(map[string]string)
201+
bucketName = strings.TrimSpace(bucketName)
202+
region := strings.TrimSpace(p.region)
203+
switch {
204+
case bucketName == "":
205+
return nil, fmt.Errorf("empty bucket name")
206+
case bucketName == "all":
207+
buckets, err := driver.ListBuckets(ctx)
208+
if err != nil {
209+
return nil, err
210+
}
211+
for _, bucket := range buckets {
212+
bucketRegion := strings.TrimSpace(bucket.Region)
213+
if bucketRegion == "" {
214+
bucketRegion, err = driver.ResolveBucketRegion(ctx, bucket.BucketName)
215+
if err != nil {
216+
return nil, err
217+
}
218+
}
219+
infos[bucket.BucketName] = bucketRegion
220+
}
221+
if len(infos) == 0 {
222+
return nil, fmt.Errorf("no buckets found")
223+
}
224+
return infos, nil
225+
case region != "" && !strings.EqualFold(region, "all"):
226+
infos[bucketName] = region
227+
return infos, nil
228+
default:
229+
resolved, err := driver.ResolveBucketRegion(ctx, bucketName)
230+
if err != nil {
231+
return nil, fmt.Errorf("bucket %s region not found; set region explicitly or use `list all` first", bucketName)
232+
}
233+
infos[bucketName] = resolved
234+
return infos, nil
235+
}
236+
}

pkg/providers/jdcloud/oss/bucket.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,31 @@ package oss
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"net/http"
8+
"strings"
69

710
"github.com/404tk/cloudtoolkit/pkg/providers/jdcloud/api"
11+
jdauth "github.com/404tk/cloudtoolkit/pkg/providers/jdcloud/auth"
812
"github.com/404tk/cloudtoolkit/pkg/schema"
913
"github.com/404tk/cloudtoolkit/utils/logger"
1014
)
1115

16+
const defaultBucketRegion = "cn-north-1"
17+
18+
var knownJDCloudOSSRegions = []string{
19+
"cn-north-1",
20+
"cn-east-1",
21+
"cn-east-2",
22+
"cn-south-1",
23+
"eu-west-1",
24+
}
25+
1226
type Driver struct {
13-
Client *api.Client
27+
Client *api.Client
28+
Credential jdauth.Credential
29+
Region string
30+
ObjectClient *Client
1431
}
1532

1633
func (d *Driver) ListBuckets(ctx context.Context) ([]schema.Storage, error) {
@@ -45,8 +62,78 @@ func (d *Driver) ListBuckets(ctx context.Context) ([]schema.Storage, error) {
4562
_bucket := schema.Storage{
4663
BucketName: bucket.Name,
4764
}
65+
if region, err := d.ResolveBucketRegion(ctx, bucket.Name); err == nil {
66+
_bucket.Region = region
67+
}
4868
list = append(list, _bucket)
4969
}
5070

5171
return list, nil
5272
}
73+
74+
func (d *Driver) ResolveBucketRegion(ctx context.Context, bucket string) (string, error) {
75+
if ctx == nil {
76+
ctx = context.Background()
77+
}
78+
bucket = strings.TrimSpace(bucket)
79+
if bucket == "" {
80+
return "", fmt.Errorf("empty bucket name")
81+
}
82+
if d.Client == nil {
83+
return "", errors.New("jdcloud oss: nil api client")
84+
}
85+
86+
var lastErr error
87+
for _, region := range d.probeRegions() {
88+
err := d.headBucket(ctx, region, bucket)
89+
if err == nil {
90+
return region, nil
91+
}
92+
lastErr = err
93+
var apiErr *api.APIError
94+
if errors.As(err, &apiErr) && apiErr.IsAuthFailure() {
95+
return "", err
96+
}
97+
}
98+
if lastErr != nil {
99+
return "", fmt.Errorf("bucket %s region not found", bucket)
100+
}
101+
return "", fmt.Errorf("bucket %s region not found", bucket)
102+
}
103+
104+
func (d *Driver) objectClient() (*Client, error) {
105+
if d.ObjectClient != nil {
106+
return d.ObjectClient, nil
107+
}
108+
if err := d.Credential.Validate(); err != nil {
109+
return nil, err
110+
}
111+
d.ObjectClient = NewClient(d.Credential)
112+
return d.ObjectClient, nil
113+
}
114+
115+
func (d *Driver) headBucket(ctx context.Context, region, bucket string) error {
116+
return d.Client.DoJSON(ctx, api.Request{
117+
Service: "oss",
118+
Region: region,
119+
Method: http.MethodHead,
120+
Version: "v1",
121+
Path: "/regions/" + region + "/buckets/" + strings.TrimSpace(bucket),
122+
}, nil)
123+
}
124+
125+
func (d *Driver) probeRegions() []string {
126+
explicit := strings.TrimSpace(d.Region)
127+
if explicit == "" || strings.EqualFold(explicit, "all") {
128+
return append([]string(nil), knownJDCloudOSSRegions...)
129+
}
130+
131+
regions := []string{explicit}
132+
for _, region := range knownJDCloudOSSRegions {
133+
if strings.EqualFold(region, explicit) {
134+
continue
135+
}
136+
regions = append(regions, region)
137+
}
138+
return regions
139+
}

pkg/providers/jdcloud/oss/bucket_test.go

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,34 @@ package oss
22

33
import (
44
"context"
5+
"crypto/tls"
56
"net/http"
67
"net/http/httptest"
8+
"net/url"
9+
"strings"
710
"testing"
811
"time"
912

13+
awsapi "github.com/404tk/cloudtoolkit/pkg/providers/aws/api"
1014
"github.com/404tk/cloudtoolkit/pkg/providers/jdcloud/api"
1115
"github.com/404tk/cloudtoolkit/pkg/providers/jdcloud/auth"
1216
)
1317

14-
func TestDriverListBucketsUsesFixedRegion(t *testing.T) {
18+
func TestDriverListBucketsUsesFixedRegionAndResolvesBucketRegions(t *testing.T) {
1519
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16-
if r.URL.Path != "/v1/regions/cn-north-1/buckets" {
17-
t.Fatalf("unexpected path: %s", r.URL.Path)
20+
switch {
21+
case r.Method == http.MethodGet && r.URL.Path == "/v1/regions/cn-north-1/buckets":
22+
_, _ = w.Write([]byte(`{"requestId":"req-oss","result":{"buckets":[{"name":"bucket-a"},{"name":"bucket-b"}]}}`))
23+
case r.Method == http.MethodHead && r.URL.Path == "/v1/regions/cn-north-1/buckets/bucket-a":
24+
w.WriteHeader(http.StatusNotFound)
25+
_, _ = w.Write([]byte(`{"requestId":"req-miss-a","error":{"status":"404","code":404,"message":"bucket not found"}}`))
26+
case r.Method == http.MethodHead && r.URL.Path == "/v1/regions/cn-east-1/buckets/bucket-a":
27+
w.WriteHeader(http.StatusOK)
28+
case r.Method == http.MethodHead && r.URL.Path == "/v1/regions/cn-north-1/buckets/bucket-b":
29+
w.WriteHeader(http.StatusOK)
30+
default:
31+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
1832
}
19-
_, _ = w.Write([]byte(`{"requestId":"req-oss","result":{"buckets":[{"name":"bucket-a"},{"name":"bucket-b"}]}}`))
2033
}))
2134
defer server.Close()
2235

@@ -28,6 +41,79 @@ func TestDriverListBucketsUsesFixedRegion(t *testing.T) {
2841
if len(got) != 2 || got[0].BucketName != "bucket-a" || got[1].BucketName != "bucket-b" {
2942
t.Fatalf("unexpected buckets: %+v", got)
3043
}
44+
if got[0].Region != "cn-east-1" || got[1].Region != "cn-north-1" {
45+
t.Fatalf("unexpected bucket regions: %+v", got)
46+
}
47+
}
48+
49+
func TestDriverResolveBucketRegionUsesExplicitRegionFirst(t *testing.T) {
50+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+
switch r.URL.Path {
52+
case "/v1/regions/cn-south-1/buckets/demo-bucket":
53+
w.WriteHeader(http.StatusOK)
54+
default:
55+
t.Fatalf("unexpected path: %s", r.URL.Path)
56+
}
57+
}))
58+
defer server.Close()
59+
60+
driver := &Driver{
61+
Client: newTestClient(server.URL),
62+
Region: "cn-south-1",
63+
}
64+
got, err := driver.ResolveBucketRegion(context.Background(), "demo-bucket")
65+
if err != nil {
66+
t.Fatalf("ResolveBucketRegion() error = %v", err)
67+
}
68+
if got != "cn-south-1" {
69+
t.Fatalf("ResolveBucketRegion() = %q, want cn-south-1", got)
70+
}
71+
}
72+
73+
func TestClientListObjectsV2UsesServiceHostAndContinuationToken(t *testing.T) {
74+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
if r.Host != "s3.cn-north-1.jdcloud-oss.com" {
76+
t.Fatalf("unexpected host: %s", r.Host)
77+
}
78+
if r.URL.Path != "/demo-bucket" {
79+
t.Fatalf("unexpected path: %s", r.URL.Path)
80+
}
81+
if got := r.URL.Query().Get("list-type"); got != "2" {
82+
t.Fatalf("unexpected list-type: %s", got)
83+
}
84+
if got := r.URL.Query().Get("continuation-token"); got != "page-2" {
85+
t.Fatalf("unexpected continuation-token: %s", got)
86+
}
87+
if got := r.URL.Query().Get("max-keys"); got != "100" {
88+
t.Fatalf("unexpected max-keys: %s", got)
89+
}
90+
if authz := r.Header.Get("Authorization"); !strings.HasPrefix(authz, "AWS4-HMAC-SHA256 Credential=AKID/20260419/cn-north-1/s3/aws4_request, SignedHeaders=") {
91+
t.Fatalf("unexpected authorization: %s", authz)
92+
}
93+
_, _ = w.Write([]byte(`<ListBucketResult><IsTruncated>true</IsTruncated><NextContinuationToken>page-3</NextContinuationToken><Contents><Key>logs/a.txt</Key><Size>12</Size></Contents></ListBucketResult>`))
94+
}))
95+
defer server.Close()
96+
97+
client := NewClient(
98+
auth.New("AKID", "SECRET", ""),
99+
awsapi.WithHTTPClient(rewriteHostClient(server.URL)),
100+
awsapi.WithClock(func() time.Time { return time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) }),
101+
awsapi.WithRetryPolicy(awsapi.RetryPolicy{
102+
MaxAttempts: 1,
103+
Sleep: func(context.Context, time.Duration) error { return nil },
104+
}),
105+
)
106+
107+
got, err := client.ListObjectsV2(context.Background(), "demo-bucket", "cn-north-1", "page-2", 100)
108+
if err != nil {
109+
t.Fatalf("ListObjectsV2() error = %v", err)
110+
}
111+
if !got.IsTruncated || got.NextContinuationToken != "page-3" {
112+
t.Fatalf("unexpected pagination: %+v", got)
113+
}
114+
if len(got.Objects) != 1 || got.Objects[0].Key != "logs/a.txt" || got.Objects[0].Size != 12 {
115+
t.Fatalf("unexpected objects: %+v", got)
116+
}
31117
}
32118

33119
func newTestClient(baseURL string) *api.Client {
@@ -42,3 +128,32 @@ func newTestClient(baseURL string) *api.Client {
42128
}),
43129
)
44130
}
131+
132+
func rewriteHostClient(rawURL string) *http.Client {
133+
target, err := url.Parse(rawURL)
134+
if err != nil {
135+
panic(err)
136+
}
137+
return &http.Client{
138+
Transport: &rewriteHostTransport{
139+
target: target,
140+
base: &http.Transport{
141+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
142+
},
143+
},
144+
}
145+
}
146+
147+
type rewriteHostTransport struct {
148+
target *url.URL
149+
base http.RoundTripper
150+
}
151+
152+
func (t *rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
153+
clone := req.Clone(req.Context())
154+
urlCopy := *clone.URL
155+
urlCopy.Scheme = t.target.Scheme
156+
urlCopy.Host = t.target.Host
157+
clone.URL = &urlCopy
158+
return t.base.RoundTrip(clone)
159+
}

0 commit comments

Comments
 (0)