diff --git a/dsdk.test b/dsdk.test new file mode 100755 index 0000000..e1082c5 Binary files /dev/null and b/dsdk.test differ diff --git a/go.mod b/go.mod index 60539d3..56220ad 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,18 @@ -module github.com/Datera/go-sdk +module github.com/tjcelaya/go-datera go 1.13 require ( github.com/Datera/go-udc v1.1.1 github.com/google/go-cmp v0.4.1 - github.com/google/go-querystring v1.0.0 // indirect github.com/google/uuid v1.1.1 github.com/levigross/grequests v0.0.0-20190908174114-253788527a1a github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.3.1 + github.com/pkg/errors v0.9.1 // indirect github.com/sirupsen/logrus v1.6.0 - github.com/stretchr/objx v0.2.0 // indirect + github.com/stretchr/testify v1.3.0 // indirect golang.org/x/net v0.0.0-20200602114024-627f9648deb9 // indirect gopkg.in/h2non/gock.v1 v1.0.15 + gotest.tools v2.2.0+incompatible ) diff --git a/go.sum b/go.sum index a054a0d..0070c10 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ github.com/Datera/go-udc v1.1.1 h1:DWWnp+0PpD30Lqez7oS4wA60g9J71/RCwfSfiCPA0OA= github.com/Datera/go-udc v1.1.1/go.mod h1:Q3d9dF5+bUj/OaTaljAApZTpwVf4nmrpGXAJIN9NC0M= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -22,14 +23,17 @@ github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5 github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +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/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -46,3 +50,5 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IV golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/pkg/dsdk/connection.go b/pkg/dsdk/connection.go index 9268ffe..f9d3f3e 100644 --- a/pkg/dsdk/connection.go +++ b/pkg/dsdk/connection.go @@ -39,6 +39,12 @@ var ( logTraceID = "trace_id" ) +const ( + canRetry = true + isSensitive = true + allowLogin = true +) + type ApiConnection struct { m *sync.RWMutex username string @@ -225,9 +231,9 @@ func makeBaseUrl(h, apiv string, secure bool) (*url.URL, error) { return url.Parse(fmt.Sprintf("http://%s:7717/v%s", h, apiv)) } -func translateErrors(resp *greq.Response, err error) (*ApiErrorResponse, error) { +func translateErrors(ctxt context.Context, resp *greq.Response, err error) (*ApiErrorResponse, error) { if err != nil { - Log().Error(err) + WithUserFields(ctxt, Log()).Error(err) if strings.Contains(err.Error(), "connect: connection refused") { return nil, badStatus[ConnectionError] } @@ -238,7 +244,7 @@ func translateErrors(resp *greq.Response, err error) (*ApiErrorResponse, error) eresp := &ApiErrorResponse{} err := resp.JSON(eresp) if err != nil { - eresp.Message = fmt.Sprintf("failed to unmarshal ApiErrorResponse: %v", err) + WithUserFields(ctxt, Log()).Error(fmt.Sprintf("failed to unmarshal ApiErrorResponse %+v: %v", eresp, err)) } // in some cases (like 503s) the response JSON doesn't contain @@ -259,12 +265,13 @@ func (c *ApiConnection) hasLoggedIn() bool { return c.apikey != "" } -func (c *ApiConnection) retry(ctxt context.Context, method, url string, ro *greq.RequestOptions, rs interface{}, sensitive bool) (*ApiErrorResponse, error) { +func (c *ApiConnection) retry(ctxt context.Context, method, url string, ro *greq.RequestOptions, rs interface{}, sensitive, allowLogin bool) (*ApiErrorResponse, error) { t1 := time.Now().Unix() backoff := 1 var apiresp *ApiErrorResponse for time.Now().Unix()-t1 < RetryTimeout { - apiresp, err := c.do(ctxt, method, url, ro, rs, false, sensitive) + // any call to `do` from within a retry must use `false` for retry param + apiresp, err := c.do(ctxt, method, url, ro, rs, !canRetry, sensitive, allowLogin) if apiresp == nil && err == nil { return nil, nil } @@ -282,13 +289,13 @@ func (c *ApiConnection) retry(ctxt context.Context, method, url string, ro *greq return apiresp, ErrRetryTimeout } -func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.RequestOptions, rs interface{}, retry, sensitive bool) (*ApiErrorResponse, error) { +func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.RequestOptions, rs interface{}, retry, sensitive, allowLogin bool) (*ApiErrorResponse, error) { gurl := *c.baseUrl gurl.Path = path.Join(gurl.Path, url) reqId := uuid.Must(uuid.NewRandom()).String() sdata, err := json.Marshal(ro.JSON) if err != nil { - Log().Errorf("Couldn't stringify data, %s", ro.JSON) + WithUserFields(ctxt, Log()).Errorf("Couldn't stringify data, %s", ro.JSON) } // Strip all CHAP credentails before printing to logs if strings.Contains(string(sdata), "target_user_name") == true { @@ -324,14 +331,15 @@ func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.Re ro.BeforeRequest = func(h *http.Request) error { sheaders, err := json.Marshal(h.Header) if err != nil { - Log().Errorf("Couldn't stringify headers, %s", h.Header) + WithUserFields(ctxt, Log()).Errorf("Couldn't stringify headers, %s", h.Header) } - Log().WithFields(log.Fields{ + WithUserFields(ctxt, Log()).WithFields(log.Fields{ logTraceID: tid, "request_id": reqId, "request_method": method, "request_url": gurl.String(), + "request_route": canonicalizeRoute(gurl.Path, c.apiVersion), "request_headers": sheaders, "request_payload": string(sdata), "query_params": ro.Params, @@ -350,25 +358,30 @@ func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.Re if _, ok := ctxt.Value("quiet").(bool); ok { rdata = "" } - detailLog := Log().WithFields(log.Fields{ + detailLog := WithUserFields(ctxt, Log()).WithFields(log.Fields{ logTraceID: tid, "request_id": reqId, "response_timedelta": tDelta.Seconds(), "request_method": method, "request_url": gurl.String(), "request_payload": string(sdata), + "request_route": canonicalizeRoute(gurl.Path, c.apiVersion), "response_payload": rdata, "response_code": resp.StatusCode, }) detailLog.Debugf("Datera SDK response received") - eresp, err := translateErrors(resp, err) + eresp, err := translateErrors(ctxt, resp, err) if err == badStatus[PermissionDenied] { // if we have logged in successfully before we may just need to refresh the apikey // and retry the original request - if c.hasLoggedIn() { + // However, because Login holds the mutex then if we got here as the result of a 401 during + // a Login we can't do anything without deadlocking. In this case we need to just return + // the error + + if allowLogin && c.hasLoggedIn() { c.Logout() if apiresp, err2 := c.Login(ctxt); apiresp != nil || err2 != nil { detailLog.Errorf("failed to re-authenticate before retrying request: %s", err2) @@ -377,7 +390,7 @@ func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.Re c.m.RLock() ro.Headers["Auth-Token"] = c.apikey c.m.RUnlock() - return c.do(ctxt, method, url, ro, rs, false, sensitive) + return c.do(ctxt, method, url, ro, rs, !canRetry, sensitive, allowLogin) } // but if we get here while logged out then then credentials may no longer be valid and we shouldn't @@ -386,7 +399,7 @@ func (c *ApiConnection) do(ctxt context.Context, method, url string, ro *greq.Re } if retry && (err == badStatus[Retry503] || err == badStatus[ConnectionError]) { - return c.retry(ctxt, method, url, ro, rs, sensitive) + return c.retry(ctxt, method, url, ro, rs, sensitive, allowLogin) } if eresp != nil { detailLog.Errorf("Received API Error %s", Pretty(eresp)) @@ -408,16 +421,18 @@ func (c *ApiConnection) doWithAuth(ctxt context.Context, method, url string, ro if ro == nil { ro = &greq.RequestOptions{} } + // don't need to check the loggingIn flag first because doWithAuth is not called from Login + // so that won't deadlock if !c.hasLoggedIn() { if apierr, err := c.Login(ctxt); apierr != nil || err != nil { - Log().Errorf("Login failure: %s, %s", Pretty(apierr), err) + WithUserFields(ctxt, Log()).Errorf("Login failure: %s, %s", Pretty(apierr), err) return apierr, err } } c.m.RLock() ro.Headers = map[string]string{"tenant": c.tenant, "Auth-Token": c.apikey} c.m.RUnlock() - return c.do(ctxt, method, url, ro, rs, true, false) + return c.do(ctxt, method, url, ro, rs, canRetry, !isSensitive, allowLogin) } func NewApiConnection(c *udc.UDC, secure bool) *ApiConnection { @@ -521,6 +536,18 @@ func (c *ApiConnection) ApiVersions() []string { } func (c *ApiConnection) Login(ctxt context.Context) (*ApiErrorResponse, error) { + c.m.Lock() + defer c.m.Unlock() + + // can't call hasLoggedIn since that needs to RLock but this is equivalent + if c.apikey != "" { + // any time the connection has an apikey we can skip the login because + // the apikey gets cleared after a session expiration before attempting to login + // therefore a non-empty apikey can be assumed to be valid + + return nil, nil + } + login := &ApiLogin{} ro := &greq.RequestOptions{ Data: map[string]string{ @@ -531,15 +558,15 @@ func (c *ApiConnection) Login(ctxt context.Context) (*ApiErrorResponse, error) { if c.ldap != "" { ro.Data["remote_server"] = c.ldap } - apiresp, err := c.do(ctxt, "PUT", "login", ro, login, true, true) - c.m.Lock() + + apiresp, err := c.do(ctxt, "PUT", "login", ro, login, canRetry, isSensitive, !allowLogin) + if (apiresp != nil && apiresp.Http == PermissionDenied) || (err != nil && err == badStatus[PermissionDenied]) { c.apikey = "" } else { c.apikey = login.Key } - c.m.Unlock() return apiresp, err } diff --git a/pkg/dsdk/placement_policy.go b/pkg/dsdk/placement_policy.go index f66b804..fcac210 100644 --- a/pkg/dsdk/placement_policy.go +++ b/pkg/dsdk/placement_policy.go @@ -1,13 +1,21 @@ package dsdk import ( + "context" "encoding/json" + _path "path" + + greq "github.com/levigross/grequests" ) type PlacementPolicy struct { - Path string `json:"path,omitempty" mapstructure:"path"` - ResolvedPath string `json:"resolved_path,omitempty" mapstructure:"resolved_path"` - ResolvedTenant string `json:"resolved_tenant,omitempty" mapstructure:"resolved_tenant"` + Path string `json:"path,omitempty" mapstructure:"path"` + ResolvedPath string `json:"resolved_path,omitempty" mapstructure:"resolved_path"` + ResolvedTenant string `json:"resolved_tenant,omitempty" mapstructure:"resolved_tenant"` + Name string `json:"name,omitempty" mapstructure:"name"` + Descr string `json:"descr,omitempty" mapstructure:"descr"` + Max []string `json:"max,omitempty" mapstructure:"max"` + Min []string `json:"min,omitempty" mapstructure:"min"` } func (p PlacementPolicy) MarshalJSON() ([]byte, error) { @@ -36,3 +44,149 @@ func (p PlacementPolicy) UnmarshalJSON(b []byte) error { } return nil } + +type PlacementPolicies struct { + Path string +} + +type PlacementPoliciesCreateRequest struct { + Ctxt context.Context `json:"-"` + Name string `json:"name,omitempty" mapstructure:"name"` + Descr string `json:"descr,omitempty" mapstructure:"descr"` + Max []string `json:"max,omitempty" mapstructure:"max"` + Min []string `json:"min,omitempty" mapstructure:"min"` +} + +func newPlacementPolicies(path string) *PlacementPolicies { + return &PlacementPolicies{ + Path: _path.Join(path, "placement_policies"), + } +} + +func (e *PlacementPolicies) Create(ro *PlacementPoliciesCreateRequest) (*PlacementPolicy, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ro} + rs, apierr, err := GetConn(ro.Ctxt).Post(ro.Ctxt, e.Path, gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &PlacementPolicy{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + +type PlacementPoliciesListRequest struct { + Ctxt context.Context `json:"-"` + Params ListParams `json:"params,omitempty"` +} + +func (e *PlacementPolicies) List(ro *PlacementPoliciesListRequest) ([]*PlacementPolicy, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{ + JSON: ro, + Params: ro.Params.ToMap()} + rs, apierr, err := GetConn(ro.Ctxt).GetList(ro.Ctxt, e.Path, gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := []*PlacementPolicy{} + for _, data := range rs.Data { + elem := &PlacementPolicy{} + adata := data.(map[string]interface{}) + if err = FillStruct(adata, elem); err != nil { + return nil, nil, err + } + resp = append(resp, elem) + } + return resp, nil, nil +} + +type PlacementPoliciesGetRequest struct { + Ctxt context.Context `json:"-"` + Name string `json:"name" mapstructure:"name"` +} + +func (e *PlacementPolicies) Get(ro *PlacementPoliciesGetRequest) (*PlacementPolicy, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ro} + rs, apierr, err := GetConn(ro.Ctxt).Get(ro.Ctxt, _path.Join(e.Path, ro.Name), gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &PlacementPolicy{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + +type PlacementPolicySetRequest struct { + Ctxt context.Context `json:"-"` + Name string `json:"name,omitempty" mapstructure:"name"` + Descr string `json:"descr,omitempty" mapstructure:"descr"` + Max []string `json:"max,omitempty" mapstructure:"max"` + Min []string `json:"min,omitempty" mapstructure:"min"` +} + +func (e *PlacementPolicy) Set(ro *PlacementPolicySetRequest) (*PlacementPolicy, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ro} + rs, apierr, err := GetConn(ro.Ctxt).Put(ro.Ctxt, e.Path, gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &PlacementPolicy{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + +type PlacementPolicyDeleteRequest struct { + Ctxt context.Context `json:"-"` +} + +func (e *PlacementPolicy) Delete(ro *PlacementPolicyDeleteRequest) (*PlacementPolicy, *ApiErrorResponse, error) { + rs, apierr, err := GetConn(ro.Ctxt).Delete(ro.Ctxt, e.Path, nil) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &PlacementPolicy{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + +type PlacementPolicyReloadRequest struct { + Ctxt context.Context `json:"-"` +} + +func (e *PlacementPolicy) Reload(ro *PlacementPolicyReloadRequest) (*PlacementPolicy, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ro} + rs, apierr, err := GetConn(ro.Ctxt).Get(ro.Ctxt, e.Path, gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &PlacementPolicy{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} diff --git a/pkg/dsdk/remote_provider.go b/pkg/dsdk/remote_provider.go index 4268519..5c8185d 100644 --- a/pkg/dsdk/remote_provider.go +++ b/pkg/dsdk/remote_provider.go @@ -31,7 +31,7 @@ type RemoteProvider struct { // Present only when the RemoteProvider is a subresource of a snapshot. Indicates the replication state of the // snapshot on this RemoteProvider. - OpState string + OpStatus string `json:"op_status,omitempty" mapstructure:"op_status"` } func RegisterRemoteProviderEndpoints(rp *RemoteProvider) { diff --git a/pkg/dsdk/sdk.go b/pkg/dsdk/sdk.go index 81db9a4..e4e935f 100644 --- a/pkg/dsdk/sdk.go +++ b/pkg/dsdk/sdk.go @@ -33,12 +33,14 @@ type SDK struct { LogsUpload *LogsUpload HWMetrics *HWMetrics IOMetrics *IOMetrics + PlacementPolicies *PlacementPolicies RemoteProvider *RemoteProviders StorageNodes *StorageNodes StoragePools *StoragePools System *System SystemEvents *SystemEvents Tenants *Tenants + UserData *UserDatas } func NewSDK(c *udc.UDC, secure bool) (*SDK, error) { @@ -66,12 +68,14 @@ func NewSDKWithHTTPClient(c *udc.UDC, secure bool, client *http.Client) (*SDK, e LogsUpload: newLogsUpload("/"), HWMetrics: newHWMetrics("/"), IOMetrics: newIOMetrics("/"), + PlacementPolicies: newPlacementPolicies("/"), RemoteProvider: newRemoteProviders("/"), StorageNodes: newStorageNodes("/"), StoragePools: newStoragePools("/"), System: newSystem("/"), SystemEvents: newSystemEvents("/"), Tenants: newTenants("/"), + UserData: newUserDatas("/"), }, nil } diff --git a/pkg/dsdk/snapshots.go b/pkg/dsdk/snapshots.go index e4c4a63..6f7451f 100644 --- a/pkg/dsdk/snapshots.go +++ b/pkg/dsdk/snapshots.go @@ -23,6 +23,8 @@ type Snapshot struct { AppStructure interface{} `json:"app_structure,omitempty" mapstructure:"app_structure"` TsVersion string `json:"ts_version,omitempty" mapstructure:"ts_version"` Version string `json:"version,omitempty" mapstructure:"version"` + Type string `json:"type,omitempty" mapstructure:"type"` + ClusterId string `json:"cluster_id,omitempty" mapstructure:"cluster_id"` } type Snapshots struct { @@ -107,6 +109,28 @@ func (e *Snapshots) Get(ro *SnapshotsGetRequest) (*Snapshot, *ApiErrorResponse, return resp, nil, nil } +type SnapshotSetRequest struct { + Ctxt context.Context `json:"-"` + DeleteLocal bool `json:"delete_local" mapstructure:"delete_local"` + RemoteProviderUuid string `json:"remote_provider_uuid" mapstructure:"remote_provider_uuid"` +} + +func (e *Snapshot) Set(ro *SnapshotSetRequest) (*Snapshot, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ro} + rs, apierr, err := GetConn(ro.Ctxt).Put(ro.Ctxt, e.Path, gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &Snapshot{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + type SnapshotDeleteRequest struct { Ctxt context.Context `json:"-"` RemoteProviderUuid string `json:"remote_provider_uuid,omitempty" mapstructure:"remote_provider_uuid"` diff --git a/pkg/dsdk/user_data.go b/pkg/dsdk/user_data.go new file mode 100644 index 0000000..611e30a --- /dev/null +++ b/pkg/dsdk/user_data.go @@ -0,0 +1,100 @@ +package dsdk + +import ( + "context" + greq "github.com/levigross/grequests" + _path "path" +) + +type UserData struct { + AppInstanceId string `json:"app_instance_id"` + Data map[string]interface{} `json:"data"` +} + +type UserDatas struct { + Path string +} + +func newUserDatas(path string) *UserDatas { + return &UserDatas{ + Path: _path.Join(path, "user_data"), + } +} + +type UserDataSetRequest struct { + Ctxt context.Context `json:"-"` + AppInstanceId string `json:"app_instance_id" mapstructure:"app_instance_id"` + Data map[string]interface{} `json:"data" mapstructure:"data"` +} + +// Set adds a JSON User Data Record to an App Instance +func (e *UserDatas) Set(ud *UserDataSetRequest) (*UserData, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ud} + rs, apierr, err := GetConn(ud.Ctxt).Put(ud.Ctxt, _path.Join("app_instances", ud.AppInstanceId, e.Path), gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := &UserData{ + AppInstanceId: ud.AppInstanceId, + } + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} + +// UserDatasListRequest lists all custom user data on all apps within a tenant +// Params is the normal ListParams, but Sort isn't used/supported. +type UserDatasListRequest struct { + Ctxt context.Context `json:"-"` + Params ListParams `json:"params,omitempty"` +} + +// List shows all UserData that have been stored +// it can be filtered via a Glob search in ro.Filter field +func (e *UserDatas) List(udlr *UserDatasListRequest) ([]*UserData, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{ + JSON: udlr, + Params: udlr.Params.ToMap()} + rs, apierr, err := GetConn(udlr.Ctxt).GetList(udlr.Ctxt, "app_instance_user_data", gro) + if apierr != nil { + return nil, apierr, err + } + if err != nil { + return nil, nil, err + } + resp := []*UserData{} + for _, data := range rs.Data { + elem := &UserData{} + adata := data.(map[string]interface{}) + if err = FillStruct(adata, elem); err != nil { + return nil, nil, err + } + resp = append(resp, elem) + } + return resp, nil, nil +} + +// UserDataGetRequest gets one AppInstance's uploaded user data +type UserDataGetRequest struct { + Ctxt context.Context `json:"-"` + AppInstanceId string `json:"app_instance_id"` +} + +// Get returns an individual JSON UserData object attached to an AppInstance +func (e *UserDatas) Get(ud *UserDataGetRequest) (*UserData, *ApiErrorResponse, error) { + gro := &greq.RequestOptions{JSON: ud} + rs, apierr, err := GetConn(ud.Ctxt).Get(ud.Ctxt, _path.Join("app_instances", e.Path, ud.AppInstanceId), gro) + if apierr != nil || err != nil { + return nil, apierr, err + } + + resp := &UserData{} + if err = FillStruct(rs.Data, resp); err != nil { + return nil, nil, err + } + return resp, nil, nil +} diff --git a/pkg/dsdk/util.go b/pkg/dsdk/util.go index 91fa074..0441cd4 100644 --- a/pkg/dsdk/util.go +++ b/pkg/dsdk/util.go @@ -8,6 +8,7 @@ import ( "math/rand" "os/exec" "reflect" + "regexp" "runtime" "strings" "sync" @@ -19,23 +20,48 @@ import ( log "github.com/sirupsen/logrus" ) +type ContextKey string + // No vowels so no accidental profanity :P const letterBytes = "bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ" const ( letterIdxBits = 6 // 6 bits to represent a letter index letterIdxMask = 1< 401 -> (503 -> 200 on login) -> 503 -> 200", + setup: func() { + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(200). + JSON(&dsdk.ApiLogin{Key: "thekey"}) + + // mock 503 followed by 401 then relogin and success + gock.New("http://127.0.0.1:7717"). + Get("/v1/system"). + Reply(503). + // On 503 errors the api may not return all fields of the ApiErrorResponse + JSON(&dsdk.ApiErrorResponse{Message: "overloaded"}) + + gock.New("http://127.0.0.1:7717"). + Get("/v1/system"). + Reply(dsdk.PermissionDenied). + JSON(apiErr401) + + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(503). + JSON(&dsdk.ApiErrorResponse{Message: "overloaded"}) + + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(200). + JSON(&dsdk.ApiLogin{Key: "thekey"}) + + gock.New("http://127.0.0.1:7717"). + Get("/v1/system"). + Reply(503). + JSON(&dsdk.ApiErrorResponse{Message: "overloaded"}) + + gock.New("http://127.0.0.1:7717"). + Get("/v1/system"). + Reply(200). + JSON(testApiResponse) + }, + expected: expected{ + Data: testSystem, + }, + }, { desc: "retries stop after a time limit", setup: func() { @@ -379,6 +431,31 @@ func TestRetryScenarios(t *testing.T) { ApiErr: &dsdk.ApiErrorResponse{Message: "invalid", Http: 400}, }, }, + { + desc: "401 during a relogin does not continue to retry login", + setup: func() { + // initial login succeeds + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(200). + JSON(&dsdk.ApiLogin{Key: "thekey"}) + + // get a 401 to force a re-login + gock.New("http://127.0.0.1:7717"). + Get("/v1/system"). + Reply(dsdk.PermissionDenied). + JSON(apiErr401) + + // re-login gets a 401 + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(dsdk.PermissionDenied). + JSON(apiErr401) + }, + expected: expected{ + ApiErr: apiErr401, + }, + }, } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { @@ -491,3 +568,51 @@ func TestConcurrentUsage(t *testing.T) { gock.OffAll() } + +// validates that concurrent login attempts will only actually log in once +func TestConcurrentLoginAttempts(t *testing.T) { + defer gock.OffAll() + + // set up mocks so that only a single login attempt will succeed and the rest will fail + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Reply(200). + JSON(&dsdk.ApiLogin{Key: "thekey"}) + gock.New("http://127.0.0.1:7717"). + Put("/v1/login"). + Persist(). + Reply(500). + JSON(&dsdk.ApiErrorResponse{Message: "goofed"}) + + conn := dsdk.NewApiConnection(&udc.UDC{ + MgmtIp: "127.0.0.1", + Username: "foo", + Password: "bar", + ApiVersion: "1", + }, false) + + wg := sync.WaitGroup{} + wg.Add(2) + var aer1 *dsdk.ApiErrorResponse + var err1 error + var aer2 *dsdk.ApiErrorResponse + var err2 error + go func() { + aer1, err1 = conn.Login(context.Background()) + wg.Done() + }() + + go func() { + aer2, err2 = conn.Login(context.Background()) + wg.Done() + }() + + wg.Wait() + + // if neither login attempt returned an error we can know that the login route was only called once + assert.NilError(t, err1) + assert.NilError(t, err2) + assert.Assert(t, aer1 == nil) + assert.Assert(t, aer2 == nil) + +}