-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ft: BKTCLT-21 implement CreateBucket(), GetBucketAttributes()
CreateBucket takes two options: - one to specify the raft session ID (by simply adding the query string param 'raftsession=') - the other to detect if a bucket already exists with the same UID and then succeed silently (making the request idempotent). This is a convenience layer on top of the bucketd API.
- Loading branch information
1 parent
1dd5be1
commit 13f307d
Showing
6 changed files
with
285 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
package bucketclient | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
) | ||
|
||
type createBucketOptionSet struct { | ||
sessionId int | ||
makeIdempotent bool | ||
} | ||
|
||
type CreateBucketOption func(*createBucketOptionSet) | ||
|
||
func CreateBucketSessionIdOption(sessionId int) CreateBucketOption { | ||
return func(options *createBucketOptionSet) { | ||
options.sessionId = sessionId | ||
} | ||
} | ||
|
||
func CreateBucketMakeIdempotent(options *createBucketOptionSet) { | ||
options.makeIdempotent = true | ||
} | ||
|
||
// CreateBucket creates a bucket in metadata. | ||
// bucketAttributes is a JSON blob of bucket attributes | ||
// opts is a set of options: | ||
// | ||
// CreateBucketSessionIdOption forces the session ID where the bucket to be | ||
// created will land | ||
// | ||
// CreateBucketMakeIdempotent makes the request return a success if a bucket | ||
// with the same UID already exists (otherwise returns 409 Conflict, as | ||
// if the option is not passed) | ||
func (client *BucketClient) CreateBucket(ctx context.Context, | ||
bucketName string, bucketAttributes []byte, opts ...CreateBucketOption) error { | ||
parsedOpts := createBucketOptionSet{ | ||
sessionId: 0, | ||
makeIdempotent: false, | ||
} | ||
for _, opt := range opts { | ||
opt(&parsedOpts) | ||
} | ||
resource := fmt.Sprintf("/default/bucket/%s", bucketName) | ||
if parsedOpts.sessionId > 0 { | ||
resource += fmt.Sprintf("?raftsession=%d", parsedOpts.sessionId) | ||
} | ||
requestOptions := []RequestOption{ | ||
RequestBodyOption(bucketAttributes), | ||
RequestBodyContentTypeOption("application/json"), | ||
} | ||
if parsedOpts.makeIdempotent { | ||
// since we will make the request idempotent, it's | ||
// okay to retry it (it may return 409 Conflict at the | ||
// first retry if it initially succeeded, but it will | ||
// then be considered a success) | ||
requestOptions = append(requestOptions, RequestIdempotent) | ||
} | ||
_, err := client.Request(ctx, "CreateBucket", "POST", resource, requestOptions...) | ||
if err == nil { | ||
return nil | ||
} | ||
if parsedOpts.makeIdempotent { | ||
// If the Idempotent option is set, Accept "409 Conflict" as a success iff | ||
// the UIDs match between the existing and the new metadata, to detect and | ||
// return an error if there is an existing bucket that was not created by us | ||
|
||
bcErr := err.(*BucketClientError) | ||
if bcErr.StatusCode != http.StatusConflict { | ||
return err | ||
} | ||
existingBucketAttributes, err := client.GetBucketAttributes(ctx, bucketName) | ||
if err != nil { | ||
return err | ||
} | ||
if bucketAttributeUIDsMatch(bucketAttributes, existingBucketAttributes) { | ||
// return silent success without updating the existing metadata | ||
return nil | ||
} | ||
} | ||
return err | ||
} | ||
|
||
func bucketAttributeUIDsMatch(attributes1 []byte, attributes2 []byte) bool { | ||
var parsedAttr1, parsedAttr2 map[string]interface{} | ||
|
||
err := json.Unmarshal(attributes1, &parsedAttr1) | ||
if err != nil { | ||
return false | ||
} | ||
err = json.Unmarshal(attributes2, &parsedAttr2) | ||
if err != nil { | ||
return false | ||
} | ||
uid1, ok1 := parsedAttr1["uid"] | ||
uid2, ok2 := parsedAttr2["uid"] | ||
if !ok1 || !ok2 { | ||
return false | ||
} | ||
return uid1 == uid2 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
package bucketclient_test | ||
|
||
import ( | ||
"io" | ||
"net/http" | ||
|
||
. "github.com/onsi/ginkgo/v2" | ||
. "github.com/onsi/gomega" | ||
|
||
"github.com/jarcoal/httpmock" | ||
|
||
"github.com/scality/bucketclient/go" | ||
) | ||
|
||
var _ = Describe("CreateBucket()", func() { | ||
It("creates a bucket on an available raft session", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
func(req *http.Request) (*http.Response, error) { | ||
defer req.Body.Close() | ||
Expect(io.ReadAll(req.Body)).To(Equal([]byte(`{"foo":"bar"}`))) | ||
return httpmock.NewStringResponse(200, ""), nil | ||
}, | ||
) | ||
Expect(client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar"}`))).To(Succeed()) | ||
}) | ||
|
||
It("creates a bucket on a chosen raft session", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket?raftsession=12", | ||
func(req *http.Request) (*http.Response, error) { | ||
defer req.Body.Close() | ||
Expect(io.ReadAll(req.Body)).To(Equal([]byte(`{"foo":"bar"}`))) | ||
return httpmock.NewStringResponse(200, ""), nil | ||
}, | ||
) | ||
Expect(client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar"}`), | ||
bucketclient.CreateBucketSessionIdOption(12))).To(Succeed()) | ||
}) | ||
|
||
It("forwards request error", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusInternalServerError, "I'm afraid I can't do this"), | ||
) | ||
err := client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar"}`), | ||
bucketclient.CreateBucketMakeIdempotent) | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(http.StatusInternalServerError)) | ||
}) | ||
|
||
It("returns 409 Conflict without MakeIdempotent option if bucket with same UID exists", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusConflict, ""), | ||
) | ||
// normally unused, but set to match the following tests | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-new-bucket", | ||
httpmock.NewStringResponder(200, `{"foo":"bar","uid":"4242"}`), | ||
) | ||
err := client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar","uid":"4242"}`)) | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(http.StatusConflict)) | ||
}) | ||
|
||
It("succeeds to create bucket with MakeIdempotent option if bucket with same UID exists", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusConflict, ""), | ||
) | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-new-bucket", | ||
httpmock.NewStringResponder(200, `{"foo":"bar","uid":"4242"}`), | ||
) | ||
Expect(client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar","uid":"4242"}`), | ||
bucketclient.CreateBucketMakeIdempotent)).To(Succeed()) | ||
}) | ||
|
||
It("returns 409 Conflict with MakeIdempotent option if bucket with different UID exists", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusConflict, ""), | ||
) | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-new-bucket", | ||
httpmock.NewStringResponder(200, `{"foo":"bar","uid":"OLDUID"}`), | ||
) | ||
err := client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar","uid":"NEWUID"}`), | ||
bucketclient.CreateBucketMakeIdempotent) | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(http.StatusConflict)) | ||
}) | ||
|
||
It("returns 409 Conflict with MakeIdempotent option if bucket exists without an \"uid\" attribute", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusConflict, ""), | ||
) | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-new-bucket", | ||
httpmock.NewStringResponder(200, `{"foo":"bar"}`), | ||
) | ||
err := client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar"}`), | ||
bucketclient.CreateBucketMakeIdempotent) | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(http.StatusConflict)) | ||
}) | ||
|
||
It("returns 409 Conflict with MakeIdempotent option if bucket exists with invalid attributes", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"POST", "/default/bucket/my-new-bucket", | ||
httpmock.NewStringResponder(http.StatusConflict, ""), | ||
) | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-new-bucket", | ||
httpmock.NewStringResponder(200, "NOT-JSON"), | ||
) | ||
err := client.CreateBucket(ctx, "my-new-bucket", []byte(`{"foo":"bar"}`), | ||
bucketclient.CreateBucketMakeIdempotent) | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(http.StatusConflict)) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package bucketclient | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
) | ||
|
||
// GetBucketAttributes retrieves the JSON blob containing the bucket | ||
// attributes attached to a bucket. | ||
func (client *BucketClient) GetBucketAttributes(ctx context.Context, bucketName string) ([]byte, error) { | ||
resource := fmt.Sprintf("/default/attributes/%s", bucketName) | ||
return client.Request(ctx, "GetBucketAttributes", "GET", resource) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package bucketclient_test | ||
|
||
import ( | ||
. "github.com/onsi/ginkgo/v2" | ||
. "github.com/onsi/gomega" | ||
|
||
"github.com/jarcoal/httpmock" | ||
|
||
"github.com/scality/bucketclient/go" | ||
) | ||
|
||
var _ = Describe("GetBucketAttributes()", func() { | ||
It("retrieves the bucket attributes of an existing bucket", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-bucket", | ||
httpmock.NewStringResponder(200, `{"foo":"bar"}`), | ||
) | ||
Expect(client.GetBucketAttributes(ctx, "my-bucket")).To( | ||
Equal([]byte(`{"foo":"bar"}`))) | ||
}) | ||
|
||
It("returns a 404 error if the bucket does not exist", func(ctx SpecContext) { | ||
httpmock.RegisterResponder( | ||
"GET", "/default/attributes/my-bucket", | ||
httpmock.NewStringResponder(404, ""), | ||
) | ||
_, err := client.GetBucketAttributes(ctx, "my-bucket") | ||
Expect(err).To(HaveOccurred()) | ||
bcErr, ok := err.(*bucketclient.BucketClientError) | ||
Expect(ok).To(BeTrue()) | ||
Expect(bcErr.StatusCode).To(Equal(404)) | ||
}) | ||
}) |