From bfeda73f4aed63ae9a1cea7268b7248689a37878 Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Wed, 5 Sep 2018 14:17:02 +0200 Subject: [PATCH] add CFM quota plugin --- .gitignore | 1 + pkg/plugins/cfm.go | 220 +++++++++++++++++++++++++++++++++++++ pkg/util/datatypes.go | 47 +++++++- pkg/util/datatypes_test.go | 48 ++++++++ 4 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 pkg/plugins/cfm.go create mode 100644 pkg/util/datatypes_test.go diff --git a/.gitignore b/.gitignore index a11ef4c50..59462c213 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ build/cover.html # custom configuration files used during development *.yaml +*.json diff --git a/pkg/plugins/cfm.go b/pkg/plugins/cfm.go new file mode 100644 index 000000000..b2ecd7dfa --- /dev/null +++ b/pkg/plugins/cfm.go @@ -0,0 +1,220 @@ +/******************************************************************************* +* +* Copyright 2018 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You should have received a copy of the License along with this +* program. If not, you may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +*******************************************************************************/ + +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/gophercloud/gophercloud" + "github.com/sapcc/limes/pkg/limes" + "github.com/sapcc/limes/pkg/util" +) + +type cfmPlugin struct { + cfg limes.ServiceConfiguration + + shareserversCache []cfmShareserver + shareserversCacheExpires time.Time +} + +func init() { + limes.RegisterQuotaPlugin(func(c limes.ServiceConfiguration, scrapeSubresources map[string]bool) limes.QuotaPlugin { + return &cfmPlugin{cfg: c} + }) +} + +//Init implements the limes.QuotaPlugin interface. +func (p *cfmPlugin) Init(provider *gophercloud.ProviderClient) error { + return nil +} + +//ServiceInfo implements the limes.QuotaPlugin interface. +func (p *cfmPlugin) ServiceInfo() limes.ServiceInfo { + return limes.ServiceInfo{ + Type: "database", + ProductName: "cfm", + Area: "storage", + } +} + +//Resources implements the limes.QuotaPlugin interface. +func (p *cfmPlugin) Resources() []limes.ResourceInfo { + return []limes.ResourceInfo{{ + Name: "capacity", + Unit: limes.UnitBytes, + //we cannot set quota for this service + ExternallyManaged: true, + }} +} + +//Scrape implements the limes.QuotaPlugin interface. +func (p *cfmPlugin) Scrape(provider *gophercloud.ProviderClient, clusterID, domainUUID, projectUUID string) (map[string]limes.ResourceData, error) { + //cache the result of cfmListShareservers(), it's mildly expensive + now := time.Now() + if p.shareserversCache == nil || p.shareserversCacheExpires.Before(now) { + shareservers, err := cfmListShareservers(provider) + if err != nil { + return nil, err + } + p.shareserversCache = shareservers + p.shareserversCacheExpires = now.Add(5 * time.Minute) + } + shareservers := p.shareserversCache + + result := limes.ResourceData{Quota: 0, Usage: 0} + for _, shareserver := range shareservers { + if shareserver.ProjectUUID != projectUUID { + continue + } + + shareserverDetailed, err := cfmGetShareserver(provider, shareserver.DetailsURL) + if err != nil { + return nil, err + } + + result.Quota += int64(shareserverDetailed.MaximumSizeBytes) + result.Usage += shareserverDetailed.SizeBytes + } + + return map[string]limes.ResourceData{"capacity": result}, nil +} + +//SetQuota implements the limes.QuotaPlugin interface. +func (p *cfmPlugin) SetQuota(provider *gophercloud.ProviderClient, clusterID, domainUUID, projectUUID string, quotas map[string]uint64) error { + return errors.New("the database/capacity resource is externally managed") +} + +//////////////////////////////////////////////////////////////////////////////// + +type cfmShareserver struct { + Type string + ProjectUUID string + DetailsURL string + //fields that are only filled by cfmGetShareserver, not by cfmListShareservers + SizeBytes uint64 + MaximumSizeBytes uint64 +} + +func cfmListShareservers(provider *gophercloud.ProviderClient) ([]cfmShareserver, error) { + baseURL, err := provider.EndpointLocator(gophercloud.EndpointOpts{ + Type: "database", + Name: "cfm", + Availability: gophercloud.AvailabilityPublic, + }) + if err != nil { + return nil, err + } + + url := strings.TrimSuffix(baseURL, "/") + "/v1.0/shareservers/" + var data struct { + Shareservers []struct { + Links []gophercloud.Link `json:"links"` + Type string `json:"type"` + ProjectUUID string `json:"customer_id"` + } `json:"shareservers"` + } + err = cfmDoRequest(provider, url, &data) + if err != nil { + return nil, fmt.Errorf("GET %s failed: %s", url, err.Error()) + } + + result := make([]cfmShareserver, len(data.Shareservers)) + for idx, srv := range data.Shareservers { + result[idx] = cfmShareserver{ + Type: srv.Type, + ProjectUUID: srv.ProjectUUID, + DetailsURL: srv.Links[0].Href, + } + } + return result, nil +} + +func cfmGetShareserver(provider *gophercloud.ProviderClient, url string) (*cfmShareserver, error) { + var data struct { + Shareserver struct { + Properties struct { + SizeBytes util.CFMBytes `json:"size"` + MaximumSizeBytes util.CFMBytes `json:"maximum_size"` + } `json:"properties"` + Links []gophercloud.Link `json:"links"` + Type string `json:"type"` + ProjectUUID string `json:"customer_id"` + } `json:"shareserver"` + } + err := cfmDoRequest(provider, url, &data) + if err != nil { + return nil, fmt.Errorf("GET %s failed: %s", url, err.Error()) + } + + srv := data.Shareserver + return &cfmShareserver{ + Type: srv.Type, + ProjectUUID: srv.ProjectUUID, + DetailsURL: srv.Links[0].Href, + SizeBytes: uint64(srv.Properties.SizeBytes), + MaximumSizeBytes: uint64(srv.Properties.MaximumSizeBytes), + }, nil +} + +func cfmDoRequest(provider *gophercloud.ProviderClient, url string, body interface{}) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + token := provider.Token() + req.Header.Set("Authorization", "Token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + //success case + if resp.StatusCode == http.StatusOK { + return json.NewDecoder(resp.Body).Decode(&body) + } + + //error case: read error message from body + buf, err := ioutil.ReadAll(resp.Body) + if err == nil { + err = errors.New(string(buf)) + } + + //detect when token has expired + // + //NOTE: We don't trust the resp.StatusCode here. The CFM API is known to return + //403 when it means 401. + if strings.Contains(err.Error(), "Invalid credentials") { + err = provider.Reauthenticate(token) + if err == nil { + //restart function call after successful reauth + return cfmDoRequest(provider, url, body) + } + } + + return err +} diff --git a/pkg/util/datatypes.go b/pkg/util/datatypes.go index 8789d6590..eb10fc572 100644 --- a/pkg/util/datatypes.go +++ b/pkg/util/datatypes.go @@ -1,6 +1,6 @@ /******************************************************************************* * -* Copyright 2017 SAP SE +* Copyright 2017-2018 SAP SE * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,8 @@ package util import ( "fmt" + "strconv" + "strings" "time" "encoding/json" @@ -74,3 +76,46 @@ func (s *JSONString) UnmarshalJSON(b []byte) error { *s = JSONString(b) return nil } + +//CFMBytes is a unsigned integer type with custom JSON demarshaling logic to +//read strings like "0 bytes", "734.24 GB" or "1.95 TB" that appear in the CFM +//API. +// +//NOTE: If we ever add base-1000 units to limes.Unit, consider using +//limes.ValueWithUnit instead. +type CFMBytes uint64 + +var cfmUnits = map[string]float64{ + "bytes": 1, + "KB": 1e3, + "MB": 1e6, + "GB": 1e9, + "TB": 1e12, + "PB": 1e15, + "EB": 1e18, +} + +//UnmarshalJSON implements the json.Unmarshaler interface +func (x *CFMBytes) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + fields := strings.SplitN(s, " ", 2) + if len(fields) != 2 { + return fmt.Errorf(`expected a string with the format "{value} {unit}", got %q instead`, s) + } + value, err := strconv.ParseFloat(fields[0], 10) + if err != nil { + return err + } + unitMultiplier, ok := cfmUnits[fields[1]] + if !ok { + return fmt.Errorf("unknown unit %q in value %q", fields[1], s) + } + + *x = CFMBytes(value * unitMultiplier) + return nil +} diff --git a/pkg/util/datatypes_test.go b/pkg/util/datatypes_test.go new file mode 100644 index 000000000..7f8808386 --- /dev/null +++ b/pkg/util/datatypes_test.go @@ -0,0 +1,48 @@ +/******************************************************************************* +* +* Copyright 2018 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You should have received a copy of the License along with this +* program. If not, you may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +*******************************************************************************/ + +package util + +import ( + "encoding/json" + "testing" +) + +func TestCFMBytesDeserialization(t *testing.T) { + testcases := map[string]CFMBytes{ + "5 MB": 5e6, + "1.75 TB": 1.75e12, + "454.53 GB": 454.53e9, + "0 bytes": 0, + } + + for input, expected := range testcases { + jsonBytes, _ := json.Marshal(input) + + var result CFMBytes + err := json.Unmarshal(jsonBytes, &result) + if err != nil { + t.Errorf("unexpected error while demarshaling %q: %s", input, err.Error()) + } + + if result != expected { + t.Errorf("expected %q to demarshal to %d, but got %d", input, expected, result) + } + } +}