|
|
@@ -15,6 +15,10 @@ import (
|
|
|
|
|
|
const timeFmt = "2006-01-02T15:04:05-0700"
|
|
|
|
|
|
+// UndefinedKey is used in composing Asset group keys if the group does not have that property defined.
|
|
|
+// E.g. if aggregating on Cluster, Assets in the AssetSet where Asset has no cluster will be grouped under key "__undefined__"
|
|
|
+const UndefinedKey = "__undefined__"
|
|
|
+
|
|
|
// Asset defines an entity within a cluster that has a defined cost over a
|
|
|
// given period of time.
|
|
|
type Asset interface {
|
|
|
@@ -57,51 +61,73 @@ type Asset interface {
|
|
|
}
|
|
|
|
|
|
// key is used to determine uniqueness of an Asset, for instance during Insert
|
|
|
-// to determine if two Assets should be combined. Passing nil props indicates
|
|
|
-// that all available props should be used. Passing empty props indicates that
|
|
|
-// no props should be used (e.g. to aggregate all assets). Passing one or more
|
|
|
-// props will key by only those props.
|
|
|
-func key(a Asset, props []AssetProperty) string {
|
|
|
+// to determine if two Assets should be combined. Passing `nil` `aggregateBy` indicates
|
|
|
+// that all available `AssetProperty` keys should be used. Passing empty `aggregateBy` indicates that
|
|
|
+// no key should be used (e.g. to aggregate all assets). Passing one or more `aggregateBy`
|
|
|
+// values will key by only those values.
|
|
|
+// Valid values of `aggregateBy` elements are strings which are an `AssetProperty`, and strings prefixed
|
|
|
+// with `"label:"`.
|
|
|
+func key(a Asset, aggregateBy []string) (string, error) {
|
|
|
keys := []string{}
|
|
|
|
|
|
- if props == nil {
|
|
|
- props = []AssetProperty{
|
|
|
- AssetProviderProp,
|
|
|
- AssetAccountProp,
|
|
|
- AssetProjectProp,
|
|
|
- AssetCategoryProp,
|
|
|
- AssetClusterProp,
|
|
|
- AssetTypeProp,
|
|
|
- AssetServiceProp,
|
|
|
- AssetProviderIDProp,
|
|
|
- AssetNameProp,
|
|
|
+ if aggregateBy == nil {
|
|
|
+ aggregateBy = []string{
|
|
|
+ string(AssetProviderProp),
|
|
|
+ string(AssetAccountProp),
|
|
|
+ string(AssetProjectProp),
|
|
|
+ string(AssetCategoryProp),
|
|
|
+ string(AssetClusterProp),
|
|
|
+ string(AssetTypeProp),
|
|
|
+ string(AssetServiceProp),
|
|
|
+ string(AssetProviderIDProp),
|
|
|
+ string(AssetNameProp),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- for _, prop := range props {
|
|
|
+ for _, s := range aggregateBy {
|
|
|
+ key := ""
|
|
|
switch true {
|
|
|
- case prop == AssetProviderProp && a.Properties().Provider != "":
|
|
|
- keys = append(keys, a.Properties().Provider)
|
|
|
- case prop == AssetAccountProp && a.Properties().Account != "":
|
|
|
- keys = append(keys, a.Properties().Account)
|
|
|
- case prop == AssetProjectProp && a.Properties().Project != "":
|
|
|
- keys = append(keys, a.Properties().Project)
|
|
|
- case prop == AssetClusterProp && a.Properties().Cluster != "":
|
|
|
- keys = append(keys, a.Properties().Cluster)
|
|
|
- case prop == AssetCategoryProp && a.Properties().Category != "":
|
|
|
- keys = append(keys, a.Properties().Category)
|
|
|
- case prop == AssetTypeProp && a.Type().String() != "":
|
|
|
- keys = append(keys, a.Type().String())
|
|
|
- case prop == AssetServiceProp && a.Properties().Service != "":
|
|
|
- keys = append(keys, a.Properties().Service)
|
|
|
- case prop == AssetProviderIDProp && a.Properties().ProviderID != "":
|
|
|
- keys = append(keys, a.Properties().ProviderID)
|
|
|
- case prop == AssetNameProp && a.Properties().Name != "":
|
|
|
- keys = append(keys, a.Properties().Name)
|
|
|
+ case s == string(AssetProviderProp):
|
|
|
+ key = a.Properties().Provider
|
|
|
+ case s == string(AssetAccountProp):
|
|
|
+ key = a.Properties().Account
|
|
|
+ case s == string(AssetProjectProp):
|
|
|
+ key = a.Properties().Project
|
|
|
+ case s == string(AssetClusterProp):
|
|
|
+ key = a.Properties().Cluster
|
|
|
+ case s == string(AssetCategoryProp):
|
|
|
+ key = a.Properties().Category
|
|
|
+ case s == string(AssetTypeProp):
|
|
|
+ key = a.Type().String()
|
|
|
+ case s == string(AssetServiceProp):
|
|
|
+ key = a.Properties().Service
|
|
|
+ case s == string(AssetProviderIDProp):
|
|
|
+ key = a.Properties().ProviderID
|
|
|
+ case s == string(AssetNameProp):
|
|
|
+ key = a.Properties().Name
|
|
|
+ case strings.HasPrefix(s, "label:"):
|
|
|
+ if labelKey := strings.TrimPrefix(s, "label:"); labelKey != "" {
|
|
|
+ labelVal := a.Labels()[labelKey]
|
|
|
+ if labelVal == "" {
|
|
|
+ key = "__undefined__"
|
|
|
+ } else {
|
|
|
+ key = fmt.Sprintf("%s=%s", labelKey, labelVal)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Don't allow aggregating on label ""
|
|
|
+ return "", fmt.Errorf("Attempted to aggregate on invalid key: %s", s)
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return "", fmt.Errorf("Attempted to aggregate on invalid key: %s", s)
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- return strings.Join(keys, "/")
|
|
|
+ if key != "" {
|
|
|
+ keys = append(keys, key)
|
|
|
+ } else {
|
|
|
+ keys = append(keys, UndefinedKey)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return strings.Join(keys, "/"), nil
|
|
|
}
|
|
|
|
|
|
func toString(a Asset) string {
|
|
|
@@ -2304,11 +2330,11 @@ func (sa *SharedAsset) String() string {
|
|
|
// a window. An AssetSet is mutable, so treat it like a threadsafe map.
|
|
|
type AssetSet struct {
|
|
|
sync.RWMutex
|
|
|
- assets map[string]Asset
|
|
|
- props []AssetProperty
|
|
|
- Window Window
|
|
|
- Warnings []string
|
|
|
- Errors []string
|
|
|
+ aggregateBy []string
|
|
|
+ assets map[string]Asset
|
|
|
+ Window Window
|
|
|
+ Warnings []string
|
|
|
+ Errors []string
|
|
|
}
|
|
|
|
|
|
// NewAssetSet instantiates a new AssetSet and, optionally, inserts
|
|
|
@@ -2329,7 +2355,7 @@ func NewAssetSet(start, end time.Time, assets ...Asset) *AssetSet {
|
|
|
// AggregateBy aggregates the Assets in the AssetSet by the given list of
|
|
|
// AssetProperties, such that each asset is binned by a key determined by its
|
|
|
// relevant property values.
|
|
|
-func (as *AssetSet) AggregateBy(props []AssetProperty, opts *AssetAggregationOptions) error {
|
|
|
+func (as *AssetSet) AggregateBy(aggregateBy []string, opts *AssetAggregationOptions) error {
|
|
|
if opts == nil {
|
|
|
opts = &AssetAggregationOptions{}
|
|
|
}
|
|
|
@@ -2342,7 +2368,7 @@ func (as *AssetSet) AggregateBy(props []AssetProperty, opts *AssetAggregationOpt
|
|
|
defer as.Unlock()
|
|
|
|
|
|
aggSet := NewAssetSet(as.Start(), as.End())
|
|
|
- aggSet.props = props
|
|
|
+ aggSet.aggregateBy = aggregateBy
|
|
|
|
|
|
// Compute hours of the given AssetSet, and if it ends in the future,
|
|
|
// adjust the hours accordingly
|
|
|
@@ -2357,7 +2383,10 @@ func (as *AssetSet) AggregateBy(props []AssetProperty, opts *AssetAggregationOpt
|
|
|
sa := NewSharedAsset(name, as.Window.Clone())
|
|
|
sa.Cost = hourlyCost * hours
|
|
|
|
|
|
- aggSet.Insert(sa)
|
|
|
+ err := aggSet.Insert(sa)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// Delete the Assets that don't pass each filter
|
|
|
@@ -2369,15 +2398,18 @@ func (as *AssetSet) AggregateBy(props []AssetProperty, opts *AssetAggregationOpt
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Insert each asset into the new set, which will be keyed by the props
|
|
|
+ // Insert each asset into the new set, which will be keyed by the `aggregateBy`
|
|
|
// on aggSet, resulting in aggregation.
|
|
|
for _, asset := range as.assets {
|
|
|
- aggSet.Insert(asset)
|
|
|
+ err := aggSet.Insert(asset)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// Assign the aggregated values back to the original set
|
|
|
as.assets = aggSet.assets
|
|
|
- as.props = props
|
|
|
+ as.aggregateBy = aggregateBy
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
@@ -2392,24 +2424,26 @@ func (as *AssetSet) Clone() *AssetSet {
|
|
|
as.RLock()
|
|
|
defer as.RUnlock()
|
|
|
|
|
|
- assets := make(map[string]Asset, len(as.assets))
|
|
|
- for k, v := range as.assets {
|
|
|
- assets[k] = v.Clone()
|
|
|
+ var aggregateBy []string
|
|
|
+ if as.aggregateBy != nil {
|
|
|
+ aggregateBy := []string{}
|
|
|
+ for _, s := range as.aggregateBy {
|
|
|
+ aggregateBy = append(aggregateBy, s)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- var props []AssetProperty
|
|
|
- if as.props != nil {
|
|
|
- props = make([]AssetProperty, len(as.props))
|
|
|
- copy(props, as.props)
|
|
|
+ assets := map[string]Asset{}
|
|
|
+ for k, v := range as.assets {
|
|
|
+ assets[k] = v.Clone()
|
|
|
}
|
|
|
|
|
|
s := as.Start()
|
|
|
e := as.End()
|
|
|
|
|
|
return &AssetSet{
|
|
|
- Window: NewWindow(&s, &e),
|
|
|
- assets: assets,
|
|
|
- props: props,
|
|
|
+ Window: NewWindow(&s, &e),
|
|
|
+ aggregateBy: aggregateBy,
|
|
|
+ assets: assets,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2432,23 +2466,28 @@ func (as *AssetSet) End() time.Time {
|
|
|
// FindMatch attempts to find a match in the AssetSet for the given Asset on
|
|
|
// the provided properties and labels. If a match is not found, FindMatch
|
|
|
// returns nil and a Not Found error.
|
|
|
-func (as *AssetSet) FindMatch(query Asset, props []AssetProperty) (Asset, error) {
|
|
|
+func (as *AssetSet) FindMatch(query Asset, aggregateBy []string) (Asset, error) {
|
|
|
as.RLock()
|
|
|
defer as.RUnlock()
|
|
|
|
|
|
- matchKey := key(query, props)
|
|
|
+ matchKey, err := key(query, aggregateBy)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
for _, asset := range as.assets {
|
|
|
- if key(asset, props) == matchKey {
|
|
|
+ if k, err := key(asset, aggregateBy); err != nil {
|
|
|
+ return nil, err
|
|
|
+ } else if k == matchKey {
|
|
|
return asset, nil
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- return nil, fmt.Errorf("Asset not found to match %s on %v", query, props)
|
|
|
+ return nil, fmt.Errorf("Asset not found to match %s on %v", query, aggregateBy)
|
|
|
}
|
|
|
|
|
|
// ReconciliationMatch attempts to find an exact match in the AssetSet on
|
|
|
// (Category, ProviderID). If a match is found, it returns the Asset with the
|
|
|
-// intent to adjuts it. If no match exists, it attempts to find one on only
|
|
|
+// intent to adjust it. If no match exists, it attempts to find one on only
|
|
|
// (ProviderID). If that match is found, it returns the Asset with the intent
|
|
|
// to insert the associated Cloud cost.
|
|
|
func (as *AssetSet) ReconciliationMatch(query Asset) (Asset, bool, error) {
|
|
|
@@ -2456,20 +2495,36 @@ func (as *AssetSet) ReconciliationMatch(query Asset) (Asset, bool, error) {
|
|
|
defer as.RUnlock()
|
|
|
|
|
|
// Full match means matching on (Category, ProviderID)
|
|
|
- fullMatchProps := []AssetProperty{AssetCategoryProp, AssetProviderIDProp}
|
|
|
- fullMatchKey := key(query, fullMatchProps)
|
|
|
+ fullMatchProps := []string{string(AssetCategoryProp), string(AssetProviderIDProp)}
|
|
|
+ fullMatchKey, err := key(query, fullMatchProps)
|
|
|
+
|
|
|
+ // This should never happen because we are using enumerated properties,
|
|
|
+ // but the check is here in case that changes
|
|
|
+ if err != nil {
|
|
|
+ return nil, false, err
|
|
|
+ }
|
|
|
|
|
|
// Partial match means matching only on (ProviderID)
|
|
|
- providerIDMatchProps := []AssetProperty{AssetProviderIDProp}
|
|
|
- providerIDMatchKey := key(query, providerIDMatchProps)
|
|
|
+ providerIDMatchProps := []string{string(AssetProviderIDProp)}
|
|
|
+ providerIDMatchKey, err := key(query, providerIDMatchProps)
|
|
|
+
|
|
|
+ // This should never happen because we are using enumerated properties,
|
|
|
+ // but the check is here in case that changes
|
|
|
+ if err != nil {
|
|
|
+ return nil, false, err
|
|
|
+ }
|
|
|
|
|
|
var providerIDMatch Asset
|
|
|
for _, asset := range as.assets {
|
|
|
- if key(asset, fullMatchProps) == fullMatchKey {
|
|
|
+ if k, err := key(asset, fullMatchProps); err != nil {
|
|
|
+ return nil, false, err
|
|
|
+ } else if k == fullMatchKey {
|
|
|
log.DedupedInfof(10, "Asset ETL: Reconciliation[rcnw]: ReconcileRange Match: %s", fullMatchKey)
|
|
|
return asset, true, nil
|
|
|
}
|
|
|
- if key(asset, providerIDMatchProps) == providerIDMatchKey {
|
|
|
+ if k, err := key(asset, providerIDMatchProps); err != nil {
|
|
|
+ return nil, false, err
|
|
|
+ } else if k == providerIDMatchKey {
|
|
|
// Found a partial match. Save it until after all other options
|
|
|
// have been checked for full matches.
|
|
|
providerIDMatch = asset
|
|
|
@@ -2510,7 +2565,10 @@ func (as *AssetSet) Insert(asset Asset) error {
|
|
|
defer as.Unlock()
|
|
|
|
|
|
// Determine key into which to Insert the Asset.
|
|
|
- k := key(asset, as.props)
|
|
|
+ k, err := key(asset, as.aggregateBy)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
|
|
|
// Add the given Asset to the existing entry, if there is one;
|
|
|
// otherwise just set directly into assets
|
|
|
@@ -2565,7 +2623,7 @@ func (as *AssetSet) MarshalJSON() ([]byte, error) {
|
|
|
return json.Marshal(as.assets)
|
|
|
}
|
|
|
|
|
|
-func (as *AssetSet) Set(asset Asset, props []AssetProperty) {
|
|
|
+func (as *AssetSet) Set(asset Asset, aggregateBy []string) error {
|
|
|
if as.IsEmpty() {
|
|
|
as.Lock()
|
|
|
as.assets = map[string]Asset{}
|
|
|
@@ -2577,7 +2635,12 @@ func (as *AssetSet) Set(asset Asset, props []AssetProperty) {
|
|
|
|
|
|
// Expand the window to match the AssetSet, then set it
|
|
|
asset.ExpandWindow(as.Window)
|
|
|
- as.assets[key(asset, props)] = asset
|
|
|
+ k, err := key(asset, aggregateBy)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ as.assets[k] = asset
|
|
|
+ return nil
|
|
|
}
|
|
|
|
|
|
func (as *AssetSet) Start() time.Time {
|
|
|
@@ -2612,11 +2675,11 @@ func (as *AssetSet) accumulate(that *AssetSet) (*AssetSet, error) {
|
|
|
}
|
|
|
|
|
|
// In the case of an AssetSetRange with empty entries, we may end up with
|
|
|
- // an incoming as without props, even though we are trying to aggregate
|
|
|
- // by props. This handles that case, assigning the correct props.
|
|
|
- if !propsEqual(as.props, that.props) {
|
|
|
- if len(as.props) == 0 {
|
|
|
- as.props = that.props
|
|
|
+ // an incoming `as` without an `aggregateBy`, even though we are tring to
|
|
|
+ // aggregate here. This handles that case by assigning the correct `aggregateBy`.
|
|
|
+ if !sameContents(as.aggregateBy, that.aggregateBy) {
|
|
|
+ if len(as.aggregateBy) == 0 {
|
|
|
+ as.aggregateBy = that.aggregateBy
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2635,7 +2698,7 @@ func (as *AssetSet) accumulate(that *AssetSet) (*AssetSet, error) {
|
|
|
}
|
|
|
|
|
|
acc := NewAssetSet(start, end)
|
|
|
- acc.props = as.props
|
|
|
+ acc.aggregateBy = as.aggregateBy
|
|
|
|
|
|
as.RLock()
|
|
|
defer as.RUnlock()
|
|
|
@@ -2695,14 +2758,14 @@ type AssetAggregationOptions struct {
|
|
|
FilterFuncs []AssetMatchFunc
|
|
|
}
|
|
|
|
|
|
-func (asr *AssetSetRange) AggregateBy(props []AssetProperty, opts *AssetAggregationOptions) error {
|
|
|
+func (asr *AssetSetRange) AggregateBy(aggregateBy []string, opts *AssetAggregationOptions) error {
|
|
|
aggRange := &AssetSetRange{assets: []*AssetSet{}}
|
|
|
|
|
|
asr.Lock()
|
|
|
defer asr.Unlock()
|
|
|
|
|
|
for _, as := range asr.assets {
|
|
|
- err := as.AggregateBy(props, opts)
|
|
|
+ err := as.AggregateBy(aggregateBy, opts)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
@@ -2809,3 +2872,25 @@ func jsonEncode(buffer *bytes.Buffer, name string, obj interface{}, comma string
|
|
|
}
|
|
|
buffer.WriteString(comma)
|
|
|
}
|
|
|
+
|
|
|
+// Returns true if string slices a and b contain all of the same strings, in any order.
|
|
|
+func sameContents(a, b []string) bool {
|
|
|
+ if len(a) != len(b) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for i := range a {
|
|
|
+ if !contains(b, a[i]) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+func contains(slice []string, item string) bool {
|
|
|
+ for _, element := range slice {
|
|
|
+ if element == item {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|