rgwadmin: add support for RadosGW Admin Ops API

Following this discussion #492

This commit introduces a new package "rgw/admin" which helps you interact
with the [RadosGW Admin Ops API](https://docs.ceph.com/en/latest/radosgw/adminops).
Not all the API capabilities are covered by this commit, but this is a
solid foundation for adding code on top. I'm expecting a few more
iterations to make 100% complete. Also, the RadosGW Admin API is going
to implement new functions soon (like bucket creation). So this library
will live on and keep catching up.

As many unit tests as possible have been added. A new integration test
suite also runs. The "micro-osd.sh" now deploys a RGW and the
integration suite tests on it. Thus the CI should cover it.

Shout out to @QuentinPerez and @IrekFasikhov for their existing
libraries. They were a very good inspiration to get started.

Co-authored-by: Irek Fasikhov <malmyzh@gmail.com>
Co-authored-by: Quentin Perez <qperez42@gmail.com>
Signed-off-by: Sébastien Han <seb@redhat.com>
This commit is contained in:
Sébastien Han 2021-05-12 14:38:18 +02:00 committed by Sven Anderson
parent edd90d8de4
commit 6be8d370cb
19 changed files with 1070 additions and 2 deletions

View File

@ -11,6 +11,7 @@ There are three main Go sub-packages that make up go-ceph:
* rados - exports functionality from Ceph's librados
* rbd - exports functionality from Ceph's librbd
* cephfs - exports functionality from Ceph's libcephfs
* rgw/admin - interact with [radosgw admin ops API](https://docs.ceph.com/en/latest/radosgw/adminops)
We aim to provide comprehensive support for the Ceph APIs over time. This
includes both I/O related functions and management functions. If your project

1
go.mod
View File

@ -3,6 +3,7 @@ module github.com/ceph/go-ceph
go 1.12
require (
github.com/aws/aws-sdk-go v1.35.24
github.com/gofrs/uuid v3.2.0+incompatible
github.com/stretchr/testify v1.4.0
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3

16
go.sum
View File

@ -1,15 +1,29 @@
github.com/aws/aws-sdk-go v1.35.24 h1:U3GNTg8+7xSM6OAJ8zksiSM4bRqxBWmVwwehvOSNG3A=
github.com/aws/aws-sdk-go v1.35.24/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 h1:5B6i6EAiSYyejWfvc5Rc9BbI3rzIsrrXfAQBWnYfn+w=
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -28,11 +28,15 @@ MON_DATA=${DIR}/mon
MDS_DATA=${DIR}/mds
MOUNTPT=${MDS_DATA}/mnt
OSD_DATA=${DIR}/osd
mkdir ${LOG_DIR} ${MON_DATA} ${OSD_DATA} ${MDS_DATA} ${MOUNTPT}
RGW_DATA=${DIR}/radosgw
mkdir ${LOG_DIR} ${MON_DATA} ${OSD_DATA} ${MDS_DATA} ${MOUNTPT} ${RGW_DATA}
MDS_NAME="Z"
MON_NAME="a"
MGR_NAME="x"
MIRROR_ID="m"
RGW_ID="r"
S3_ACCESS_KEY=2262XNX11FZRR44XWIRD
S3_SECRET_KEY=rmtuS1Uj1bIC08QFYGW18GfSHAbkPqdsuYynNudw
# cluster wide parameters
cat >> ${DIR}/ceph.conf <<EOF
@ -67,6 +71,16 @@ osd journal size = 100
osd objectstore = memstore
osd class load list = *
osd class default list = *
[client.rgw.${RGW_ID}]
rgw dns name = ${HOSTNAME}
rgw enable usage log = true
rgw usage log tick interval = 1
rgw usage log flush threshold = 1
rgw usage max shards = 32
rgw usage max user shards = 1
log file = /var/log/ceph/client.rgw.${RGW_ID}.log
rgw frontends = beast port=80
EOF
export CEPH_CONF=${DIR}/ceph.conf
@ -100,6 +114,12 @@ ceph-mgr --id ${MGR_NAME}
ceph auth get-or-create client.rbd-mirror.${MIRROR_ID} mon 'profile rbd-mirror' osd 'profile rbd'
rbd-mirror --id ${MIRROR_ID} --log-file ${LOG_DIR}/rbd-mirror.log
# start an rgw
ceph auth get-or-create client.rgw."${RGW_ID}" osd 'allow rwx' mon 'allow rw' -o ${RGW_DATA}/keyring
radosgw -n client.rgw."${RGW_ID}" -k ${RGW_DATA}/keyring
timeout 60 sh -c 'until [ $(ceph -s | grep -c "rgw:") -eq 1 ]; do echo "waiting for rgw to show up" && sleep 1; done'
radosgw-admin user create --uid admin --display-name "Admin User" --caps "buckets=*;users=*;usage=read;metadata=read" --access-key="$S3_ACCESS_KEY" --secret-key="$S3_SECRET_KEY"
# test the setup
ceph --version
ceph status

39
rgw/admin/README.md Normal file
View File

@ -0,0 +1,39 @@
# Prerequisites
You must create an admin user like so:
```
radosgw-admin user create --uid admin --display-name "Admin User" --caps "buckets=*;users=*;usage=read;metadata=read;zone=read --access-key=2262XNX11FZRR44XWIRD --secret-key=rmtuS1Uj1bIC08QFYGW18GfSHAbkPqdsuYynNudw
```
Then use the `access_key` and `secret_key` for authentication.
Snippet usage example:
```golang
package main
import (
"github.com/ceph/go-ceph/rgw/admin"
)
func main() {
// Generate a connection object
co, err := admin.New("http://192.168.1.1", "2262XNX11FZRR44XWIRD", "rmtuS1Uj1bIC08QFYGW18GfSHAbkPqdsuYynNudw", nil)
if err != nil {
panic(err)
}
// To enable debug requests
// co.Debug = true
// Get the "admin" user
user, err := co.GetUser(context.Background(), "admin")
if err != nil {
panic(err)
}
// Print the user display name
fmt.Println(user.DisplayName)
}
```

146
rgw/admin/bucket.go Normal file
View File

@ -0,0 +1,146 @@
package admin
import (
"context"
"encoding/json"
"fmt"
)
// Bucket describes an object store bucket
type Bucket struct {
Bucket string `json:"bucket" url:"bucket"`
Zonegroup string `json:"zonegroup"`
PlacementRule string `json:"placement_rule"`
ExplicitPlacement struct {
DataPool string `json:"data_pool"`
DataExtraPool string `json:"data_extra_pool"`
IndexPool string `json:"index_pool"`
} `json:"explicit_placement"`
ID string `json:"id"`
Marker string `json:"marker"`
IndexType string `json:"index_type"`
Owner string `json:"owner"`
Ver string `json:"ver"`
MasterVer string `json:"master_ver"`
Mtime string `json:"mtime"`
MaxMarker string `json:"max_marker"`
Usage struct {
RgwMain struct {
Size *uint64 `json:"size"`
SizeActual *uint64 `json:"size_actual"`
SizeUtilized *uint64 `json:"size_utilized"`
SizeKb *uint64 `json:"size_kb"`
SizeKbActual *uint64 `json:"size_kb_actual"`
SizeKbUtilized *uint64 `json:"size_kb_utilized"`
NumObjects *uint64 `json:"num_objects"`
} `json:"rgw.main"`
RgwMultimeta struct {
Size *uint64 `json:"size"`
SizeActual *uint64 `json:"size_actual"`
SizeUtilized *uint64 `json:"size_utilized"`
SizeKb *uint64 `json:"size_kb"`
SizeKbActual *uint64 `json:"size_kb_actual"`
SizeKbUtilized *uint64 `json:"size_kb_utilized"`
NumObjects *uint64 `json:"num_objects"`
} `json:"rgw.multimeta"`
} `json:"usage"`
BucketQuota struct {
Enabled *bool `json:"enabled"`
CheckOnRaw *bool `json:"check_on_raw"`
MaxSize *uint64 `json:"max_size"`
MaxSizeKb *uint64 `json:"max_size_kb"`
MaxObjects *uint64 `json:"max_objects"`
} `json:"bucket_quota"`
Policy *bool `url:"policy"`
PurgeObject *bool `url:"purge-objects"`
}
// Policy describes a bucket policy
type Policy struct {
ACL struct {
ACLUserMap []struct {
User string `json:"user"`
ACL *int `json:"acl"`
} `json:"acl_user_map"`
ACLGroupMap []interface{} `json:"acl_group_map"`
GrantMap []struct {
ID string `json:"id"`
Grant struct {
Type struct {
Type int `json:"type"`
} `json:"type"`
ID string `json:"id"`
Email string `json:"email"`
Permission struct {
Flags int `json:"flags"`
} `json:"permission"`
Name string `json:"name"`
Group *int `json:"group"`
URLSpec string `json:"url_spec"`
} `json:"grant"`
} `json:"grant_map"`
} `json:"acl"`
Owner struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
} `json:"owner"`
}
// ListBuckets will return the list of all buckets present in the object store
func (api *API) ListBuckets(ctx context.Context) ([]string, error) {
body, err := api.call(ctx, get, "/bucket", nil)
if err != nil {
return nil, err
}
var s []string
err = json.Unmarshal(body, &s)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return s, nil
}
// GetBucketInfo will return various information about a specific token
func (api *API) GetBucketInfo(ctx context.Context, bucket Bucket) (*Bucket, error) {
body, err := api.call(ctx, get, "/bucket", valueToURLParams(bucket))
if err != nil {
return nil, err
}
ref := &Bucket{}
err = json.Unmarshal(body, ref)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return ref, nil
}
// GetBucketPolicy - http://docs.ceph.com/docs/mimic/radosgw/adminops/#get-bucket-or-object-policy
func (api *API) GetBucketPolicy(ctx context.Context, bucket Bucket) (*Policy, error) {
policy := true
bucket.Policy = &policy
body, err := api.call(ctx, get, "/bucket", valueToURLParams(bucket))
if err != nil {
return nil, err
}
ref := &Policy{}
err = json.Unmarshal(body, ref)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return ref, nil
}
// RemoveBucket will remove a given token from the object store
func (api *API) RemoveBucket(ctx context.Context, bucket Bucket) error {
_, err := api.call(ctx, delete, "/bucket", valueToURLParams(bucket))
if err != nil {
return err
}
return nil
}

35
rgw/admin/bucket_test.go Normal file
View File

@ -0,0 +1,35 @@
package admin
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func (suite *RadosGWTestSuite) TestBucket() {
suite.SetupConnection()
co, err := New(suite.endpoint, suite.accessKey, suite.secretKey, nil)
co.Debug = true
assert.NoError(suite.T(), err)
suite.T().Run("list buckets", func(t *testing.T) {
buckets, err := co.ListBuckets(context.Background())
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), 0, len(buckets))
})
suite.T().Run("info non-existing bucket", func(t *testing.T) {
_, err := co.GetBucketInfo(context.Background(), Bucket{Bucket: "foo"})
assert.Error(suite.T(), err)
assert.True(suite.T(), errors.Is(err, ErrNoSuchBucket), err)
})
suite.T().Run("remove non-existing bucket", func(t *testing.T) {
err := co.RemoveBucket(context.Background(), Bucket{Bucket: "foo"})
assert.Error(suite.T(), err)
// TODO: report to rgw team, this should return NoSuchBucket?
assert.True(suite.T(), errors.Is(err, ErrNoSuchKey))
})
}

4
rgw/admin/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package admin contains a set of wrappers around Ceph's RGW Admin Ops API.
*/
package admin

116
rgw/admin/errors.go Normal file
View File

@ -0,0 +1,116 @@
package admin
import (
"encoding/json"
"errors"
"fmt"
)
const (
// ErrUserExists - Attempt to create existing user
ErrUserExists errorReason = "UserAlreadyExists"
// ErrNoSuchUser - Attempt to create existing user
ErrNoSuchUser errorReason = "NoSuchUser"
// ErrInvalidAccessKey - Invalid access key specified
ErrInvalidAccessKey errorReason = "InvalidAccessKey"
// ErrInvalidSecretKey - Invalid secret key specified
ErrInvalidSecretKey errorReason = "InvalidSecretKey"
// ErrInvalidKeyType - Invalid key type specified
ErrInvalidKeyType errorReason = "InvalidKeyType"
// ErrKeyExists - Provided access key exists and belongs to another user
ErrKeyExists errorReason = "KeyExists"
// ErrEmailExists - Provided email address exists
ErrEmailExists errorReason = "EmailExists"
// ErrInvalidCapability - Attempt to remove an invalid admin capability
ErrInvalidCapability errorReason = "InvalidCapability"
// ErrSubuserExists - Specified subuser exists
ErrSubuserExists errorReason = "SubuserExists"
// ErrInvalidAccess - Invalid subuser access specified
ErrInvalidAccess errorReason = "InvalidAccess"
// ErrIndexRepairFailed - Bucket index repair failed
ErrIndexRepairFailed errorReason = "IndexRepairFailed"
// ErrBucketNotEmpty - Attempted to delete non-empty bucket
ErrBucketNotEmpty errorReason = "BucketNotEmpty"
// ErrObjectRemovalFailed - Unable to remove objects
ErrObjectRemovalFailed errorReason = "ObjectRemovalFailed"
// ErrBucketUnlinkFailed - Unable to unlink bucket from specified user
ErrBucketUnlinkFailed errorReason = "BucketUnlinkFailed"
// ErrBucketLinkFailed - Unable to link bucket to specified user
ErrBucketLinkFailed errorReason = "BucketLinkFailed"
// ErrNoSuchObject - Specified object does not exist
ErrNoSuchObject errorReason = "NoSuchObject"
// ErrIncompleteBody - Either bucket was not specified for a bucket policy request or bucket and object were not specified for an object policy request.
ErrIncompleteBody errorReason = "IncompleteBody"
// ErrNoSuchCap - User does not possess specified capability
ErrNoSuchCap errorReason = "NoSuchCap"
// ErrInternalError - Internal server error.
ErrInternalError errorReason = "InternalError"
// ErrAccessDenied - Access denied.
ErrAccessDenied errorReason = "AccessDenied"
// ErrNoSuchBucket - Bucket does not exist.
ErrNoSuchBucket errorReason = "NoSuchBucket"
// ErrNoSuchKey - No such access key.
ErrNoSuchKey errorReason = "NoSuchKey"
// ErrInvalidArgument - Invalid argument.
ErrInvalidArgument errorReason = "InvalidArgument"
// ErrUnknown - reports an unknown error
ErrUnknown errorReason = "Unknown"
unmarshalError = "failed to unmarshal radosgw http response"
)
var (
errMissingUserID = errors.New("missing user ID")
errMissingUserDisplayName = errors.New("missing user display name")
)
// errorReason is the reason of the error
type errorReason string
// statusError is the API response when an error occurs
type statusError struct {
Code string `json:"Code,omitempty"`
RequestID string `json:"RequestId,omitempty"`
HostID string `json:"HostId,omitempty"`
}
func handleStatusError(decodedResponse []byte) error {
statusError := statusError{}
err := json.Unmarshal(decodedResponse, &statusError)
if err != nil {
return fmt.Errorf("%s. %s. %w", unmarshalError, string(decodedResponse), err)
}
return statusError
}
func (e errorReason) Error() string { return string(e) }
// Is determines whether the error is known to be reported
func (e statusError) Is(target error) bool { return target == errorReason(e.Code) }
// Error returns non-empty string if there was an error.
func (e statusError) Error() string { return fmt.Sprintf("%s %s %s", e.Code, e.RequestID, e.HostID) }

18
rgw/admin/errors_test.go Normal file
View File

@ -0,0 +1,18 @@
package admin
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
var (
fakeGetUserError = []byte(`{"Code":"NoSuchUser","RequestId":"tx0000000000000000005a9-00608957a2-10496-my-store","HostId":"10496-my-store-my-store"}`)
)
func TestHandleStatusError(t *testing.T) {
err := handleStatusError(fakeGetUserError)
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrNoSuchUser), err)
}

143
rgw/admin/radosgw.go Normal file
View File

@ -0,0 +1,143 @@
package admin
import (
"bytes"
"context"
"io/ioutil"
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
"errors"
"github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
)
const (
authRegion = "default"
service = "s3"
connectionTimeout = time.Second * 3
post verbHTTP = "POST"
put verbHTTP = "PUT"
get verbHTTP = "GET"
delete verbHTTP = "DELETE"
)
var (
errNoEndpoint = errors.New("endpoint not set")
errNoAccessKey = errors.New("access key not set")
errNoSecretKey = errors.New("secret key not set")
)
type verbHTTP string
// API struct for New Client
type API struct {
AccessKey string
SecretKey string
Endpoint string
HTTPClient *http.Client
Debug bool
}
// New returns client for Ceph RGW
func New(endpoint, accessKey, secretKey string, httpClient *http.Client) (*API, error) {
// validate endpoint
if endpoint == "" {
return nil, errNoEndpoint
}
// validate access key
if accessKey == "" {
return nil, errNoAccessKey
}
// validate secret key
if secretKey == "" {
return nil, errNoSecretKey
}
// set http client, if TLS is desired it will have to be passed with an http client
if httpClient == nil {
httpClient = &http.Client{Timeout: connectionTimeout}
}
return &API{
Endpoint: endpoint,
AccessKey: accessKey,
SecretKey: secretKey,
HTTPClient: httpClient,
Debug: false,
}, nil
}
// call makes request to the RGW Admin Ops API
func (api *API) call(ctx context.Context, verb verbHTTP, path string, args url.Values) (body []byte, err error) {
// Verify the endpoint URL is correct
url, err := url.Parse(buildQueryPath(api.Endpoint, path, args.Encode()))
if err != nil {
return nil, err
}
// Build request
request, err := http.NewRequestWithContext(ctx, string(verb), url.String(), nil)
if err != nil {
return nil, err
}
// Build S3 authentication
cred := credentials.NewStaticCredentials(api.AccessKey, api.SecretKey, "")
signer := v4.NewSigner(cred)
// This was present in https://github.com/IrekFasikhov/go-rgwadmin/ but it seems that the lib works without it
// Let's keep it here just in case something shows up
// signer.DisableRequestBodyOverwrite = true
// Sign in S3
_, err = signer.Sign(request, nil, service, authRegion, time.Now())
if err != nil {
return nil, err
}
// Print request if Debug is enabled
if api.Debug {
dump, err := httputil.DumpRequestOut(request, true)
if err != nil {
return nil, err
}
log.Printf("\n%s\n", string(dump))
}
// Send HTTP request
resp, err := api.HTTPClient.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Print response if Debug is enabled
if api.Debug {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
return nil, err
}
log.Printf("\n%s\n", string(dump))
}
// Decode HTTP response
decodedResponse, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
resp.Body = ioutil.NopCloser(bytes.NewBuffer(decodedResponse))
// Handle error in response
if resp.StatusCode >= 300 {
return nil, handleStatusError(decodedResponse)
}
return decodedResponse, nil
}

61
rgw/admin/radosgw_test.go Normal file
View File

@ -0,0 +1,61 @@
package admin
import (
"os"
"reflect"
"testing"
"github.com/stretchr/testify/suite"
)
type RadosGWTestSuite struct {
suite.Suite
endpoint string
accessKey string
secretKey string
}
func TestRadosGWTestSuite(t *testing.T) {
suite.Run(t, new(RadosGWTestSuite))
}
func (suite *RadosGWTestSuite) SetupConnection() {
suite.accessKey = "2262XNX11FZRR44XWIRD"
suite.secretKey = "rmtuS1Uj1bIC08QFYGW18GfSHAbkPqdsuYynNudw"
hostname := os.Getenv("HOSTNAME")
endpoint := hostname
if hostname != "test_ceph_aio" {
endpoint = "test_ceph_a"
}
suite.endpoint = "http://" + endpoint
}
func TestNew(t *testing.T) {
type args struct {
endpoint string
accessKey string
secretKey string
}
tests := []struct {
name string
args args
want *API
wantErr error
}{
{"no endpoint", args{}, nil, errNoEndpoint},
{"no accessKey", args{endpoint: "http://192.168.0.1"}, nil, errNoAccessKey},
{"no secretKey", args{endpoint: "http://192.168.0.1", accessKey: "foo"}, nil, errNoSecretKey},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := New(tt.args.endpoint, tt.args.accessKey, tt.args.secretKey, nil)
if tt.wantErr != err {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("New() = %v, want %v", got, tt.want)
}
})
}
}

69
rgw/admin/usage.go Normal file
View File

@ -0,0 +1,69 @@
package admin
import (
"context"
"encoding/json"
"fmt"
)
// Usage struct
type Usage struct {
Entries []struct {
User string `json:"user"`
Buckets []struct {
Bucket string `json:"bucket"`
Time string `json:"time"`
Epoch uint64 `json:"epoch"`
Owner string `json:"owner"`
Categories []struct {
Category string `json:"category"`
BytesSent uint64 `json:"bytes_sent"`
BytesReceived uint64 `json:"bytes_received"`
Ops uint64 `json:"ops"`
SuccessfulOps uint64 `json:"successful_ops"`
} `json:"categories"`
} `json:"buckets"`
} `json:"entries"`
Summary []struct {
User string `json:"user"`
Categories []struct {
Category string `json:"category"`
BytesSent uint64 `json:"bytes_sent"`
BytesReceived uint64 `json:"bytes_received"`
Ops uint64 `json:"ops"`
SuccessfulOps uint64 `json:"successful_ops"`
} `json:"categories"`
Total struct {
BytesSent uint64 `json:"bytes_sent"`
BytesReceived uint64 `json:"bytes_received"`
Ops uint64 `json:"ops"`
SuccessfulOps uint64 `json:"successful_ops"`
} `json:"total"`
} `json:"summary"`
Start string `url:"start"` //Example: 2012-09-25 16:00:00
End string `url:"end"`
ShowEntries *bool `url:"show-entries"`
ShowSummary *bool `url:"show-summary"`
RemoveAll *bool `url:"remove-all"` //true
}
// GetUsage request bandwidth usage information on the object store
func (api *API) GetUsage(ctx context.Context, usage Usage) (*Usage, error) {
body, err := api.call(ctx, get, "/usage", valueToURLParams(usage))
if err != nil {
return nil, err
}
u := &Usage{}
err = json.Unmarshal(body, u)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return u, nil
}
// TrimUsage removes bandwidth usage information. With no dates specified, removes all usage information.
func (api *API) TrimUsage(ctx context.Context, usage Usage) error {
_, err := api.call(ctx, delete, "/usage", valueToURLParams(usage))
return err
}

28
rgw/admin/usage_test.go Normal file
View File

@ -0,0 +1,28 @@
package admin
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func (suite *RadosGWTestSuite) TestUsage() {
suite.SetupConnection()
co, err := New(suite.endpoint, suite.accessKey, suite.secretKey, nil)
co.Debug = true
assert.NoError(suite.T(), err)
suite.T().Run("get usage", func(t *testing.T) {
pTrue := true
usage, err := co.GetUsage(context.Background(), Usage{ShowSummary: &pTrue})
assert.NoError(suite.T(), err)
assert.NotEmpty(t, usage)
})
suite.T().Run("trim usage", func(t *testing.T) {
pFalse := false
_, err := co.GetUsage(context.Background(), Usage{RemoveAll: &pFalse})
assert.NoError(suite.T(), err)
})
}

146
rgw/admin/user.go Normal file
View File

@ -0,0 +1,146 @@
package admin
import (
"context"
"encoding/json"
"fmt"
)
// User is GO representation of the json output of a user creation
type User struct {
ID string `json:"user_id" url:"uid"`
DisplayName string `json:"display_name" url:"display-name"`
Email string `json:"email" url:"email"`
Suspended *int `json:"suspended" url:"suspended"`
MaxBuckets *int `json:"max_buckets" url:"max-buckets"`
Subusers []interface{} `json:"subusers"`
Keys []struct {
User string `json:"user"`
AccessKey string `json:"access_key" url:"access-key"`
SecretKey string `json:"secret_key" url:"secret-key"`
} `json:"keys"`
SwiftKeys []interface{} `json:"swift_keys"`
Caps []struct {
Type string `json:"type"`
Perm string `json:"perm"`
} `json:"caps"`
OpMask string `json:"op_mask"`
DefaultPlacement string `json:"default_placement"`
DefaultStorageClass string `json:"default_storage_class"`
PlacementTags []interface{} `json:"placement_tags"`
BucketQuota struct {
Enabled *bool `json:"enabled"`
CheckOnRaw *bool `json:"check_on_raw"`
MaxSize *int `json:"max_size"`
MaxSizeKb *int `json:"max_size_kb"`
MaxObjects *int `json:"max_objects"`
} `json:"bucket_quota"`
UserQuota struct {
Enabled *bool `json:"enabled"`
CheckOnRaw *bool `json:"check_on_raw"`
MaxSize *int `json:"max_size"`
MaxSizeKb *int `json:"max_size_kb"`
MaxObjects *int `json:"max_objects"`
} `json:"user_quota"`
TempURLKeys []interface{} `json:"temp_url_keys"`
Type string `json:"type"`
MfaIds []interface{} `json:"mfa_ids"`
KeyType string `url:"key-type"`
Tenant string `url:"tenant"`
GenerateKey *bool `url:"generate-key"`
PurgeData *int `url:"purge-data"`
}
// GetUser retrieves a given object store user
func (api *API) GetUser(ctx context.Context, user User) (*User, error) {
if user.ID == "" {
return nil, errMissingUserID
}
body, err := api.call(ctx, get, "/user", valueToURLParams(user))
if err != nil {
return nil, err
}
u := &User{}
err = json.Unmarshal(body, u)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return u, nil
}
// GetUsers lists all object store users
func (api *API) GetUsers(ctx context.Context) (*[]string, error) {
body, err := api.call(ctx, get, "/metadata/user", nil)
if err != nil {
return nil, err
}
var users *[]string
err = json.Unmarshal(body, &users)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return users, nil
}
// CreateUser creates a user in the object store
func (api *API) CreateUser(ctx context.Context, user User) (*User, error) {
if user.ID == "" {
return nil, errMissingUserID
}
if user.DisplayName == "" {
return nil, errMissingUserDisplayName
}
// Send request
body, err := api.call(ctx, put, "/user", valueToURLParams(user))
if err != nil {
return nil, err
}
// Unmarshal response into Go type
u := &User{}
err = json.Unmarshal(body, u)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return u, nil
}
// RemoveUser remove an user from the object store
func (api *API) RemoveUser(ctx context.Context, user User) error {
if user.ID == "" {
return errMissingUserID
}
_, err := api.call(ctx, delete, "/user", valueToURLParams(user))
if err != nil {
return err
}
return nil
}
// ModifyUser - http://docs.ceph.com/docs/latest/radosgw/adminops/#modify-user
func (api *API) ModifyUser(ctx context.Context, user User) (*User, error) {
if user.ID == "" {
return nil, errMissingUserID
}
body, err := api.call(ctx, post, "/user", valueToURLParams(user))
if err != nil {
return nil, err
}
u := &User{}
err = json.Unmarshal(body, u)
if err != nil {
return nil, fmt.Errorf("%s. %s. %w", unmarshalError, string(body), err)
}
return u, nil
}

116
rgw/admin/user_test.go Normal file
View File

@ -0,0 +1,116 @@
package admin
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
var (
fakeUserResponse = []byte(`
{
"tenant": "",
"user_id": "dashboard-admin",
"display_name": "dashboard-admin",
"email": "",
"suspended": 0,
"max_buckets": 1000,
"subusers": [],
"keys": [
{
"user": "dashboard-admin",
"access_key": "4WD1FGM5PXKLC97YC0SZ",
"secret_key": "YSaT5bEcJTjBJCDG5yvr2NhGQ9xzoTIg8B1gQHa3"
}
],
"swift_keys": [],
"caps": [],
"op_mask": "read, write, delete",
"system": "true",
"admin": "false",
"default_placement": "",
"default_storage_class": "",
"placement_tags": [],
"bucket_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"user_quota": {
"enabled": false,
"check_on_raw": false,
"max_size": -1,
"max_size_kb": 0,
"max_objects": -1
},
"temp_url_keys": [],
"type": "rgw",
"mfa_ids": []
}`)
)
func TestUnmarshal(t *testing.T) {
u := &User{}
err := json.Unmarshal(fakeUserResponse, &u)
assert.NoError(t, err)
}
func (suite *RadosGWTestSuite) TestUser() {
suite.SetupConnection()
co, err := New(suite.endpoint, suite.accessKey, suite.secretKey, nil)
co.Debug = true
assert.NoError(suite.T(), err)
suite.T().Run("fail to create user since no UID provided", func(t *testing.T) {
_, err = co.CreateUser(context.Background(), User{Email: "leseb@example.com"})
assert.Error(suite.T(), err)
assert.EqualError(suite.T(), err, errMissingUserID.Error())
})
suite.T().Run("fail to create user since no no display name provided", func(t *testing.T) {
_, err = co.CreateUser(context.Background(), User{ID: "leseb", Email: "leseb@example.com"})
assert.Error(suite.T(), err)
assert.EqualError(suite.T(), err, errMissingUserDisplayName.Error())
})
suite.T().Run("user creation success", func(t *testing.T) {
user, err := co.CreateUser(context.Background(), User{ID: "leseb", DisplayName: "This is leseb", Email: "leseb@example.com"})
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "leseb@example.com", user.Email)
})
suite.T().Run("get user leseb", func(t *testing.T) {
user, err := co.GetUser(context.Background(), User{ID: "leseb"})
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "leseb@example.com", user.Email)
})
suite.T().Run("modify user email", func(t *testing.T) {
user, err := co.ModifyUser(context.Background(), User{ID: "leseb", Email: "leseb@leseb.com"})
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "leseb@leseb.com", user.Email)
})
suite.T().Run("user already exists", func(t *testing.T) {
_, err := co.CreateUser(context.Background(), User{ID: "admin", DisplayName: "Admin user"})
assert.Error(suite.T(), err)
assert.True(suite.T(), errors.Is(err, ErrUserExists), fmt.Sprintf("%+v", err))
})
suite.T().Run("get users", func(t *testing.T) {
users, err := co.GetUsers(context.Background())
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), 2, len(*users))
})
suite.T().Run("remove user", func(t *testing.T) {
err = co.RemoveUser(context.Background(), User{ID: "leseb"})
assert.NoError(suite.T(), err)
})
}

65
rgw/admin/utils.go Normal file
View File

@ -0,0 +1,65 @@
package admin
import (
"fmt"
"net/url"
"reflect"
"strings"
)
const (
queryAdminPath = "/admin"
)
func buildQueryPath(endpoint, path, args string) string {
return fmt.Sprintf("%s%s%s?%s", endpoint, queryAdminPath, path, args)
}
// valueToURLParams encodes structs into URL query parameters.
func valueToURLParams(i interface{}) url.Values {
values := url.Values{}
// Always return json
values.Add("format", "json")
getReflect(i, &values)
return values
}
func getReflect(i interface{}, values *url.Values) {
t := reflect.TypeOf(i)
v := reflect.ValueOf(i)
for b := 0; b < v.NumField(); b++ {
v2 := v.Field(b)
name := t.Field(b).Tag.Get("url")
for _, name := range strings.Split(name, ",") {
if v2.Kind() == reflect.Struct {
getReflect(v2.Interface(), values)
}
if v2.Kind() == reflect.Slice {
for i := 0; i < v2.Len(); i++ {
item := v2.Index(i)
getReflect(item.Interface(), values)
}
}
if v2.Kind() == reflect.String ||
v2.Kind() == reflect.Bool ||
v2.Kind() == reflect.Int {
_v2 := fmt.Sprint(v2)
if len(_v2) > 0 && len(name) > 0 {
values.Add(name, _v2)
}
}
if v2.Kind() == reflect.Ptr && v2.IsValid() && !v2.IsNil() {
_v2 := fmt.Sprint(v2.Elem())
values.Add(name, _v2)
}
}
}
}

42
rgw/admin/utils_test.go Normal file
View File

@ -0,0 +1,42 @@
package admin
import (
"net/url"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func getDefaultValue() url.Values {
values := url.Values{}
values.Add("format", "json")
return values
}
func TestBuildQueryPath(t *testing.T) {
queryPath := buildQueryPath("http://192.168.0.1", "/user", getDefaultValue().Encode())
assert.Equal(t, "http://192.168.0.1/admin/user?format=json", queryPath)
}
func TestValueToURLParams(t *testing.T) {
type args struct {
i interface{}
}
tests := []struct {
name string
args args
want string
}{
{"default", args{User{ID: "leseb"}}, "format=json&uid=leseb"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := valueToURLParams(tt.args.i)
if !reflect.DeepEqual(got.Encode(), tt.want) {
t.Errorf("valueToURLParams() = %v, want %v", got.Encode(), tt.want)
}
})
}
}

4
rgw/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package rgw contains sub-packages pertaining to the administration of RGW. It currently contains no functionally.
*/
package rgw