Przeglądaj źródła

Merge pull request #783 from kubecost/develop

Merge develop into master
Ajay Tripathy 5 lat temu
rodzic
commit
7e3cfe0376

+ 3 - 1
cmd/costmodel/main.go

@@ -7,6 +7,7 @@ import (
 	"github.com/kubecost/cost-model/pkg/costmodel"
 	"github.com/kubecost/cost-model/pkg/errors"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
+	"github.com/rs/cors"
 	"k8s.io/klog"
 )
 
@@ -23,5 +24,6 @@ func main() {
 	a.Router.GET("/healthz", Healthz)
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/metrics", promhttp.Handler())
-	klog.Fatal(http.ListenAndServe(":9003", errors.PanicHandlerMiddleware(rootMux)))
+	handler := cors.AllowAll().Handler(rootMux)
+	klog.Fatal(http.ListenAndServe(":9003", errors.PanicHandlerMiddleware(handler)))
 }

+ 1 - 0
go.mod

@@ -26,6 +26,7 @@ require (
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/prometheus/client_golang v1.0.0
 	github.com/prometheus/client_model v0.2.0
+	github.com/rs/cors v1.7.0 // indirect
 	github.com/satori/go.uuid v1.2.0 // indirect
 	github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect
 	go.etcd.io/bbolt v1.3.5

+ 2 - 0
go.sum

@@ -397,6 +397,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
 github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
+github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=

+ 26 - 9
kubecost-exporter.md

@@ -1,6 +1,6 @@
 # Running Kubecost as a Prometheus metric exporter
 
-Running Kubecost as a Prometheus metric exporter allows you to export various cost metrics to Prometheus without setting up any other Kubecost dependencies. Doing so lets you write PromQL queries to calculate the cost and efficiency of any Kubernetes concept, e.g. namespace, service, label, deployment, etc. You can also calculate the cost of different Kubernetes resources, e.g. nodes, PVs, LoadBalancers, and more. Finally, you can do other interesting things like create custom alerts via AlertManager and custom dashboards via Grafana. 
+Running Kubecost as a Prometheus metric exporter allows you to export various cost metrics to Prometheus without setting up any other Kubecost dependencies. Doing so lets you write PromQL queries to calculate the cost and efficiency of any Kubernetes concept, e.g. namespace, service, label, deployment, etc. You can also calculate the cost of different Kubernetes resources, e.g. nodes, PVs, LoadBalancers, and more. Finally, you can do other interesting things like create custom alerts via AlertManager and custom dashboards via Grafana.
 
 ## Installing
 
@@ -11,9 +11,26 @@ If you would prefer to not use the recommended install option and just deploy th
 
 1. Apply the combined YAML:
 
-    ```
-    kubectl apply -f https://raw.githubusercontent.com/kubecost/cost-model/develop/kubernetes/exporter/exporter.yaml --namespace cost-model
-    ```
+    1.a.
+
+      ```
+      wget https://raw.githubusercontent.com/kubecost/cost-model/develop/kubernetes/exporter/exporter.yaml
+      ```
+
+    1.b.
+      On the line
+
+      ```
+      value: "{{prometheusEndpoint}}" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
+      ```
+
+      of `exporter.yaml`, substitute your own Prometheus URI for `{{prometheusEndpoint}}`
+
+    1.c.
+
+      ```
+      kubectl apply -f exporter.yaml --namespace cost-model
+      ```
 
     > If you want to use a namespace other than `cost-model`, you will have to edit the `ClusterRoleBinding` after applying the YAML to change `subjects[0].namespace`. You can do this with `kubectl edit clusterrolebinding cost-model`.
 
@@ -38,7 +55,7 @@ Add Kubecost scrape config to Prom ([more info](https://prometheus.io/docs/intro
 
 Done! Kubecost is now exporting cost metrics. See the following sections for different metrics available and query examples.
 
-## Available Prometheus Metrics 
+## Available Prometheus Metrics
 
 | Metric       | Description                                                                                            |
 | ------------ | ------------------------------------------------------------------------------------------------------ |
@@ -51,13 +68,13 @@ Done! Kubecost is now exporting cost metrics. See the following sections for dif
 | container_cpu_allocation   | Average number of CPUs requested over last 1m                      |
 | container_memory_allocation_bytes   | Average bytes of RAM requested over last 1m                 |
 
-By default, all cost metrics are based on public billing APIs. See the Limitations section below about reflecting your precise billing information. Supported platforms are AWS, Azure, and GCP. For on-prem clusters, prices are based on configurable defaults. 
+By default, all cost metrics are based on public billing APIs. See the Limitations section below about reflecting your precise billing information. Supported platforms are AWS, Azure, and GCP. For on-prem clusters, prices are based on configurable defaults.
 
 More metrics are available in the recommended install path and are described in [PROMETHEUS.md](PROMETHEUS.md).
 
 ## Dashboard examples
 
-Here’s an example dashboard using Kubecost Prometheus metrics: 
+Here’s an example dashboard using Kubecost Prometheus metrics:
 
 ![sample dashboard](https://grafana.com/api/dashboards/8670/images/5480/image)
 
@@ -98,7 +115,7 @@ avg(avg_over_time(node_ram_hourly_cost[1d] )) by (node)
 
 ## Setting Cost Alerts
 
-Custom cost alerts can be implemented with a set of Prometheus queries and can be used for alerting with AlertManager or Grafana alerts. Below are example alerting rules. 
+Custom cost alerts can be implemented with a set of Prometheus queries and can be used for alerting with AlertManager or Grafana alerts. Below are example alerting rules.
 
 #### Determine in real-time if the monthly cost of all nodes is > $1000
 
@@ -114,4 +131,4 @@ Running Kubecost in exporter-only mode by definition limits functionality. The f
 - For large clusters, these Prometheus queries might not scale well over large time windows. We recommend using [Kubecost APIs](https://github.com/kubecost/docs/blob/master/apis.md) for these scenarios.
 - Allocation metrics, like `container_cpu_allocation` only contain _requests_ and do not take usage into account.
 - Related to the previous point, efficiency metrics are not available.
-- Public billing costs on default. The standard Kubecost install and a cloud integration gives you accurate pricing based on your bill. 
+- Public billing costs on default. The standard Kubecost install and a cloud integration gives you accurate pricing based on your bill.

+ 3 - 1
kubernetes/exporter/exporter.yaml

@@ -152,9 +152,11 @@ spec:
               memory: "55M"
           env:
             - name: PROMETHEUS_SERVER_ENDPOINT
-              value: "{{prometheusEndpoint}}"  #The endpoint should have the form http://<service-name>.<namespace-name>.svc
+              value: "{{prometheusEndpoint}}" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
             - name: CLOUD_PROVIDER_API_KEY
               value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
+            - name: CLUSTER_ID
+              value: "cluster-one" # Default cluster ID to use if cluster_id is not set in Prometheus metrics.
           imagePullPolicy: Always
 
 ---

+ 65 - 28
pkg/cloud/awsprovider.go

@@ -191,15 +191,28 @@ type AWSOfferTerm struct {
 	PriceDimensions map[string]*AWSRateCode `json:"priceDimensions"`
 }
 
+func (ot *AWSOfferTerm) String() string {
+	var strs []string
+	for k, rc := range ot.PriceDimensions {
+		strs = append(strs, fmt.Sprintf("%s:%s", k, rc.String()))
+	}
+	return fmt.Sprintf("%s:%s", ot.Sku, strings.Join(strs, ","))
+}
+
 // AWSRateCode encodes data about the price of a product
 type AWSRateCode struct {
 	Unit         string          `json:"unit"`
 	PricePerUnit AWSCurrencyCode `json:"pricePerUnit"`
 }
 
+func (rc *AWSRateCode) String() string {
+	return fmt.Sprintf("{unit: %s, pricePerUnit: %v", rc.Unit, rc.PricePerUnit)
+}
+
 // AWSCurrencyCode is the localized currency. (TODO: support non-USD)
 type AWSCurrencyCode struct {
-	USD string `json:"USD"`
+	USD string `json:"USD,omitempty"`
+	CNY string `json:"CNY,omitempty"`
 }
 
 // AWSProductTerms represents the full terms of the product
@@ -219,12 +232,14 @@ const ClusterIdEnvVar = "AWS_CLUSTER_ID"
 
 // OnDemandRateCode is appended to an node sku
 const OnDemandRateCode = ".JRTCKXETXF"
+const OnDemandRateCodeCn = ".99YE2YK9UR"
 
 // ReservedRateCode is appended to a node sku
 const ReservedRateCode = ".38NPMPTW36"
 
 // HourlyRateCode is appended to a node sku
 const HourlyRateCode = ".6YS6EN2CT7"
+const HourlyRateCodeCn = ".Q7UJUT2CE6"
 
 // volTypes are used to map between AWS UsageTypes and
 // EBS volume types, as they would appear in K8s storage class
@@ -501,6 +516,8 @@ func (aws *AWS) GetPVKey(pv *v1.PersistentVolume, parameters map[string]string,
 	providerID := ""
 	if pv.Spec.AWSElasticBlockStore != nil {
 		providerID = pv.Spec.AWSElasticBlockStore.VolumeID
+	} else if pv.Spec.CSI != nil {
+		providerID = pv.Spec.CSI.VolumeHandle
 	}
 	return &awsPVKey{
 		Labels:                 pv.Labels,
@@ -565,7 +582,6 @@ func (aws *AWS) ClusterManagementPricing() (string, float64, error) {
 func (aws *AWS) getRegionPricing(nodeList []*v1.Node) (*http.Response, string, error) {
 
 	pricingURL := "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/"
-
 	region := ""
 	multiregion := false
 	for _, n := range nodeList {
@@ -573,6 +589,10 @@ func (aws *AWS) getRegionPricing(nodeList []*v1.Node) (*http.Response, string, e
 		currentNodeRegion := ""
 		if r, ok := util.GetRegion(labels); ok {
 			currentNodeRegion = r
+			// Switch to Chinese endpoint for regions with the Chinese prefix
+			if strings.HasPrefix(currentNodeRegion, "cn-") {
+				pricingURL = "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonEC2/current/"
+			}
 		} else {
 			multiregion = true // We weren't able to detect the node's region, so pull all data.
 			break
@@ -585,6 +605,7 @@ func (aws *AWS) getRegionPricing(nodeList []*v1.Node) (*http.Response, string, e
 		}
 	}
 
+	// Chinese multiregion endpoint only contains data for Chinese regions and Chinese regions are excluded from other endpoint
 	if region != "" && !multiregion {
 		pricingURL += region + "/"
 	}
@@ -725,6 +746,9 @@ func (aws *AWS) DownloadPricingData() error {
 		if err == io.EOF {
 			klog.V(2).Infof("done loading \"%s\"\n", pricingURL)
 			break
+		} else if err != nil {
+			klog.V(2).Infof("error parsing response json %v", resp.Body)
+			break
 		}
 		if t == "products" {
 			_, err := dec.Token() // this should parse the opening "{""
@@ -819,28 +843,33 @@ func (aws *AWS) DownloadPricingData() error {
 					if err != nil {
 						klog.V(1).Infof("Error decoding AWS Offer Term: " + err.Error())
 					}
-					if sku.(string)+OnDemandRateCode == skuOnDemand {
-						key, ok := skusToKeys[sku.(string)]
-						spotKey := key + ",preemptible"
-						if ok {
-							aws.Pricing[key].OnDemand = offerTerm
-							aws.Pricing[spotKey].OnDemand = offerTerm
-							if strings.Contains(key, "EBS:VolumeP-IOPS.piops") {
-								// If the specific UsageType is the per IO cost used on io1 volumes
-								// we need to add the per IO cost to the io1 PV cost
-								cost := offerTerm.PriceDimensions[sku.(string)+OnDemandRateCode+HourlyRateCode].PricePerUnit.USD
-								// Add the per IO cost to the PV object for the io1 volume type
-								aws.Pricing[key].PV.CostPerIO = cost
-							} else if strings.Contains(key, "EBS:Volume") {
-								// If volume, we need to get hourly cost and add it to the PV object
-								cost := offerTerm.PriceDimensions[sku.(string)+OnDemandRateCode+HourlyRateCode].PricePerUnit.USD
-								costFloat, _ := strconv.ParseFloat(cost, 64)
-								hourlyPrice := costFloat / 730
-
-								aws.Pricing[key].PV.Cost = strconv.FormatFloat(hourlyPrice, 'f', -1, 64)
-							}
+
+					key, ok := skusToKeys[sku.(string)]
+					spotKey := key + ",preemptible"
+					if ok {
+						aws.Pricing[key].OnDemand = offerTerm
+						aws.Pricing[spotKey].OnDemand = offerTerm
+						var cost string
+						if sku.(string)+OnDemandRateCode == skuOnDemand {
+							cost = offerTerm.PriceDimensions[sku.(string)+OnDemandRateCode+HourlyRateCode].PricePerUnit.USD
+						} else if sku.(string)+OnDemandRateCodeCn == skuOnDemand {
+							cost = offerTerm.PriceDimensions[sku.(string)+OnDemandRateCodeCn+HourlyRateCodeCn].PricePerUnit.CNY
+						}
+						if strings.Contains(key, "EBS:VolumeP-IOPS.piops") {
+							// If the specific UsageType is the per IO cost used on io1 volumes
+							// we need to add the per IO cost to the io1 PV cost
+
+							// Add the per IO cost to the PV object for the io1 volume type
+							aws.Pricing[key].PV.CostPerIO = cost
+						} else if strings.Contains(key, "EBS:Volume") {
+							// If volume, we need to get hourly cost and add it to the PV object
+							costFloat, _ := strconv.ParseFloat(cost, 64)
+							hourlyPrice := costFloat / 730
+
+							aws.Pricing[key].PV.Cost = strconv.FormatFloat(hourlyPrice, 'f', -1, 64)
 						}
 					}
+
 					_, err = dec.Token()
 					if err != nil {
 						return err
@@ -1044,11 +1073,20 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k Key) (*No
 		}, nil
 
 	}
+	var cost string
 	c, ok := terms.OnDemand.PriceDimensions[terms.Sku+OnDemandRateCode+HourlyRateCode]
-	if !ok {
-		return nil, fmt.Errorf("Could not fetch data for \"%s\"", k.ID())
+	if ok {
+		cost = c.PricePerUnit.USD
+	} else {
+		// Check for Chinese pricing before throwing error
+		c, ok = terms.OnDemand.PriceDimensions[terms.Sku+OnDemandRateCodeCn+HourlyRateCodeCn]
+		if ok {
+			cost = c.PricePerUnit.CNY
+		} else {
+			return nil, fmt.Errorf("Could not fetch data for \"%s\"", k.ID())
+		}
 	}
-	cost := c.PricePerUnit.USD
+
 	return &Node{
 		Cost:         cost,
 		VCPU:         terms.VCpu,
@@ -1562,7 +1600,7 @@ func (a *AWS) QueryAthenaPaginated(query string) (*athena.GetQueryResultsInput,
 	resultsBucket := customPricing.AthenaBucketName
 	database := customPricing.AthenaDatabase
 	c := &aws.Config{
-		Region: region,
+		Region:              region,
 		STSRegionalEndpoint: endpoints.RegionalSTSEndpoint,
 	}
 	s := session.Must(session.NewSession(c))
@@ -1632,7 +1670,7 @@ func (a *AWS) QueryAthenaBillingData(query string) (*athena.GetQueryResultsOutpu
 	resultsBucket := customPricing.AthenaBucketName
 	database := customPricing.AthenaDatabase
 	c := &aws.Config{
-		Region: region,
+		Region:              region,
 		STSRegionalEndpoint: endpoints.RegionalSTSEndpoint,
 	}
 	s := session.Must(session.NewSession(c))
@@ -1902,7 +1940,6 @@ func (aws *AWS) ShowAthenaColumns() (map[string]bool, error) {
 	return columnSet, nil
 }
 
-
 // ExternalAllocations represents tagged assets outside the scope of kubernetes.
 // "start" and "end" are dates of the format YYYY-MM-DD
 // "aggregator" is the tag used to determine how to allocate those assets, ie namespace, pod, etc.

+ 1 - 1
pkg/cloud/azureprovider.go

@@ -751,7 +751,7 @@ func (az *Azure) NodePricing(key Key) (*Node, error) {
 			VCPUCost: c.CPU,
 			RAMCost:  c.RAM,
 			GPUCost:  c.GPU,
-			GPU: key.(*azureKey).GetGPUCount(),
+			GPU:      key.(*azureKey).GetGPUCount(),
 		}, nil
 	}
 	return &Node{

+ 1 - 0
pkg/cloud/csvretriever.go

@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 )
+
 type CSVRetriever interface {
 	GetCSVReaders(start, end time.Time) ([]*csv.Reader, error)
 }

+ 1 - 1
pkg/clustermanager/clustersendpoints.go

@@ -7,8 +7,8 @@ import (
 
 	"github.com/julienschmidt/httprouter"
 
-	"k8s.io/klog"
 	"github.com/kubecost/cost-model/pkg/util/json"
+	"k8s.io/klog"
 )
 
 // DataEnvelope is a generic wrapper struct for http response data

+ 63 - 98
pkg/costmodel/aggregation.go

@@ -42,45 +42,45 @@ const (
 // allocation data per resource, vectors of rate data per resource, efficiency
 // data, and metadata describing the type of aggregation operation.
 type Aggregation struct {
-	Aggregator                 string               `json:"aggregation"`
-	Subfields                  []string             `json:"subfields,omitempty"`
-	Environment                string               `json:"environment"`
-	Cluster                    string               `json:"cluster,omitempty"`
-	Properties                 *kubecost.Properties `json:"-"`
-	Start                      time.Time            `json:"-"`
-	End                        time.Time            `json:"-"`
-	CPUAllocationHourlyAverage float64              `json:"cpuAllocationAverage"`
-	CPUAllocationVectors       []*util.Vector       `json:"-"`
-	CPUAllocationTotal         float64              `json:"-"`
-	CPUCost                    float64              `json:"cpuCost"`
-	CPUCostVector              []*util.Vector       `json:"cpuCostVector,omitempty"`
-	CPUEfficiency              float64              `json:"cpuEfficiency"`
-	CPURequestedVectors        []*util.Vector       `json:"-"`
-	CPUUsedVectors             []*util.Vector       `json:"-"`
-	Efficiency                 float64              `json:"efficiency"`
-	GPUAllocationHourlyAverage float64              `json:"gpuAllocationAverage"`
-	GPUAllocationVectors       []*util.Vector       `json:"-"`
-	GPUCost                    float64              `json:"gpuCost"`
-	GPUCostVector              []*util.Vector       `json:"gpuCostVector,omitempty"`
-	GPUAllocationTotal         float64              `json:"-"`
-	RAMAllocationHourlyAverage float64              `json:"ramAllocationAverage"`
-	RAMAllocationVectors       []*util.Vector       `json:"-"`
-	RAMAllocationTotal         float64              `json:"-"`
-	RAMCost                    float64              `json:"ramCost"`
-	RAMCostVector              []*util.Vector       `json:"ramCostVector,omitempty"`
-	RAMEfficiency              float64              `json:"ramEfficiency"`
-	RAMRequestedVectors        []*util.Vector       `json:"-"`
-	RAMUsedVectors             []*util.Vector       `json:"-"`
-	PVAllocationHourlyAverage  float64              `json:"pvAllocationAverage"`
-	PVAllocationVectors        []*util.Vector       `json:"-"`
-	PVAllocationTotal          float64              `json:"-"`
-	PVCost                     float64              `json:"pvCost"`
-	PVCostVector               []*util.Vector       `json:"pvCostVector,omitempty"`
-	NetworkCost                float64              `json:"networkCost"`
-	NetworkCostVector          []*util.Vector       `json:"networkCostVector,omitempty"`
-	SharedCost                 float64              `json:"sharedCost"`
-	TotalCost                  float64              `json:"totalCost"`
-	TotalCostVector            []*util.Vector       `json:"totalCostVector,omitempty"`
+	Aggregator                 string                         `json:"aggregation"`
+	Subfields                  []string                       `json:"subfields,omitempty"`
+	Environment                string                         `json:"environment"`
+	Cluster                    string                         `json:"cluster,omitempty"`
+	Properties                 *kubecost.AllocationProperties `json:"-"`
+	Start                      time.Time                      `json:"-"`
+	End                        time.Time                      `json:"-"`
+	CPUAllocationHourlyAverage float64                        `json:"cpuAllocationAverage"`
+	CPUAllocationVectors       []*util.Vector                 `json:"-"`
+	CPUAllocationTotal         float64                        `json:"-"`
+	CPUCost                    float64                        `json:"cpuCost"`
+	CPUCostVector              []*util.Vector                 `json:"cpuCostVector,omitempty"`
+	CPUEfficiency              float64                        `json:"cpuEfficiency"`
+	CPURequestedVectors        []*util.Vector                 `json:"-"`
+	CPUUsedVectors             []*util.Vector                 `json:"-"`
+	Efficiency                 float64                        `json:"efficiency"`
+	GPUAllocationHourlyAverage float64                        `json:"gpuAllocationAverage"`
+	GPUAllocationVectors       []*util.Vector                 `json:"-"`
+	GPUCost                    float64                        `json:"gpuCost"`
+	GPUCostVector              []*util.Vector                 `json:"gpuCostVector,omitempty"`
+	GPUAllocationTotal         float64                        `json:"-"`
+	RAMAllocationHourlyAverage float64                        `json:"ramAllocationAverage"`
+	RAMAllocationVectors       []*util.Vector                 `json:"-"`
+	RAMAllocationTotal         float64                        `json:"-"`
+	RAMCost                    float64                        `json:"ramCost"`
+	RAMCostVector              []*util.Vector                 `json:"ramCostVector,omitempty"`
+	RAMEfficiency              float64                        `json:"ramEfficiency"`
+	RAMRequestedVectors        []*util.Vector                 `json:"-"`
+	RAMUsedVectors             []*util.Vector                 `json:"-"`
+	PVAllocationHourlyAverage  float64                        `json:"pvAllocationAverage"`
+	PVAllocationVectors        []*util.Vector                 `json:"-"`
+	PVAllocationTotal          float64                        `json:"-"`
+	PVCost                     float64                        `json:"pvCost"`
+	PVCostVector               []*util.Vector                 `json:"pvCostVector,omitempty"`
+	NetworkCost                float64                        `json:"networkCost"`
+	NetworkCostVector          []*util.Vector                 `json:"networkCostVector,omitempty"`
+	SharedCost                 float64                        `json:"sharedCost"`
+	TotalCost                  float64                        `json:"totalCost"`
+	TotalCostVector            []*util.Vector                 `json:"totalCostVector,omitempty"`
 }
 
 // TotalHours determines the amount of hours the Aggregation covers, as a
@@ -593,19 +593,19 @@ func aggregateDatum(cp cloud.Provider, aggregations map[string]*Aggregation, cos
 			agg.Subfields = subfields
 		}
 		if includeProperties {
-			props := &kubecost.Properties{}
-			props.SetCluster(costDatum.ClusterID)
-			props.SetNode(costDatum.NodeName)
+			props := &kubecost.AllocationProperties{}
+			props.Cluster = costDatum.ClusterID
+			props.Node = costDatum.NodeName
 			if controller, kind, hasController := costDatum.GetController(); hasController {
-				props.SetController(controller)
-				props.SetControllerKind(kind)
+				props.Controller = controller
+				props.ControllerKind = kind
 			}
-			props.SetLabels(costDatum.Labels)
-			props.SetAnnotations(costDatum.Annotations)
-			props.SetNamespace(costDatum.Namespace)
-			props.SetPod(costDatum.PodName)
-			props.SetServices(costDatum.Services)
-			props.SetContainer(costDatum.Name)
+			props.Labels = costDatum.Labels
+			props.Annotations = costDatum.Annotations
+			props.Namespace = costDatum.Namespace
+			props.Pod = costDatum.PodName
+			props.Services = costDatum.Services
+			props.Container = costDatum.Name
 			agg.Properties = props
 		}
 
@@ -2116,57 +2116,22 @@ func (a *Accesses) AggregateCostModelHandler(w http.ResponseWriter, r *http.Requ
 
 // ParseAggregationProperties attempts to parse and return aggregation properties
 // encoded under the given key. If none exist, or if parsing fails, an error
-// is returned with empty Properties.
-func ParseAggregationProperties(qp util.QueryParams, key string) (kubecost.Properties, error) {
-	aggProps := kubecost.Properties{}
-
-	labelMap := make(map[string]string)
-	annotationMap := make(map[string]string)
-	for _, raw := range qp.GetList(key, ",") {
-		fields := strings.Split(raw, ":")
-
-		switch kubecost.ParseProperty(fields[0]) {
-		case kubecost.ClusterProp:
-			aggProps.SetCluster("")
-		case kubecost.NodeProp:
-			aggProps.SetNode("")
-		case kubecost.NamespaceProp:
-			aggProps.SetNamespace("")
-		case kubecost.ControllerKindProp:
-			aggProps.SetControllerKind("")
-		case kubecost.ControllerProp:
-			aggProps.SetController("")
-		case kubecost.PodProp:
-			aggProps.SetPod("")
-		case kubecost.ContainerProp:
-			aggProps.SetContainer("")
-		case kubecost.ServiceProp:
-			aggProps.SetServices([]string{})
-		case kubecost.LabelProp:
-			if len(fields) != 2 {
-				return kubecost.Properties{}, fmt.Errorf("illegal aggregate by label: %s", raw)
+// is returned with empty AllocationProperties.
+func ParseAggregationProperties(qp util.QueryParams, key string) ([]string, error) {
+	aggregateBy := []string{}
+	for _, agg := range qp.GetList(key, ",") {
+		aggregate := strings.TrimSpace(agg)
+		if aggregate != "" {
+			if prop, err := kubecost.ParseProperty(aggregate); err == nil {
+				aggregateBy = append(aggregateBy, string(prop))
+			} else if strings.HasPrefix(aggregate, "label:") {
+				aggregateBy = append(aggregateBy, aggregate)
+			} else if strings.HasPrefix(aggregate, "annotation:") {
+				aggregateBy = append(aggregateBy, aggregate)
 			}
-			label := prom.SanitizeLabelName(strings.TrimSpace(fields[1]))
-			labelMap[label] = ""
-		case kubecost.AnnotationProp:
-			if len(fields) != 2 {
-				return kubecost.Properties{}, fmt.Errorf("illegal aggregate by annotation: %s", raw)
-			}
-			annotation := prom.SanitizeLabelName(strings.TrimSpace(fields[1]))
-			annotationMap[annotation] = ""
 		}
-
-	}
-
-	if len(labelMap) > 0 {
-		aggProps.SetLabels(labelMap)
 	}
-
-	if len(annotationMap) > 0 {
-		aggProps.SetAnnotations(annotationMap)
-	}
-
-	return aggProps, nil
+	return aggregateBy, nil
 }
 
 // ComputeAllocationHandler computes an AllocationSetRange from the CostModel.

+ 145 - 60
pkg/costmodel/allocation.go

@@ -17,13 +17,24 @@ import (
 )
 
 const (
-	queryFmtPods                  = `avg(kube_pod_container_status_running{}) by (pod, namespace, cluster_id)[%s:%s]%s`
-	queryFmtRAMBytesAllocated     = `avg(avg_over_time(container_memory_allocation_bytes{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
-	queryFmtRAMRequests           = `avg(avg_over_time(kube_pod_container_resource_requests_memory_bytes{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
-	queryFmtRAMUsage              = `avg(avg_over_time(container_memory_working_set_bytes{container_name!="", container_name!="POD", instance!=""}[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
-	queryFmtCPUCoresAllocated     = `avg(avg_over_time(container_cpu_allocation{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
-	queryFmtCPURequests           = `avg(avg_over_time(kube_pod_container_resource_requests_cpu_cores{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
-	queryFmtCPUUsage              = `avg(rate(container_cpu_usage_seconds_total{container_name!="", container_name!="POD", instance!=""}[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
+	queryFmtPods              = `avg(kube_pod_container_status_running{}) by (pod, namespace, cluster_id)[%s:%s]%s`
+	queryFmtRAMBytesAllocated = `avg(avg_over_time(container_memory_allocation_bytes{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
+	queryFmtRAMRequests       = `avg(avg_over_time(kube_pod_container_resource_requests_memory_bytes{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
+	queryFmtRAMUsageAvg       = `avg(avg_over_time(container_memory_working_set_bytes{container_name!="", container_name!="POD", instance!=""}[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
+	queryFmtRAMUsageMax       = `max(max_over_time(container_memory_working_set_bytes{container_name!="", container_name!="POD", instance!=""}[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
+	queryFmtCPUCoresAllocated = `avg(avg_over_time(container_cpu_allocation{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
+	queryFmtCPURequests       = `avg(avg_over_time(kube_pod_container_resource_requests_cpu_cores{container!="", container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
+	queryFmtCPUUsageAvg       = `avg(rate(container_cpu_usage_seconds_total{container_name!="", container_name!="POD", instance!=""}[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
+
+	// This query could be written without the recording rule
+	// "kubecost_savings_container_cpu_usage_seconds", but we should
+	// only do that when we're ready to incur the performance tradeoffs
+	// with subqueries which would probably be in the world of hourly
+	// ETL.
+	//
+	// See PromQL subquery documentation for a rate example:
+	// https://prometheus.io/blog/2019/01/28/subquery-support/#examples
+	queryFmtCPUUsageMax           = `max(max_over_time(kubecost_savings_container_cpu_usage_seconds[%s]%s)) by (container_name, pod_name, namespace, instance, cluster_id)`
 	queryFmtGPUsRequested         = `avg(avg_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!=""}[%s]%s)) by (container, pod, namespace, node, cluster_id)`
 	queryFmtNodeCostPerCPUHr      = `avg(avg_over_time(node_cpu_hourly_cost[%s]%s)) by (node, cluster_id, instance_type)`
 	queryFmtNodeCostPerRAMGiBHr   = `avg(avg_over_time(node_ram_hourly_cost[%s]%s)) by (node, cluster_id, instance_type)`
@@ -107,8 +118,11 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 	queryRAMRequests := fmt.Sprintf(queryFmtRAMRequests, durStr, offStr)
 	resChRAMRequests := ctx.Query(queryRAMRequests)
 
-	queryRAMUsage := fmt.Sprintf(queryFmtRAMUsage, durStr, offStr)
-	resChRAMUsage := ctx.Query(queryRAMUsage)
+	queryRAMUsageAvg := fmt.Sprintf(queryFmtRAMUsageAvg, durStr, offStr)
+	resChRAMUsageAvg := ctx.Query(queryRAMUsageAvg)
+
+	queryRAMUsageMax := fmt.Sprintf(queryFmtRAMUsageMax, durStr, offStr)
+	resChRAMUsageMax := ctx.Query(queryRAMUsageMax)
 
 	queryCPUCoresAllocated := fmt.Sprintf(queryFmtCPUCoresAllocated, durStr, offStr)
 	resChCPUCoresAllocated := ctx.Query(queryCPUCoresAllocated)
@@ -116,8 +130,11 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 	queryCPURequests := fmt.Sprintf(queryFmtCPURequests, durStr, offStr)
 	resChCPURequests := ctx.Query(queryCPURequests)
 
-	queryCPUUsage := fmt.Sprintf(queryFmtCPUUsage, durStr, offStr)
-	resChCPUUsage := ctx.Query(queryCPUUsage)
+	queryCPUUsageAvg := fmt.Sprintf(queryFmtCPUUsageAvg, durStr, offStr)
+	resChCPUUsageAvg := ctx.Query(queryCPUUsageAvg)
+
+	queryCPUUsageMax := fmt.Sprintf(queryFmtCPUUsageMax, durStr, offStr)
+	resChCPUUsageMax := ctx.Query(queryCPUUsageMax)
 
 	queryGPUsRequested := fmt.Sprintf(queryFmtGPUsRequested, durStr, offStr)
 	resChGPUsRequested := ctx.Query(queryGPUsRequested)
@@ -202,10 +219,12 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 
 	resCPUCoresAllocated, _ := resChCPUCoresAllocated.Await()
 	resCPURequests, _ := resChCPURequests.Await()
-	resCPUUsage, _ := resChCPUUsage.Await()
+	resCPUUsageAvg, _ := resChCPUUsageAvg.Await()
+	resCPUUsageMax, _ := resChCPUUsageMax.Await()
 	resRAMBytesAllocated, _ := resChRAMBytesAllocated.Await()
 	resRAMRequests, _ := resChRAMRequests.Await()
-	resRAMUsage, _ := resChRAMUsage.Await()
+	resRAMUsageAvg, _ := resChRAMUsageAvg.Await()
+	resRAMUsageMax, _ := resChRAMUsageMax.Await()
 	resGPUsRequested, _ := resChGPUsRequested.Await()
 
 	resNodeCostPerCPUHr, _ := resChNodeCostPerCPUHr.Await()
@@ -252,10 +271,12 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 	// or equal to request.
 	applyCPUCoresAllocated(podMap, resCPUCoresAllocated)
 	applyCPUCoresRequested(podMap, resCPURequests)
-	applyCPUCoresUsed(podMap, resCPUUsage)
+	applyCPUCoresUsedAvg(podMap, resCPUUsageAvg)
+	applyCPUCoresUsedMax(podMap, resCPUUsageMax)
 	applyRAMBytesAllocated(podMap, resRAMBytesAllocated)
 	applyRAMBytesRequested(podMap, resRAMRequests)
-	applyRAMBytesUsed(podMap, resRAMUsage)
+	applyRAMBytesUsedAvg(podMap, resRAMUsageAvg)
+	applyRAMBytesUsedMax(podMap, resRAMUsageMax)
 	applyGPUsRequested(podMap, resGPUsRequested)
 	applyNetworkAllocation(podMap, resNetZoneGiB, resNetZoneCostPerGiB)
 	applyNetworkAllocation(podMap, resNetRegionGiB, resNetRegionCostPerGiB)
@@ -326,11 +347,11 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 
 	for _, pod := range podMap {
 		for _, alloc := range pod.Allocations {
-			cluster, _ := alloc.Properties.GetCluster()
-			nodeName, _ := alloc.Properties.GetNode()
-			namespace, _ := alloc.Properties.GetNamespace()
-			pod, _ := alloc.Properties.GetPod()
-			container, _ := alloc.Properties.GetContainer()
+			cluster := alloc.Properties.Cluster
+			nodeName := alloc.Properties.Node
+			namespace := alloc.Properties.Namespace
+			pod := alloc.Properties.Pod
+			container := alloc.Properties.Container
 
 			podKey := newPodKey(cluster, namespace, pod)
 			nodeKey := newNodeKey(cluster, nodeName)
@@ -593,7 +614,7 @@ func applyCPUCoresAllocated(podMap map[podKey]*Pod, resCPUCoresAllocated []*prom
 			log.Warningf("CostModel.ComputeAllocation: CPU allocation query result missing 'node': %s", key)
 			continue
 		}
-		pod.Allocations[container].Properties.SetNode(node)
+		pod.Allocations[container].Properties.Node = node
 	}
 }
 
@@ -633,15 +654,15 @@ func applyCPUCoresRequested(podMap map[podKey]*Pod, resCPUCoresRequested []*prom
 			log.Warningf("CostModel.ComputeAllocation: CPU request query result missing 'node': %s", key)
 			continue
 		}
-		pod.Allocations[container].Properties.SetNode(node)
+		pod.Allocations[container].Properties.Node = node
 	}
 }
 
-func applyCPUCoresUsed(podMap map[podKey]*Pod, resCPUCoresUsed []*prom.QueryResult) {
-	for _, res := range resCPUCoresUsed {
+func applyCPUCoresUsedAvg(podMap map[podKey]*Pod, resCPUCoresUsedAvg []*prom.QueryResult) {
+	for _, res := range resCPUCoresUsedAvg {
 		key, err := resultPodKey(res, "cluster_id", "namespace", "pod_name")
 		if err != nil {
-			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage result missing field: %s", err)
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage avg result missing field: %s", err)
 			continue
 		}
 
@@ -652,7 +673,7 @@ func applyCPUCoresUsed(podMap map[podKey]*Pod, resCPUCoresUsed []*prom.QueryResu
 
 		container, err := res.GetString("container_name")
 		if err != nil {
-			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage query result missing 'container': %s", key)
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage avg query result missing 'container': %s", key)
 			continue
 		}
 
@@ -664,6 +685,39 @@ func applyCPUCoresUsed(podMap map[podKey]*Pod, resCPUCoresUsed []*prom.QueryResu
 	}
 }
 
+func applyCPUCoresUsedMax(podMap map[podKey]*Pod, resCPUCoresUsedMax []*prom.QueryResult) {
+	for _, res := range resCPUCoresUsedMax {
+		key, err := resultPodKey(res, "cluster_id", "namespace", "pod_name")
+		if err != nil {
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage max result missing field: %s", err)
+			continue
+		}
+
+		pod, ok := podMap[key]
+		if !ok {
+			continue
+		}
+
+		container, err := res.GetString("container_name")
+		if err != nil {
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: CPU usage max query result missing 'container': %s", key)
+			continue
+		}
+
+		if _, ok := pod.Allocations[container]; !ok {
+			pod.AppendContainer(container)
+		}
+
+		if pod.Allocations[container].RawAllocationOnly == nil {
+			pod.Allocations[container].RawAllocationOnly = &kubecost.RawAllocationOnlyData{
+				CPUCoreUsageMax: res.Values[0].Value,
+			}
+		} else {
+			pod.Allocations[container].RawAllocationOnly.CPUCoreUsageMax = res.Values[0].Value
+		}
+	}
+}
+
 func applyRAMBytesAllocated(podMap map[podKey]*Pod, resRAMBytesAllocated []*prom.QueryResult) {
 	for _, res := range resRAMBytesAllocated {
 		key, err := resultPodKey(res, "cluster_id", "namespace", "pod")
@@ -696,7 +750,7 @@ func applyRAMBytesAllocated(podMap map[podKey]*Pod, resRAMBytesAllocated []*prom
 			log.Warningf("CostModel.ComputeAllocation: RAM allocation query result missing 'node': %s", key)
 			continue
 		}
-		pod.Allocations[container].Properties.SetNode(node)
+		pod.Allocations[container].Properties.Node = node
 	}
 }
 
@@ -736,15 +790,15 @@ func applyRAMBytesRequested(podMap map[podKey]*Pod, resRAMBytesRequested []*prom
 			log.Warningf("CostModel.ComputeAllocation: RAM request query result missing 'node': %s", key)
 			continue
 		}
-		pod.Allocations[container].Properties.SetNode(node)
+		pod.Allocations[container].Properties.Node = node
 	}
 }
 
-func applyRAMBytesUsed(podMap map[podKey]*Pod, resRAMBytesUsed []*prom.QueryResult) {
-	for _, res := range resRAMBytesUsed {
+func applyRAMBytesUsedAvg(podMap map[podKey]*Pod, resRAMBytesUsedAvg []*prom.QueryResult) {
+	for _, res := range resRAMBytesUsedAvg {
 		key, err := resultPodKey(res, "cluster_id", "namespace", "pod_name")
 		if err != nil {
-			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM usage result missing field: %s", err)
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM avg usage result missing field: %s", err)
 			continue
 		}
 
@@ -755,7 +809,7 @@ func applyRAMBytesUsed(podMap map[podKey]*Pod, resRAMBytesUsed []*prom.QueryResu
 
 		container, err := res.GetString("container_name")
 		if err != nil {
-			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM usage query result missing 'container': %s", key)
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM usage avg query result missing 'container': %s", key)
 			continue
 		}
 
@@ -767,6 +821,39 @@ func applyRAMBytesUsed(podMap map[podKey]*Pod, resRAMBytesUsed []*prom.QueryResu
 	}
 }
 
+func applyRAMBytesUsedMax(podMap map[podKey]*Pod, resRAMBytesUsedMax []*prom.QueryResult) {
+	for _, res := range resRAMBytesUsedMax {
+		key, err := resultPodKey(res, "cluster_id", "namespace", "pod_name")
+		if err != nil {
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM usage max result missing field: %s", err)
+			continue
+		}
+
+		pod, ok := podMap[key]
+		if !ok {
+			continue
+		}
+
+		container, err := res.GetString("container_name")
+		if err != nil {
+			log.DedupedWarningf(10, "CostModel.ComputeAllocation: RAM usage max query result missing 'container': %s", key)
+			continue
+		}
+
+		if _, ok := pod.Allocations[container]; !ok {
+			pod.AppendContainer(container)
+		}
+
+		if pod.Allocations[container].RawAllocationOnly == nil {
+			pod.Allocations[container].RawAllocationOnly = &kubecost.RawAllocationOnlyData{
+				RAMBytesUsageMax: res.Values[0].Value,
+			}
+		} else {
+			pod.Allocations[container].RawAllocationOnly.RAMBytesUsageMax = res.Values[0].Value
+		}
+	}
+}
+
 func applyGPUsRequested(podMap map[podKey]*Pod, resGPUsRequested []*prom.QueryResult) {
 	for _, res := range resGPUsRequested {
 		key, err := resultPodKey(res, "cluster_id", "namespace", "pod")
@@ -914,11 +1001,10 @@ func resToPodAnnotations(resPodAnnotations []*prom.QueryResult) map[podKey]map[s
 func applyLabels(podMap map[podKey]*Pod, namespaceLabels map[namespaceKey]map[string]string, podLabels map[podKey]map[string]string) {
 	for podKey, pod := range podMap {
 		for _, alloc := range pod.Allocations {
-			allocLabels, err := alloc.Properties.GetLabels()
-			if err != nil {
-				allocLabels = map[string]string{}
+			allocLabels := alloc.Properties.Labels
+			if allocLabels == nil {
+				allocLabels = make(map[string]string)
 			}
-
 			// Apply namespace labels first, then pod labels so that pod labels
 			// overwrite namespace labels.
 			nsKey := newNamespaceKey(podKey.Cluster, podKey.Namespace)
@@ -933,7 +1019,7 @@ func applyLabels(podMap map[podKey]*Pod, namespaceLabels map[namespaceKey]map[st
 				}
 			}
 
-			alloc.Properties.SetLabels(allocLabels)
+			alloc.Properties.Labels = allocLabels
 		}
 	}
 }
@@ -941,11 +1027,10 @@ func applyLabels(podMap map[podKey]*Pod, namespaceLabels map[namespaceKey]map[st
 func applyAnnotations(podMap map[podKey]*Pod, namespaceAnnotations map[string]map[string]string, podAnnotations map[podKey]map[string]string) {
 	for key, pod := range podMap {
 		for _, alloc := range pod.Allocations {
-			allocAnnotations, err := alloc.Properties.GetAnnotations()
-			if err != nil {
-				allocAnnotations = map[string]string{}
+			allocAnnotations := alloc.Properties.Annotations
+			if allocAnnotations == nil {
+				allocAnnotations = make(map[string]string)
 			}
-
 			// Apply namespace annotations first, then pod annotations so that
 			// pod labels overwrite namespace labels.
 			if labels, ok := namespaceAnnotations[key.Namespace]; ok {
@@ -959,7 +1044,7 @@ func applyAnnotations(podMap map[podKey]*Pod, namespaceAnnotations map[string]ma
 				}
 			}
 
-			alloc.Properties.SetAnnotations(allocAnnotations)
+			alloc.Properties.Annotations = allocAnnotations
 		}
 	}
 }
@@ -1179,7 +1264,7 @@ func applyServicesToPods(podMap map[podKey]*Pod, podLabels map[podKey]map[string
 					services = append(services, sKey.Service)
 					allocsByService[sKey] = append(allocsByService[sKey], alloc)
 				}
-				alloc.Properties.SetServices(services)
+				alloc.Properties.Services = services
 
 			}
 		}
@@ -1190,8 +1275,8 @@ func applyControllersToPods(podMap map[podKey]*Pod, podControllerMap map[podKey]
 	for key, pod := range podMap {
 		for _, alloc := range pod.Allocations {
 			if controllerKey, ok := podControllerMap[key]; ok {
-				alloc.Properties.SetControllerKind(controllerKey.ControllerKind)
-				alloc.Properties.SetController(controllerKey.Controller)
+				alloc.Properties.ControllerKind = controllerKey.ControllerKind
+				alloc.Properties.Controller = controllerKey.Controller
 			}
 		}
 	}
@@ -1550,11 +1635,11 @@ func applyUnmountedPVs(window kubecost.Window, podMap map[podKey]*Pod, pvMap map
 		}
 
 		podMap[key].AppendContainer(container)
-		podMap[key].Allocations[container].Properties.SetCluster(cluster)
-		podMap[key].Allocations[container].Properties.SetNode(node)
-		podMap[key].Allocations[container].Properties.SetNamespace(namespace)
-		podMap[key].Allocations[container].Properties.SetPod(pod)
-		podMap[key].Allocations[container].Properties.SetContainer(container)
+		podMap[key].Allocations[container].Properties.Cluster = cluster
+		podMap[key].Allocations[container].Properties.Node = node
+		podMap[key].Allocations[container].Properties.Namespace = namespace
+		podMap[key].Allocations[container].Properties.Pod = pod
+		podMap[key].Allocations[container].Properties.Container = container
 		podMap[key].Allocations[container].PVByteHours = unmountedPVBytes[cluster] * window.Minutes() / 60.0
 		podMap[key].Allocations[container].PVCost = amount
 	}
@@ -1593,11 +1678,11 @@ func applyUnmountedPVCs(window kubecost.Window, podMap map[podKey]*Pod, pvcMap m
 		}
 
 		podMap[podKey].AppendContainer(container)
-		podMap[podKey].Allocations[container].Properties.SetCluster(cluster)
-		podMap[podKey].Allocations[container].Properties.SetNode(node)
-		podMap[podKey].Allocations[container].Properties.SetNamespace(namespace)
-		podMap[podKey].Allocations[container].Properties.SetPod(pod)
-		podMap[podKey].Allocations[container].Properties.SetContainer(container)
+		podMap[podKey].Allocations[container].Properties.Cluster = cluster
+		podMap[podKey].Allocations[container].Properties.Node = node
+		podMap[podKey].Allocations[container].Properties.Namespace = namespace
+		podMap[podKey].Allocations[container].Properties.Pod = pod
+		podMap[podKey].Allocations[container].Properties.Container = container
 		podMap[podKey].Allocations[container].PVByteHours = unmountedPVCBytes[key] * window.Minutes() / 60.0
 		podMap[podKey].Allocations[container].PVCost = amount
 	}
@@ -1824,15 +1909,15 @@ func (p Pod) AppendContainer(container string) {
 
 	alloc := &kubecost.Allocation{
 		Name:       name,
-		Properties: kubecost.Properties{},
+		Properties: &kubecost.AllocationProperties{},
 		Window:     p.Window.Clone(),
 		Start:      p.Start,
 		End:        p.End,
 	}
-	alloc.Properties.SetContainer(container)
-	alloc.Properties.SetPod(p.Key.Pod)
-	alloc.Properties.SetNamespace(p.Key.Namespace)
-	alloc.Properties.SetCluster(p.Key.Cluster)
+	alloc.Properties.Container = container
+	alloc.Properties.Pod = p.Key.Pod
+	alloc.Properties.Namespace = p.Key.Namespace
+	alloc.Properties.Cluster = p.Key.Cluster
 
 	p.Allocations[container] = alloc
 }

+ 20 - 20
pkg/costmodel/cluster_helpers_test.go

@@ -711,15 +711,15 @@ func TestBuildGPUCostMap(t *testing.T) {
 			promResult: []*prom.QueryResult{
 				{
 					Metric: map[string]interface{}{
-						"cluster_id": "cluster1",
-						"node": "node1",
-						"instance_type":"type1",
-						"provider_id": "provider1",
+						"cluster_id":    "cluster1",
+						"node":          "node1",
+						"instance_type": "type1",
+						"provider_id":   "provider1",
 					},
 					Values: []*util.Vector{
 						&util.Vector{
 							Timestamp: 0,
-							Value: 0,
+							Value:     0,
 						},
 					},
 				},
@@ -744,15 +744,15 @@ func TestBuildGPUCostMap(t *testing.T) {
 			promResult: []*prom.QueryResult{
 				{
 					Metric: map[string]interface{}{
-						"cluster_id": "cluster1",
-						"node": "node1",
-						"instance_type":"type1",
-						"provider_id": "provider1",
+						"cluster_id":    "cluster1",
+						"node":          "node1",
+						"instance_type": "type1",
+						"provider_id":   "provider1",
 					},
 					Values: []*util.Vector{
 						&util.Vector{
 							Timestamp: 0,
-							Value: 2,
+							Value:     2,
 						},
 					},
 				},
@@ -777,15 +777,15 @@ func TestBuildGPUCostMap(t *testing.T) {
 			promResult: []*prom.QueryResult{
 				{
 					Metric: map[string]interface{}{
-						"cluster_id": "cluster1",
-						"node": "node1",
-						"instance_type":"type1",
-						"provider_id": "provider1",
+						"cluster_id":    "cluster1",
+						"node":          "node1",
+						"instance_type": "type1",
+						"provider_id":   "provider1",
 					},
 					Values: []*util.Vector{
 						&util.Vector{
 							Timestamp: 0,
-							Value: 2,
+							Value:     2,
 						},
 					},
 				},
@@ -818,15 +818,15 @@ func TestBuildGPUCostMap(t *testing.T) {
 			promResult: []*prom.QueryResult{
 				{
 					Metric: map[string]interface{}{
-						"cluster_id": "cluster1",
-						"node": "node1",
-						"instance_type":"type1",
-						"provider_id": "provider1",
+						"cluster_id":    "cluster1",
+						"node":          "node1",
+						"instance_type": "type1",
+						"provider_id":   "provider1",
 					},
 					Values: []*util.Vector{
 						&util.Vector{
 							Timestamp: 0,
-							Value: 2,
+							Value:     2,
 						},
 					},
 				},

+ 11 - 11
pkg/costmodel/containerkeys.go

@@ -13,22 +13,22 @@ var (
 	NewKeyTupleErr = errors.New("NewKeyTuple() Provided key not containing exactly 3 components.")
 
 	// Static Errors for ContainerMetric creation
-	InvalidKeyErr error = errors.New("Not a valid key")
-	NoContainerErr error = errors.New("Prometheus vector does not have container name")
+	InvalidKeyErr      error = errors.New("Not a valid key")
+	NoContainerErr     error = errors.New("Prometheus vector does not have container name")
 	NoContainerNameErr error = errors.New("Prometheus vector does not have string container name")
-	NoPodErr error = errors.New("Prometheus vector does not have pod name")
-	NoPodNameErr error = errors.New("Prometheus vector does not have string pod name")
-	NoNamespaceErr error = errors.New("Prometheus vector does not have namespace")
+	NoPodErr           error = errors.New("Prometheus vector does not have pod name")
+	NoPodNameErr       error = errors.New("Prometheus vector does not have string pod name")
+	NoNamespaceErr     error = errors.New("Prometheus vector does not have namespace")
 	NoNamespaceNameErr error = errors.New("Prometheus vector does not have string namespace")
-	NoNodeNameErr error = errors.New("Prometheus vector does not have string node")
-	NoClusterIDErr error = errors.New("Prometheus vector does not have string cluster_id")
+	NoNodeNameErr      error = errors.New("Prometheus vector does not have string node")
+	NoClusterIDErr     error = errors.New("Prometheus vector does not have string cluster_id")
 )
 
 //--------------------------------------------------------------------------
 //  KeyTuple
 //--------------------------------------------------------------------------
 
-// KeyTuple contains is a utility which parses Namespace, Key, and ClusterID from a 
+// KeyTuple contains is a utility which parses Namespace, Key, and ClusterID from a
 // comma delimitted string.
 type KeyTuple struct {
 	key    string
@@ -103,8 +103,8 @@ func containerMetricKey(ns, podName, containerName, nodeName, clusterID string)
 	return ns + "," + podName + "," + containerName + "," + nodeName + "," + clusterID
 }
 
-// NewContainerMetricFromKey creates a new ContainerMetric instance using a provided comma delimitted 
-// string key. 
+// NewContainerMetricFromKey creates a new ContainerMetric instance using a provided comma delimitted
+// string key.
 func NewContainerMetricFromKey(key string) (*ContainerMetric, error) {
 	s := strings.Split(key, ",")
 	if len(s) == 5 {
@@ -132,7 +132,7 @@ func NewContainerMetricFromValues(ns, podName, containerName, nodeName, clusterI
 	}
 }
 
-// NewContainerMetricsFromPod creates a slice of ContainerMetric instances for each container in the 
+// NewContainerMetricsFromPod creates a slice of ContainerMetric instances for each container in the
 // provided Pod.
 func NewContainerMetricsFromPod(pod *v1.Pod, clusterID string) ([]*ContainerMetric, error) {
 	podName := pod.GetObjectMeta().GetName()

+ 1 - 1
pkg/env/costmodelenv.go

@@ -30,7 +30,7 @@ const (
 	SQLAddressEnvVar               = "SQL_ADDRESS"
 	UseCSVProviderEnvVar           = "USE_CSV_PROVIDER"
 	CSVRegionEnvVar                = "CSV_REGION"
-	CSVEndpointEnvVar 			   = "CSV_ENDPOINT"
+	CSVEndpointEnvVar              = "CSV_ENDPOINT"
 	CSVPathEnvVar                  = "CSV_PATH"
 	ConfigPathEnvVar               = "CONFIG_PATH"
 	CloudProviderAPIKeyEnvVar      = "CLOUD_PROVIDER_API_KEY"

+ 226 - 208
pkg/kubecost/allocation.go

@@ -47,30 +47,62 @@ const ShareNone = "__none__"
 // Allocation is a unit of resource allocation and cost for a given window
 // of time and for a given kubernetes construct with its associated set of
 // properties.
-// TODO:CLEANUP consider dropping name in favor of just Properties and an
+// TODO:CLEANUP consider dropping name in favor of just AllocationProperties and an
 // Assets-style key() function for AllocationSet.
 type Allocation struct {
-	Name                   string     `json:"name"`
-	Properties             Properties `json:"properties,omitempty"`
-	Window                 Window     `json:"window"`
-	Start                  time.Time  `json:"start"`
-	End                    time.Time  `json:"end"`
-	CPUCoreHours           float64    `json:"cpuCoreHours"`
-	CPUCoreRequestAverage  float64    `json:"cpuCoreRequestAverage"`
-	CPUCoreUsageAverage    float64    `json:"cpuCoreUsageAverage"`
-	CPUCost                float64    `json:"cpuCost"`
-	GPUHours               float64    `json:"gpuHours"`
-	GPUCost                float64    `json:"gpuCost"`
-	NetworkCost            float64    `json:"networkCost"`
-	LoadBalancerCost       float64    `json:"loadBalancerCost"`
-	PVByteHours            float64    `json:"pvByteHours"`
-	PVCost                 float64    `json:"pvCost"`
-	RAMByteHours           float64    `json:"ramByteHours"`
-	RAMBytesRequestAverage float64    `json:"ramByteRequestAverage"`
-	RAMBytesUsageAverage   float64    `json:"ramByteUsageAverage"`
-	RAMCost                float64    `json:"ramCost"`
-	SharedCost             float64    `json:"sharedCost"`
-	ExternalCost           float64    `json:"externalCost"`
+	Name                   string                `json:"name"`
+	Properties             *AllocationProperties `json:"properties,omitempty"`
+	Window                 Window                `json:"window"`
+	Start                  time.Time             `json:"start"`
+	End                    time.Time             `json:"end"`
+	CPUCoreHours           float64               `json:"cpuCoreHours"`
+	CPUCoreRequestAverage  float64               `json:"cpuCoreRequestAverage"`
+	CPUCoreUsageAverage    float64               `json:"cpuCoreUsageAverage"`
+	CPUCost                float64               `json:"cpuCost"`
+	GPUHours               float64               `json:"gpuHours"`
+	GPUCost                float64               `json:"gpuCost"`
+	NetworkCost            float64               `json:"networkCost"`
+	LoadBalancerCost       float64               `json:"loadBalancerCost"`
+	PVByteHours            float64               `json:"pvByteHours"`
+	PVCost                 float64               `json:"pvCost"`
+	RAMByteHours           float64               `json:"ramByteHours"`
+	RAMBytesRequestAverage float64               `json:"ramByteRequestAverage"`
+	RAMBytesUsageAverage   float64               `json:"ramByteUsageAverage"`
+	RAMCost                float64               `json:"ramCost"`
+	SharedCost             float64               `json:"sharedCost"`
+	ExternalCost           float64               `json:"externalCost"`
+
+	// RawAllocationOnly is a pointer so if it is not present it will be
+	// marshalled as null rather than as an object with Go default values.
+	RawAllocationOnly *RawAllocationOnlyData `json:"rawAllocationOnly"`
+}
+
+// RawAllocationOnlyData is information that only belong in "raw" Allocations,
+// those which have not undergone aggregation, accumulation, or any other form
+// of combination to produce a new Allocation from other Allocations.
+//
+// Max usage data belongs here because computing the overall maximum from two
+// or more Allocations is a non-trivial operation that cannot be defined without
+// maintaining a large amount of state. Consider the following example:
+// _______________________________________________
+//
+// A1 Using 3 CPU    ----      -----     ------
+// A2 Using 2 CPU      ----      -----      ----
+// A3 Using 1 CPU         ---       --
+// _______________________________________________
+//                   Time ---->
+//
+// The logical maximum CPU usage is 5, but this cannot be calculated iteratively,
+// which is how we calculate aggregations and accumulations of Allocations currently.
+// This becomes a problem I could call "maximum sum of overlapping intervals" and is
+// essentially a variant of an interval scheduling algorithm.
+//
+// If we had types to differentiate between regular Allocations and AggregatedAllocations
+// then this type would be unnecessary and its fields would go into the regular Allocation
+// and not in the AggregatedAllocation.
+type RawAllocationOnlyData struct {
+	CPUCoreUsageMax  float64 `json:"cpuCoreUsageMax"`
+	RAMBytesUsageMax float64 `json:"ramByteUsageMax"`
 }
 
 // AllocationMatchFunc is a function that can be used to match Allocations by
@@ -124,6 +156,19 @@ func (a *Allocation) Clone() *Allocation {
 		RAMCost:                a.RAMCost,
 		SharedCost:             a.SharedCost,
 		ExternalCost:           a.ExternalCost,
+		RawAllocationOnly:      a.RawAllocationOnly.Clone(),
+	}
+}
+
+// Clone returns a deep copy of the given RawAllocationOnlyData
+func (r *RawAllocationOnlyData) Clone() *RawAllocationOnlyData {
+	if r == nil {
+		return nil
+	}
+
+	return &RawAllocationOnlyData{
+		CPUCoreUsageMax:  r.CPUCoreUsageMax,
+		RAMBytesUsageMax: r.RAMBytesUsageMax,
 	}
 }
 
@@ -139,7 +184,7 @@ func (a *Allocation) Equal(that *Allocation) bool {
 	if a.Name != that.Name {
 		return false
 	}
-	if !a.Properties.Equal(&that.Properties) {
+	if !a.Properties.Equal(that.Properties) {
 		return false
 	}
 	if !a.Window.Equal(that.Window) {
@@ -188,6 +233,22 @@ func (a *Allocation) Equal(that *Allocation) bool {
 		return false
 	}
 
+	if a.RawAllocationOnly == nil && that.RawAllocationOnly != nil {
+		return false
+	}
+	if a.RawAllocationOnly != nil && that.RawAllocationOnly == nil {
+		return false
+	}
+
+	if a.RawAllocationOnly != nil && that.RawAllocationOnly != nil {
+		if !util.IsApproximately(a.RawAllocationOnly.CPUCoreUsageMax, that.RawAllocationOnly.CPUCoreUsageMax) {
+			return false
+		}
+		if !util.IsApproximately(a.RawAllocationOnly.RAMBytesUsageMax, that.RawAllocationOnly.RAMBytesUsageMax) {
+			return false
+		}
+	}
+
 	return true
 }
 
@@ -293,7 +354,8 @@ func (a *Allocation) MarshalJSON() ([]byte, error) {
 	jsonEncodeFloat64(buffer, "sharedCost", a.SharedCost, ",")
 	jsonEncodeFloat64(buffer, "externalCost", a.ExternalCost, ",")
 	jsonEncodeFloat64(buffer, "totalCost", a.TotalCost(), ",")
-	jsonEncodeFloat64(buffer, "totalEfficiency", a.TotalEfficiency(), "")
+	jsonEncodeFloat64(buffer, "totalEfficiency", a.TotalEfficiency(), ",")
+	jsonEncode(buffer, "rawAllocationOnly", a.RawAllocationOnly, "")
 	buffer.WriteString("}")
 	return buffer.Bytes(), nil
 }
@@ -304,7 +366,7 @@ func (a *Allocation) Resolution() time.Duration {
 }
 
 // IsAggregated is true if the given Allocation has been aggregated, which we
-// define by a lack of Properties.
+// define by a lack of AllocationProperties.
 func (a *Allocation) IsAggregated() bool {
 	return a == nil || a.Properties == nil
 }
@@ -331,7 +393,7 @@ func (a *Allocation) Minutes() float64 {
 }
 
 // Share adds the TotalCost of the given Allocation to the SharedCost of the
-// receiving Allocation. No Start, End, Window, or Properties are considered.
+// receiving Allocation. No Start, End, Window, or AllocationProperties are considered.
 // Neither Allocation is mutated; a new Allocation is always returned.
 func (a *Allocation) Share(that *Allocation) (*Allocation, error) {
 	if that == nil {
@@ -358,27 +420,8 @@ func (a *Allocation) add(that *Allocation) {
 		log.Warningf("Allocation.AggregateBy: trying to add a nil receiver")
 		return
 	}
-
-	aCluster, _ := a.Properties.GetCluster()
-	thatCluster, _ := that.Properties.GetCluster()
-	aNode, _ := a.Properties.GetNode()
-	thatNode, _ := that.Properties.GetNode()
-
-	// reset properties
-	a.Properties = nil
-
-	// ensure that we carry cluster ID and/or node over if they're the same
-	// required for idle/shared cost allocation
-	if aCluster == thatCluster {
-		a.Properties = Properties{ClusterProp: aCluster}
-	}
-	if aNode == thatNode {
-		if a.Properties == nil {
-			a.Properties = Properties{NodeProp: aNode}
-		} else {
-			a.Properties.SetNode(aNode)
-		}
-	}
+	// Preserve string properties that are matching between the two allocations
+	a.Properties = a.Properties.Intersection(that.Properties)
 
 	// Expand the window to encompass both Allocations
 	a.Window = a.Window.Expand(that.Window)
@@ -435,6 +478,10 @@ func (a *Allocation) add(that *Allocation) {
 	a.LoadBalancerCost += that.LoadBalancerCost
 	a.SharedCost += that.SharedCost
 	a.ExternalCost += that.ExternalCost
+
+	// Any data that is in a "raw allocation only" is not valid in any
+	// sort of cumulative Allocation (like one that is added).
+	a.RawAllocationOnly = nil
 }
 
 // AllocationSet stores a set of Allocations, each with a unique name, that share
@@ -483,9 +530,9 @@ type AllocationAggregationOptions struct {
 }
 
 // AggregateBy aggregates the Allocations in the given AllocationSet by the given
-// Property. This will only be legal if the AllocationSet is divisible by the
-// given Property; e.g. Containers can be divided by Namespace, but not vice-a-versa.
-func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationAggregationOptions) error {
+// AllocationProperty. This will only be legal if the AllocationSet is divisible by the
+// given AllocationProperty; e.g. Containers can be divided by Namespace, but not vice-a-versa.
+func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAggregationOptions) error {
 	// The order of operations for aggregating allocations is as follows:
 	//  1. Partition external, idle, and shared allocations into separate sets.
 	//     Also, create the aggSet into which the results will be aggregated.
@@ -622,7 +669,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	//
 	// In order to maintain stable results when multiple operations are being
 	// carried out (e.g. sharing idle, sharing resources, and filtering) these
-	// coefficients are computed for the full set of allocaitons prior to
+	// coefficients are computed for the full set of allocations prior to
 	// adding shared overhead and prior to applying filters.
 
 	var err error
@@ -632,7 +679,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	// the shared allocations).
 	var idleCoefficients map[string]map[string]map[string]float64
 	if idleSet.Length() > 0 && options.ShareIdle != ShareNone {
-		idleCoefficients, err = computeIdleCoeffs(properties, options, as, shareSet)
+		idleCoefficients, err = computeIdleCoeffs(options, as, shareSet)
 		if err != nil {
 			log.Warningf("AllocationSet.AggregateBy: compute idle coeff: %s", err)
 			return fmt.Errorf("error computing idle coefficients: %s", err)
@@ -665,7 +712,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	// need to track this on a per-cluster, per-allocation, per-resource basis.
 	var idleFiltrationCoefficients map[string]map[string]map[string]float64
 	if len(options.FilterFuncs) > 0 && options.ShareIdle == ShareNone {
-		idleFiltrationCoefficients, err = computeIdleCoeffs(properties, options, as, shareSet)
+		idleFiltrationCoefficients, err = computeIdleCoeffs(options, as, shareSet)
 		if err != nil {
 			return fmt.Errorf("error computing idle filtration coefficients: %s", err)
 		}
@@ -691,7 +738,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 				Start:      as.Start(),
 				End:        as.End(),
 				SharedCost: totalSharedCost,
-				Properties: Properties{ClusterProp: SharedSuffix}, // The allocation needs to belong to a cluster,but it really doesn't matter which one, so just make it clear.
+				Properties: &AllocationProperties{Cluster: SharedSuffix}, // The allocation needs to belong to a cluster,but it really doesn't matter which one, so just make it clear.
 			})
 		}
 	}
@@ -701,7 +748,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	// of the main allocation set. See above for details and an example.
 	var shareCoefficients map[string]float64
 	if shareSet.Length() > 0 {
-		shareCoefficients, err = computeShareCoeffs(properties, options, as)
+		shareCoefficients, err = computeShareCoeffs(aggregateBy, options, as)
 		if err != nil {
 			return fmt.Errorf("error computing share coefficients: %s", err)
 		}
@@ -709,10 +756,10 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 
 	// (3-5) Filter, distribute idle cost, and aggregate (in that order)
 	for _, alloc := range as.allocations {
-		cluster, err := alloc.Properties.GetCluster()
-		if err != nil {
+		cluster := alloc.Properties.Cluster
+		if cluster == "" {
 			log.Warningf("AllocationSet.AggregateBy: missing cluster for allocation: %s", alloc.Name)
-			return err
+			return fmt.Errorf("ClusterProp is not set")
 		}
 
 		skip := false
@@ -747,9 +794,9 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			for _, idleAlloc := range idleSet.allocations {
 				// Only share idle if the cluster matches; i.e. the allocation
 				// is from the same cluster as the idle costs
-				idleCluster, err := idleAlloc.Properties.GetCluster()
-				if err != nil {
-					return err
+				idleCluster := idleAlloc.Properties.Cluster
+				if idleCluster == "" {
+					return fmt.Errorf("ClusterProp is not set")
 				}
 				if idleCluster != cluster {
 					continue
@@ -779,7 +826,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		}
 
 		// (5) generate key to use for aggregation-by-key and allocation name
-		key := alloc.generateKey(properties)
+		key := alloc.generateKey(aggregateBy)
 
 		alloc.Name = key
 		if options.MergeUnallocated && alloc.IsUnallocated() {
@@ -797,8 +844,8 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	// before sharing with the aggregated allocations.
 	if idleSet.Length() > 0 && shareSet.Length() > 0 {
 		for _, alloc := range shareSet.allocations {
-			cluster, err := alloc.Properties.GetCluster()
-			if err != nil {
+			cluster := alloc.Properties.Cluster
+			if cluster == "" {
 				log.Warningf("AllocationSet.AggregateBy: missing cluster for allocation: %s", alloc.Name)
 				return err
 			}
@@ -807,9 +854,9 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			for _, idleAlloc := range idleSet.allocations {
 				// Only share idle if the cluster matches; i.e. the allocation
 				// is from the same cluster as the idle costs
-				idleCluster, err := idleAlloc.Properties.GetCluster()
-				if err != nil {
-					return err
+				idleCluster := idleAlloc.Properties.Cluster
+				if idleCluster == "" {
+					return fmt.Errorf("ClusterProp is not set")
 				}
 				if idleCluster != cluster {
 					continue
@@ -870,8 +917,8 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		for idleKey := range aggSet.idleKeys {
 			idleAlloc := aggSet.Get(idleKey)
 
-			cluster, err := idleAlloc.Properties.GetCluster()
-			if err != nil {
+			cluster := idleAlloc.Properties.Cluster
+			if cluster == "" {
 				log.Warningf("AllocationSet.AggregateBy: idle allocation without cluster: %s", idleAlloc)
 				continue
 			}
@@ -912,7 +959,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			}
 		}
 		if !skip {
-			key := alloc.generateKey(properties)
+			key := alloc.generateKey(aggregateBy)
 
 			alloc.Name = key
 			aggSet.Insert(alloc)
@@ -933,7 +980,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	return nil
 }
 
-func computeShareCoeffs(properties Properties, options *AllocationAggregationOptions, as *AllocationSet) (map[string]float64, error) {
+func computeShareCoeffs(aggregateBy []string, options *AllocationAggregationOptions, as *AllocationSet) (map[string]float64, error) {
 	// Compute coeffs by totalling per-allocation, then dividing by the total.
 	coeffs := map[string]float64{}
 
@@ -953,7 +1000,7 @@ func computeShareCoeffs(properties Properties, options *AllocationAggregationOpt
 
 		// Determine the post-aggregation key under which the allocation will
 		// be shared.
-		name := alloc.generateKey(properties)
+		name := alloc.generateKey(aggregateBy)
 
 		// If the current allocation will be filtered out in step 3, contribute
 		// its share of the shared coefficient to a "__filtered__" bin, which
@@ -998,7 +1045,7 @@ func computeShareCoeffs(properties Properties, options *AllocationAggregationOpt
 	return coeffs, nil
 }
 
-func computeIdleCoeffs(properties Properties, options *AllocationAggregationOptions, as *AllocationSet, shareSet *AllocationSet) (map[string]map[string]map[string]float64, error) {
+func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet, shareSet *AllocationSet) (map[string]map[string]map[string]float64, error) {
 	types := []string{"cpu", "gpu", "ram"}
 
 	// Compute idle coefficients, then save them in AllocationAggregationOptions
@@ -1019,9 +1066,9 @@ func computeIdleCoeffs(properties Properties, options *AllocationAggregationOpti
 		}
 
 		// We need to key the allocations by cluster id
-		clusterID, err := alloc.Properties.GetCluster()
-		if err != nil {
-			return nil, err
+		clusterID := alloc.Properties.Cluster
+		if clusterID == "" {
+			return nil, fmt.Errorf("ClusterProp is not set")
 		}
 
 		// get the name key for the allocation
@@ -1066,9 +1113,9 @@ func computeIdleCoeffs(properties Properties, options *AllocationAggregationOpti
 		}
 
 		// We need to key the allocations by cluster id
-		clusterID, err := alloc.Properties.GetCluster()
-		if err != nil {
-			return nil, err
+		clusterID := alloc.Properties.Cluster
+		if clusterID == "" {
+			return nil, fmt.Errorf("ClusterProp is not set")
 		}
 
 		// get the name key for the allocation
@@ -1119,7 +1166,7 @@ func computeIdleCoeffs(properties Properties, options *AllocationAggregationOpti
 	return coeffs, nil
 }
 
-func (a *Allocation) generateKey(properties Properties) string {
+func (a *Allocation) generateKey(aggregateBy []string) string {
 	if a == nil {
 		return ""
 	}
@@ -1128,140 +1175,111 @@ func (a *Allocation) generateKey(properties Properties) string {
 	// identifies allocations.
 	names := []string{}
 
-	if properties.HasCluster() {
-		cluster, _ := a.Properties.GetCluster()
-		names = append(names, cluster)
-	}
-
-	if properties.HasNode() {
-		node, _ := a.Properties.GetNode()
-		names = append(names, node)
-	}
-
-	if properties.HasNamespace() {
-		namespace, _ := a.Properties.GetNamespace()
-		names = append(names, namespace)
-	}
-
-	if properties.HasControllerKind() {
-		controllerKind, err := a.Properties.GetControllerKind()
-		if err != nil {
-			// Indicate that allocation has no controller
-			controllerKind = UnallocatedSuffix
-		}
-
-		if prop, _ := properties.GetControllerKind(); prop != "" && prop != controllerKind {
-			// The allocation does not have the specified controller kind
-			controllerKind = UnallocatedSuffix
-		}
-		names = append(names, controllerKind)
-	}
-
-	if properties.HasController() {
-		if !properties.HasControllerKind() {
-			controllerKind, err := a.Properties.GetControllerKind()
-			if err == nil {
-				names = append(names, controllerKind)
+	for _, agg := range aggregateBy {
+		switch true {
+		case agg == AllocationClusterProp:
+			names = append(names, a.Properties.Cluster)
+		case agg == AllocationNodeProp:
+			names = append(names, a.Properties.Node)
+		case agg == AllocationNamespaceProp:
+			names = append(names, a.Properties.Namespace)
+		case agg == AllocationControllerKindProp:
+			controllerKind := a.Properties.ControllerKind
+			if controllerKind == "" {
+				// Indicate that allocation has no controller
+				controllerKind = UnallocatedSuffix
 			}
-		}
-
-		controller, err := a.Properties.GetController()
-		if err != nil {
-			// Indicate that allocation has no controller
-			controller = UnallocatedSuffix
-		}
-
-		names = append(names, controller)
-	}
-
-	if properties.HasPod() {
-		pod, _ := a.Properties.GetPod()
-		names = append(names, pod)
-	}
-
-	if properties.HasContainer() {
-		container, _ := a.Properties.GetContainer()
-		names = append(names, container)
-	}
-
-	if properties.HasService() {
-		services, err := a.Properties.GetServices()
-		if err != nil {
-			// Indicate that allocation has no services
-			names = append(names, UnallocatedSuffix)
-		} else {
-			if len(services) > 0 {
+			names = append(names, controllerKind)
+		case agg == AllocationDaemonSetProp || agg == AllocationStatefulSetProp || agg == AllocationDeploymentProp || agg == AllocationJobProp:
+			controller := a.Properties.Controller
+			if agg != a.Properties.ControllerKind || controller == "" {
+				// The allocation does not have the specified controller kind
+				controller = UnallocatedSuffix
+			}
+			names = append(names, controller)
+		case agg == AllocationControllerProp:
+			controller := a.Properties.Controller
+			if controller == "" {
+				// Indicate that allocation has no controller
+				controller = UnallocatedSuffix
+			} else if a.Properties.ControllerKind != "" {
+				controller = fmt.Sprintf("%s:%s", a.Properties.ControllerKind, controller)
+			}
+			names = append(names, controller)
+		case agg == AllocationPodProp:
+			names = append(names, a.Properties.Pod)
+		case agg == AllocationContainerProp:
+			names = append(names, a.Properties.Container)
+		case agg == AllocationServiceProp:
+			services := a.Properties.Services
+			if services == nil || len(services) == 0 {
+				// Indicate that allocation has no services
+				names = append(names, UnallocatedSuffix)
+			} else {
+				// This just uses the first service
 				for _, service := range services {
 					names = append(names, service)
 					break
 				}
-			} else {
-				// Indicate that allocation has no services
-				names = append(names, UnallocatedSuffix)
 			}
-		}
-	}
-
-	if properties.HasAnnotations() {
-		annotations, err := a.Properties.GetAnnotations()
-		if err != nil {
-			// Indicate that allocation has no annotations
-			names = append(names, UnallocatedSuffix)
-		} else {
-			annotationNames := []string{}
-
-			aggAnnotations, _ := properties.GetAnnotations()
-			for annotationName := range aggAnnotations {
-				if val, ok := annotations[annotationName]; ok {
-					annotationNames = append(annotationNames, fmt.Sprintf("%s=%s", annotationName, val))
-				} else if indexOf(UnallocatedSuffix, annotationNames) == -1 { // if UnallocatedSuffix not already in names
-					annotationNames = append(annotationNames, UnallocatedSuffix)
+		case strings.HasPrefix(agg, "label:"):
+			labels := a.Properties.Labels
+			if labels == nil {
+				// Indicate that allocation has no labels
+				names = append(names, UnallocatedSuffix)
+			} else {
+				labelNames := []string{}
+				aggLabels := strings.Split(strings.TrimPrefix(agg, "label:"), ";")
+				for _, labelName := range aggLabels {
+					if val, ok := labels[labelName]; ok {
+						labelNames = append(labelNames, fmt.Sprintf("%s=%s", labelName, val))
+					} else if indexOf(UnallocatedSuffix, labelNames) == -1 { // if UnallocatedSuffix not already in names
+						labelNames = append(labelNames, UnallocatedSuffix)
+					}
 				}
-			}
-			// resolve arbitrary ordering. e.g., app=app0/env=env0 is the same agg as env=env0/app=app0
-			if len(annotationNames) > 1 {
-				sort.Strings(annotationNames)
-			}
-			unallocatedSuffixIndex := indexOf(UnallocatedSuffix, annotationNames)
-			// suffix should be at index 0 if it exists b/c of underscores
-			if unallocatedSuffixIndex != -1 {
-				annotationNames = append(annotationNames[:unallocatedSuffixIndex], annotationNames[unallocatedSuffixIndex+1:]...)
-				annotationNames = append(annotationNames, UnallocatedSuffix) // append to end
-			}
-
-			names = append(names, annotationNames...)
-		}
-	}
-
-	if properties.HasLabel() {
-		labels, err := a.Properties.GetLabels()
-		if err != nil {
-			// Indicate that allocation has no labels
-			names = append(names, UnallocatedSuffix)
-		} else {
-			labelNames := []string{}
-
-			aggLabels, _ := properties.GetLabels()
-			for labelName := range aggLabels {
-				if val, ok := labels[labelName]; ok {
-					labelNames = append(labelNames, fmt.Sprintf("%s=%s", labelName, val))
-				} else if indexOf(UnallocatedSuffix, labelNames) == -1 { // if UnallocatedSuffix not already in names
-					labelNames = append(labelNames, UnallocatedSuffix)
+				// resolve arbitrary ordering. e.g., app=app0/env=env0 is the same agg as env=env0/app=app0
+				if len(labelNames) > 1 {
+					sort.Strings(labelNames)
 				}
+				unallocatedSuffixIndex := indexOf(UnallocatedSuffix, labelNames)
+				// suffix should be at index 0 if it exists b/c of underscores
+				if unallocatedSuffixIndex != -1 {
+					labelNames = append(labelNames[:unallocatedSuffixIndex], labelNames[unallocatedSuffixIndex+1:]...)
+					labelNames = append(labelNames, UnallocatedSuffix) // append to end
+				}
+
+				names = append(names, labelNames...)
 			}
-			// resolve arbitrary ordering. e.g., app=app0/env=env0 is the same agg as env=env0/app=app0
-			if len(labelNames) > 1 {
-				sort.Strings(labelNames)
-			}
-			unallocatedSuffixIndex := indexOf(UnallocatedSuffix, labelNames)
-			// suffix should be at index 0 if it exists b/c of underscores
-			if unallocatedSuffixIndex != -1 {
-				labelNames = append(labelNames[:unallocatedSuffixIndex], labelNames[unallocatedSuffixIndex+1:]...)
-				labelNames = append(labelNames, UnallocatedSuffix) // append to end
-			}
+		case strings.HasPrefix(agg, "annotation:"):
+			annotations := a.Properties.Annotations
+			if annotations == nil {
+				// Indicate that allocation has no annotations
+				names = append(names, UnallocatedSuffix)
+			} else {
+				annotationNames := []string{}
+				aggAnnotations := strings.Split(strings.TrimPrefix(agg, "annotation:"), ";")
+				for _, annotationName := range aggAnnotations {
+					if val, ok := annotations[annotationName]; ok {
+						annotationNames = append(annotationNames, fmt.Sprintf("%s=%s", annotationName, val))
+					} else if indexOf(UnallocatedSuffix, annotationNames) == -1 { // if UnallocatedSuffix not already in names
+						annotationNames = append(annotationNames, UnallocatedSuffix)
+					}
+				}
+				// resolve arbitrary ordering. e.g., app=app0/env=env0 is the same agg as env=env0/app=app0
+				if len(annotationNames) > 1 {
+					sort.Strings(annotationNames)
+				}
+				unallocatedSuffixIndex := indexOf(UnallocatedSuffix, annotationNames)
+				// suffix should be at index 0 if it exists b/c of underscores
+				if unallocatedSuffixIndex != -1 {
+					annotationNames = append(annotationNames[:unallocatedSuffixIndex], annotationNames[unallocatedSuffixIndex+1:]...)
+					annotationNames = append(annotationNames, UnallocatedSuffix) // append to end
+				}
 
-			names = append(names, labelNames...)
+				names = append(names, annotationNames...)
+			}
 		}
+
 	}
 
 	return strings.Join(names, "/")
@@ -1379,8 +1397,8 @@ func (as *AllocationSet) ComputeIdleAllocations(assetSet *AssetSet) (map[string]
 	// Subtract allocated costs from asset costs, leaving only the remaining
 	// idle costs.
 	as.Each(func(name string, a *Allocation) {
-		cluster, err := a.Properties.GetCluster()
-		if err != nil {
+		cluster := a.Properties.Cluster
+		if cluster == "" {
 			// Failed to find allocation's cluster
 			return
 		}
@@ -1422,7 +1440,7 @@ func (as *AllocationSet) ComputeIdleAllocations(assetSet *AssetSet) (map[string]
 		idleAlloc := &Allocation{
 			Name:       fmt.Sprintf("%s/%s", cluster, IdleSuffix),
 			Window:     window.Clone(),
-			Properties: Properties{ClusterProp: cluster},
+			Properties: &AllocationProperties{Cluster: cluster},
 			Start:      start,
 			End:        end,
 			CPUCost:    resources["cpu"],
@@ -1800,14 +1818,14 @@ func (asr *AllocationSetRange) Accumulate() (*AllocationSet, error) {
 
 // AggregateBy aggregates each AllocationSet in the range by the given
 // properties and options.
-func (asr *AllocationSetRange) AggregateBy(properties Properties, options *AllocationAggregationOptions) error {
+func (asr *AllocationSetRange) AggregateBy(aggregateBy []string, options *AllocationAggregationOptions) error {
 	aggRange := &AllocationSetRange{allocations: []*AllocationSet{}}
 
 	asr.Lock()
 	defer asr.Unlock()
 
 	for _, as := range asr.allocations {
-		err := as.AggregateBy(properties, options)
+		err := as.AggregateBy(aggregateBy, options)
 		if err != nil {
 			return err
 		}

+ 220 - 177
pkg/kubecost/allocation_test.go

@@ -12,20 +12,20 @@ import (
 
 const day = 24 * time.Hour
 
-func NewUnitAllocation(name string, start time.Time, resolution time.Duration, props *Properties) *Allocation {
+func NewUnitAllocation(name string, start time.Time, resolution time.Duration, props *AllocationProperties) *Allocation {
 	if name == "" {
 		name = "cluster1/namespace1/pod1/container1"
 	}
 
-	properties := &Properties{}
+	properties := &AllocationProperties{}
 	if props == nil {
-		properties.SetCluster("cluster1")
-		properties.SetNode("node1")
-		properties.SetNamespace("namespace1")
-		properties.SetControllerKind("deployment")
-		properties.SetController("deployment1")
-		properties.SetPod("pod1")
-		properties.SetContainer("container1")
+		properties.Cluster = "cluster1"
+		properties.Node = "node1"
+		properties.Namespace = "namespace1"
+		properties.ControllerKind = "deployment"
+		properties.Controller = "deployment1"
+		properties.Pod = "pod1"
+		properties.Container = "container1"
 	} else {
 		properties = props
 	}
@@ -34,7 +34,7 @@ func NewUnitAllocation(name string, start time.Time, resolution time.Duration, p
 
 	alloc := &Allocation{
 		Name:                   name,
-		Properties:             *properties,
+		Properties:             properties,
 		Window:                 NewWindow(&start, &end).Clone(),
 		Start:                  start,
 		End:                    end,
@@ -52,6 +52,10 @@ func NewUnitAllocation(name string, start time.Time, resolution time.Duration, p
 		RAMCost:                1,
 		RAMBytesRequestAverage: 1,
 		RAMBytesUsageAverage:   1,
+		RawAllocationOnly: &RawAllocationOnlyData{
+			CPUCoreUsageMax:  1,
+			RAMBytesUsageMax: 1,
+		},
 	}
 
 	// If idle allocation, remove non-idle costs, but maintain total cost
@@ -103,6 +107,7 @@ func TestAllocation_Add(t *testing.T) {
 	a1 := &Allocation{
 		Start:                  s1,
 		End:                    e1,
+		Properties:             &AllocationProperties{},
 		CPUCoreHours:           2.0 * hrs1,
 		CPUCoreRequestAverage:  2.0,
 		CPUCoreUsageAverage:    1.0,
@@ -117,6 +122,7 @@ func TestAllocation_Add(t *testing.T) {
 		RAMCost:                8.0 * hrs1 * ramPrice,
 		SharedCost:             2.00,
 		ExternalCost:           1.00,
+		RawAllocationOnly:      &RawAllocationOnlyData{},
 	}
 	a1b := a1.Clone()
 
@@ -126,6 +132,7 @@ func TestAllocation_Add(t *testing.T) {
 	a2 := &Allocation{
 		Start:                  s2,
 		End:                    e2,
+		Properties:             &AllocationProperties{},
 		CPUCoreHours:           1.0 * hrs2,
 		CPUCoreRequestAverage:  1.0,
 		CPUCoreUsageAverage:    1.0,
@@ -142,6 +149,7 @@ func TestAllocation_Add(t *testing.T) {
 		LoadBalancerCost:       0.05,
 		SharedCost:             0.00,
 		ExternalCost:           1.00,
+		RawAllocationOnly:      &RawAllocationOnlyData{},
 	}
 	a2b := a2.Clone()
 
@@ -237,6 +245,10 @@ func TestAllocation_Add(t *testing.T) {
 	if !util.IsApproximately(1.6493506, act.TotalEfficiency()) {
 		t.Fatalf("Allocation.Add: expected %f; actual %f", 1.6493506, act.TotalEfficiency())
 	}
+
+	if act.RawAllocationOnly != nil {
+		t.Errorf("Allocation.Add: Raw only data must be nil after an add")
+	}
 }
 
 func TestAllocation_Share(t *testing.T) {
@@ -252,6 +264,7 @@ func TestAllocation_Share(t *testing.T) {
 	a1 := &Allocation{
 		Start:                  s1,
 		End:                    e1,
+		Properties:             &AllocationProperties{},
 		CPUCoreHours:           2.0 * hrs1,
 		CPUCoreRequestAverage:  2.0,
 		CPUCoreUsageAverage:    1.0,
@@ -275,6 +288,7 @@ func TestAllocation_Share(t *testing.T) {
 	a2 := &Allocation{
 		Start:                  s2,
 		End:                    e2,
+		Properties:             &AllocationProperties{},
 		CPUCoreHours:           1.0 * hrs2,
 		CPUCoreRequestAverage:  1.0,
 		CPUCoreUsageAverage:    1.0,
@@ -397,12 +411,12 @@ func TestAllocation_MarshalJSON(t *testing.T) {
 
 	before := &Allocation{
 		Name: "cluster1/namespace1/node1/pod1/container1",
-		Properties: Properties{
-			ClusterProp:   "cluster1",
-			NodeProp:      "node1",
-			NamespaceProp: "namespace1",
-			PodProp:       "pod1",
-			ContainerProp: "container1",
+		Properties: &AllocationProperties{
+			Cluster:   "cluster1",
+			Node:      "node1",
+			Namespace: "namespace1",
+			Pod:       "pod1",
+			Container: "container1",
 		},
 		Window:                 NewWindow(&start, &end),
 		Start:                  start,
@@ -423,6 +437,7 @@ func TestAllocation_MarshalJSON(t *testing.T) {
 		RAMCost:                8.0 * hrs * ramPrice,
 		SharedCost:             2.00,
 		ExternalCost:           1.00,
+		RawAllocationOnly:      &RawAllocationOnlyData{},
 	}
 
 	data, err := json.Marshal(before)
@@ -449,8 +464,9 @@ func TestAllocationSet_generateKey(t *testing.T) {
 	var alloc *Allocation
 	var key string
 
-	props := Properties{}
-	props.SetCluster("")
+	props := []string{
+		AllocationClusterProp,
+	}
 
 	key = alloc.generateKey(props)
 	if key != "" {
@@ -458,9 +474,9 @@ func TestAllocationSet_generateKey(t *testing.T) {
 	}
 
 	alloc = &Allocation{}
-	alloc.Properties = Properties{
-		ClusterProp: "cluster1",
-		LabelProp: map[string]string{
+	alloc.Properties = &AllocationProperties{
+		Cluster: "cluster1",
+		Labels: map[string]string{
 			"app": "app1",
 			"env": "env1",
 		},
@@ -471,18 +487,21 @@ func TestAllocationSet_generateKey(t *testing.T) {
 		t.Fatalf("generateKey: expected \"cluster1\"; actual \"%s\"", key)
 	}
 
-	props.SetNamespace("")
-	props.SetLabels(map[string]string{"app": ""})
+	props = []string{
+		AllocationClusterProp,
+		AllocationNamespaceProp,
+		"label:app",
+	}
 
 	key = alloc.generateKey(props)
 	if key != "cluster1//app=app1" {
 		t.Fatalf("generateKey: expected \"cluster1//app=app1\"; actual \"%s\"", key)
 	}
 
-	alloc.Properties = Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace1",
-		LabelProp: map[string]string{
+	alloc.Properties = &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace1",
+		Labels: map[string]string{
 			"app": "app1",
 			"env": "env1",
 		},
@@ -499,153 +518,153 @@ func TestNewAllocationSet(t *testing.T) {
 
 func generateAllocationSet(start time.Time) *AllocationSet {
 	// Idle allocations
-	a1i := NewUnitAllocation(fmt.Sprintf("cluster1/%s", IdleSuffix), start, day, &Properties{
-		ClusterProp: "cluster1",
-		NodeProp:    "node1",
+	a1i := NewUnitAllocation(fmt.Sprintf("cluster1/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster: "cluster1",
+		Node:    "node1",
 	})
 	a1i.CPUCost = 5.0
 	a1i.RAMCost = 15.0
 	a1i.GPUCost = 0.0
 
-	a2i := NewUnitAllocation(fmt.Sprintf("cluster2/%s", IdleSuffix), start, day, &Properties{
-		ClusterProp: "cluster2",
+	a2i := NewUnitAllocation(fmt.Sprintf("cluster2/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster: "cluster2",
 	})
 	a2i.CPUCost = 5.0
 	a2i.RAMCost = 5.0
 	a2i.GPUCost = 0.0
 
 	// Active allocations
-	a1111 := NewUnitAllocation("cluster1/namespace1/pod1/container1", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace1",
-		PodProp:       "pod1",
-		ContainerProp: "container1",
+	a1111 := NewUnitAllocation("cluster1/namespace1/pod1/container1", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace1",
+		Pod:       "pod1",
+		Container: "container1",
 	})
 	a1111.RAMCost = 11.00
 
-	a11abc2 := NewUnitAllocation("cluster1/namespace1/pod-abc/container2", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace1",
-		PodProp:       "pod-abc",
-		ContainerProp: "container2",
+	a11abc2 := NewUnitAllocation("cluster1/namespace1/pod-abc/container2", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace1",
+		Pod:       "pod-abc",
+		Container: "container2",
 	})
 
-	a11def3 := NewUnitAllocation("cluster1/namespace1/pod-def/container3", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace1",
-		PodProp:       "pod-def",
-		ContainerProp: "container3",
+	a11def3 := NewUnitAllocation("cluster1/namespace1/pod-def/container3", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace1",
+		Pod:       "pod-def",
+		Container: "container3",
 	})
 
-	a12ghi4 := NewUnitAllocation("cluster1/namespace2/pod-ghi/container4", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-ghi",
-		ContainerProp: "container4",
+	a12ghi4 := NewUnitAllocation("cluster1/namespace2/pod-ghi/container4", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace2",
+		Pod:       "pod-ghi",
+		Container: "container4",
 	})
 
-	a12ghi5 := NewUnitAllocation("cluster1/namespace2/pod-ghi/container5", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-ghi",
-		ContainerProp: "container5",
+	a12ghi5 := NewUnitAllocation("cluster1/namespace2/pod-ghi/container5", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace2",
+		Pod:       "pod-ghi",
+		Container: "container5",
 	})
 
-	a12jkl6 := NewUnitAllocation("cluster1/namespace2/pod-jkl/container6", start, day, &Properties{
-		ClusterProp:   "cluster1",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-jkl",
-		ContainerProp: "container6",
+	a12jkl6 := NewUnitAllocation("cluster1/namespace2/pod-jkl/container6", start, day, &AllocationProperties{
+		Cluster:   "cluster1",
+		Namespace: "namespace2",
+		Pod:       "pod-jkl",
+		Container: "container6",
 	})
 
-	a22mno4 := NewUnitAllocation("cluster2/namespace2/pod-mno/container4", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-mno",
-		ContainerProp: "container4",
+	a22mno4 := NewUnitAllocation("cluster2/namespace2/pod-mno/container4", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace2",
+		Pod:       "pod-mno",
+		Container: "container4",
 	})
 
-	a22mno5 := NewUnitAllocation("cluster2/namespace2/pod-mno/container5", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-mno",
-		ContainerProp: "container5",
+	a22mno5 := NewUnitAllocation("cluster2/namespace2/pod-mno/container5", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace2",
+		Pod:       "pod-mno",
+		Container: "container5",
 	})
 
-	a22pqr6 := NewUnitAllocation("cluster2/namespace2/pod-pqr/container6", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace2",
-		PodProp:       "pod-pqr",
-		ContainerProp: "container6",
+	a22pqr6 := NewUnitAllocation("cluster2/namespace2/pod-pqr/container6", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace2",
+		Pod:       "pod-pqr",
+		Container: "container6",
 	})
 
-	a23stu7 := NewUnitAllocation("cluster2/namespace3/pod-stu/container7", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace3",
-		PodProp:       "pod-stu",
-		ContainerProp: "container7",
+	a23stu7 := NewUnitAllocation("cluster2/namespace3/pod-stu/container7", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace3",
+		Pod:       "pod-stu",
+		Container: "container7",
 	})
 
-	a23vwx8 := NewUnitAllocation("cluster2/namespace3/pod-vwx/container8", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace3",
-		PodProp:       "pod-vwx",
-		ContainerProp: "container8",
+	a23vwx8 := NewUnitAllocation("cluster2/namespace3/pod-vwx/container8", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace3",
+		Pod:       "pod-vwx",
+		Container: "container8",
 	})
 
-	a23vwx9 := NewUnitAllocation("cluster2/namespace3/pod-vwx/container9", start, day, &Properties{
-		ClusterProp:   "cluster2",
-		NamespaceProp: "namespace3",
-		PodProp:       "pod-vwx",
-		ContainerProp: "container9",
+	a23vwx9 := NewUnitAllocation("cluster2/namespace3/pod-vwx/container9", start, day, &AllocationProperties{
+		Cluster:   "cluster2",
+		Namespace: "namespace3",
+		Pod:       "pod-vwx",
+		Container: "container9",
 	})
 
 	// Controllers
 
-	a11abc2.Properties.SetControllerKind("deployment")
-	a11abc2.Properties.SetController("deployment1")
-	a11def3.Properties.SetControllerKind("deployment")
-	a11def3.Properties.SetController("deployment1")
-
-	a12ghi4.Properties.SetControllerKind("deployment")
-	a12ghi4.Properties.SetController("deployment2")
-	a12ghi5.Properties.SetControllerKind("deployment")
-	a12ghi5.Properties.SetController("deployment2")
-	a22mno4.Properties.SetControllerKind("deployment")
-	a22mno4.Properties.SetController("deployment2")
-	a22mno5.Properties.SetControllerKind("deployment")
-	a22mno5.Properties.SetController("deployment2")
-
-	a23stu7.Properties.SetControllerKind("deployment")
-	a23stu7.Properties.SetController("deployment3")
-
-	a12jkl6.Properties.SetControllerKind("daemonset")
-	a12jkl6.Properties.SetController("daemonset1")
-	a22pqr6.Properties.SetControllerKind("daemonset")
-	a22pqr6.Properties.SetController("daemonset1")
-
-	a23vwx8.Properties.SetControllerKind("statefulset")
-	a23vwx8.Properties.SetController("statefulset1")
-	a23vwx9.Properties.SetControllerKind("statefulset")
-	a23vwx9.Properties.SetController("statefulset1")
+	a11abc2.Properties.ControllerKind = "deployment"
+	a11abc2.Properties.Controller = "deployment1"
+	a11def3.Properties.ControllerKind = "deployment"
+	a11def3.Properties.Controller = "deployment1"
+
+	a12ghi4.Properties.ControllerKind = "deployment"
+	a12ghi4.Properties.Controller = "deployment2"
+	a12ghi5.Properties.ControllerKind = "deployment"
+	a12ghi5.Properties.Controller = "deployment2"
+	a22mno4.Properties.ControllerKind = "deployment"
+	a22mno4.Properties.Controller = "deployment2"
+	a22mno5.Properties.ControllerKind = "deployment"
+	a22mno5.Properties.Controller = "deployment2"
+
+	a23stu7.Properties.ControllerKind = "deployment"
+	a23stu7.Properties.Controller = "deployment3"
+
+	a12jkl6.Properties.ControllerKind = "daemonset"
+	a12jkl6.Properties.Controller = "daemonset1"
+	a22pqr6.Properties.ControllerKind = "daemonset"
+	a22pqr6.Properties.Controller = "daemonset1"
+
+	a23vwx8.Properties.ControllerKind = "statefulset"
+	a23vwx8.Properties.Controller = "statefulset1"
+	a23vwx9.Properties.ControllerKind = "statefulset"
+	a23vwx9.Properties.Controller = "statefulset1"
 
 	// Labels
 
-	a1111.Properties.SetLabels(map[string]string{"app": "app1", "env": "env1"})
-	a12ghi4.Properties.SetLabels(map[string]string{"app": "app2", "env": "env2"})
-	a12ghi5.Properties.SetLabels(map[string]string{"app": "app2", "env": "env2"})
-	a22mno4.Properties.SetLabels(map[string]string{"app": "app2"})
-	a22mno5.Properties.SetLabels(map[string]string{"app": "app2"})
+	a1111.Properties.Labels = map[string]string{"app": "app1", "env": "env1"}
+	a12ghi4.Properties.Labels = map[string]string{"app": "app2", "env": "env2"}
+	a12ghi5.Properties.Labels = map[string]string{"app": "app2", "env": "env2"}
+	a22mno4.Properties.Labels = map[string]string{"app": "app2"}
+	a22mno5.Properties.Labels = map[string]string{"app": "app2"}
 
 	//Annotations
-	a23stu7.Properties.SetAnnotations(map[string]string{"team": "team1"})
-	a23vwx8.Properties.SetAnnotations(map[string]string{"team": "team2"})
-	a23vwx9.Properties.SetAnnotations(map[string]string{"team": "team1"})
+	a23stu7.Properties.Annotations = map[string]string{"team": "team1"}
+	a23vwx8.Properties.Annotations = map[string]string{"team": "team2"}
+	a23vwx9.Properties.Annotations = map[string]string{"team": "team1"}
 
 	// Services
 
-	a12jkl6.Properties.SetServices([]string{"service1"})
-	a22pqr6.Properties.SetServices([]string{"service1"})
+	a12jkl6.Properties.Services = []string{"service1"}
+	a22pqr6.Properties.Services = []string{"service1"}
 
 	return NewAllocationSet(start, start.Add(day),
 		// idle
@@ -824,12 +843,12 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	sharedOverheadHourlyCost := 7.0
 
 	isNamespace3 := func(a *Allocation) bool {
-		ns, err := a.Properties.GetNamespace()
-		return err == nil && ns == "namespace3"
+		ns := a.Properties.Namespace
+		return ns == "namespace3"
 	}
 
 	isApp1 := func(a *Allocation) bool {
-		ls, _ := a.Properties.GetLabels()
+		ls := a.Properties.Labels
 		if app, ok := ls["app"]; ok && app == "app1" {
 			return true
 		}
@@ -845,7 +864,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1a AggregationProperties=(Cluster)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ClusterProp: ""}, nil)
+	err = as.AggregateBy([]string{AllocationClusterProp}, nil)
 	assertAllocationSetTotals(t, as, "1a", err, numClusters+numIdle, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1a", map[string]float64{
 		"cluster1": 46.00,
@@ -856,7 +875,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1b AggregationProperties=(Namespace)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, nil)
 	assertAllocationSetTotals(t, as, "1b", err, numNamespaces+numIdle, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1b", map[string]float64{
 		"namespace1": 28.00,
@@ -868,7 +887,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1c AggregationProperties=(Pod)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{PodProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationPodProp}, nil)
 	assertAllocationSetTotals(t, as, "1c", err, numPods+numIdle, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1c", map[string]float64{
 		"pod-jkl":  6.00,
@@ -886,7 +905,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1d AggregationProperties=(Container)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ContainerProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationContainerProp}, nil)
 	assertAllocationSetTotals(t, as, "1d", err, numContainers+numIdle, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1d", map[string]float64{
 		"container2": 6.00,
@@ -904,7 +923,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1e AggregationProperties=(ControllerKind)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ControllerKindProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationControllerKindProp}, nil)
 	assertAllocationSetTotals(t, as, "1e", err, numControllerKinds+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1e", map[string]float64{
 		"daemonset":       12.00,
@@ -917,14 +936,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1f AggregationProperties=(Controller)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ControllerProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationControllerProp}, nil)
 	assertAllocationSetTotals(t, as, "1f", err, numControllers+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1f", map[string]float64{
-		"deployment/deployment2":   24.00,
-		"daemonset/daemonset1":     12.00,
-		"deployment/deployment3":   6.00,
-		"statefulset/statefulset1": 12.00,
-		"deployment/deployment1":   12.00,
+		"deployment:deployment2":   24.00,
+		"daemonset:daemonset1":     12.00,
+		"deployment:deployment3":   6.00,
+		"statefulset:statefulset1": 12.00,
+		"deployment:deployment1":   12.00,
 		IdleSuffix:                 30.00,
 		UnallocatedSuffix:          16.00,
 	})
@@ -932,7 +951,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1g AggregationProperties=(Service)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ServiceProp: true}, nil)
+	err = as.AggregateBy([]string{AllocationServiceProp}, nil)
 	assertAllocationSetTotals(t, as, "1g", err, numServices+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1g", map[string]float64{
 		"service1":        12.00,
@@ -943,7 +962,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1h AggregationProperties=(Label:app)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{LabelProp: map[string]string{"app": ""}}, nil)
+	err = as.AggregateBy([]string{"label:app"}, nil)
 	assertAllocationSetTotals(t, as, "1h", err, numLabelApps+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1h", map[string]float64{
 		"app=app1":        16.00,
@@ -953,12 +972,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	})
 	assertAllocationWindow(t, as, "1h", startYesterday, endYesterday, 1440.0)
 
-	// 1i AggregationProperties=(ControllerKind:deployment)
+	// 1i AggregationProperties=(deployment)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ControllerKindProp: "deployment"}, nil)
-	assertAllocationSetTotals(t, as, "1i", err, 1+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
+	err = as.AggregateBy([]string{AllocationDeploymentProp}, nil)
+	assertAllocationSetTotals(t, as, "1i", err, 3+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1i", map[string]float64{
-		"deployment":      42.00,
+		"deployment1":     12.00,
+		"deployment2":     24.00,
+		"deployment3":     6.00,
 		IdleSuffix:        30.00,
 		UnallocatedSuffix: 40.00,
 	})
@@ -966,7 +987,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 1j AggregationProperties=(Annotation:team)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{AnnotationProp: map[string]string{"team": ""}}, nil)
+	err = as.AggregateBy([]string{"annotation:team"}, nil)
 	assertAllocationSetTotals(t, as, "1j", err, 2+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "1j", map[string]float64{
 		"team=team1":      12.00,
@@ -974,7 +995,29 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		IdleSuffix:        30.00,
 		UnallocatedSuffix: 64.00,
 	})
-	assertAllocationWindow(t, as, "1i", startYesterday, endYesterday, 1440.0)
+	assertAllocationWindow(t, as, "1j", startYesterday, endYesterday, 1440.0)
+
+	// 1k AggregationProperties=(daemonSet)
+	as = generateAllocationSet(start)
+	err = as.AggregateBy([]string{AllocationDaemonSetProp}, nil)
+	assertAllocationSetTotals(t, as, "1k", err, 1+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
+	assertAllocationTotals(t, as, "1k", map[string]float64{
+		"daemonset1":      12.00,
+		IdleSuffix:        30.00,
+		UnallocatedSuffix: 70.00,
+	})
+	assertAllocationWindow(t, as, "1k", startYesterday, endYesterday, 1440.0)
+
+	// 1l AggregationProperties=(statefulSet)
+	as = generateAllocationSet(start)
+	err = as.AggregateBy([]string{AllocationStatefulSetProp}, nil)
+	assertAllocationSetTotals(t, as, "1l", err, 1+numIdle+numUnallocated, activeTotalCost+idleTotalCost)
+	assertAllocationTotals(t, as, "1l", map[string]float64{
+		"statefulset1":    12.00,
+		IdleSuffix:        30.00,
+		UnallocatedSuffix: 70.00,
+	})
+	assertAllocationWindow(t, as, "1l", startYesterday, endYesterday, 1440.0)
 
 	// 2  Multi-aggregation
 
@@ -984,7 +1027,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 2d AggregationProperties=(Label:app, Label:environment)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{LabelProp: map[string]string{"app": "", "env": ""}}, nil)
+	err = as.AggregateBy([]string{"label:app;env"}, nil)
 	// sets should be {idle, unallocated, app1/env1, app2/env2, app2/unallocated}
 	assertAllocationSetTotals(t, as, "2d", err, numIdle+numUnallocated+3, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "2d", map[string]float64{
@@ -997,7 +1040,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 2e AggregationProperties=(Cluster, Label:app, Label:environment)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ClusterProp: "", LabelProp: map[string]string{"app": "", "env": ""}}, nil)
+	err = as.AggregateBy([]string{AllocationClusterProp, "label:app;env"}, nil)
 	assertAllocationSetTotals(t, as, "2e", err, 6, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "2e", map[string]float64{
 		"cluster1/app=app2/env=env2":             12.00,
@@ -1010,9 +1053,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 2f AggregationProperties=(annotation:team, pod)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{AnnotationProp: map[string]string{"team": ""}, PodProp: ""}, nil)
-	assertAllocationSetTotals(t, as, "2e", err, 11, activeTotalCost+idleTotalCost)
-	assertAllocationTotals(t, as, "2e", map[string]float64{
+	err = as.AggregateBy([]string{AllocationPodProp, "annotation:team"}, nil)
+	assertAllocationSetTotals(t, as, "2f", err, 11, activeTotalCost+idleTotalCost)
+	assertAllocationTotals(t, as, "2f", map[string]float64{
 		"pod-jkl/" + UnallocatedSuffix: 6.00,
 		"pod-stu/team=team1":           6.00,
 		"pod-abc/" + UnallocatedSuffix: 6.00,
@@ -1035,7 +1078,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace2: 46.3125 = 36.00 + 5.0*(3.0/6.0) + 15.0*(3.0/16.0) + 5.0*(3.0/6.0) + 5.0*(3.0/6.0)
 	// namespace3: 23.0000 = 18.00 + 5.0*(3.0/6.0) + 5.0*(3.0/6.0)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{ShareIdle: ShareWeighted})
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{ShareIdle: ShareWeighted})
 	assertAllocationSetTotals(t, as, "3a", err, numNamespaces, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "3a", map[string]float64{
 		"namespace1": 42.69,
@@ -1049,7 +1092,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace2: 51.0000 = 36.00 + 5.0*(1.0/2.0) + 15.0*(1.0/2.0) + 5.0*(1.0/2.0) + 5.0*(1.0/2.0)
 	// namespace3: 23.0000 = 18.00 + 5.0*(1.0/2.0) + 5.0*(1.0/2.0)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{ShareIdle: ShareEven})
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{ShareIdle: ShareEven})
 	assertAllocationSetTotals(t, as, "3a", err, numNamespaces, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "3a", map[string]float64{
 		"namespace1": 38.00,
@@ -1065,7 +1108,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace2: 45.5000 = 36.00 + 18.00*(1.0/2.0)
 	// idle:       30.0000
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		ShareFuncs: []AllocationMatchFunc{isNamespace3},
 		ShareSplit: ShareEven,
 	})
@@ -1082,7 +1125,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace2: 37.5000 =
 	// idle:       30.0000
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		ShareFuncs: []AllocationMatchFunc{isNamespace3},
 		ShareSplit: ShareWeighted,
 	})
@@ -1100,7 +1143,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace3: 23.3333 = 18.00 + 16.00*(1.0/3.0)
 	// idle:       30.0000
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		ShareFuncs: []AllocationMatchFunc{isApp1},
 		ShareSplit: ShareEven,
 	})
@@ -1119,7 +1162,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace3: 54.878 = 18.00 + (7.0*24.0)*(18.00/82.00)
 	// idle:       30.0000
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: true}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 		ShareSplit:        ShareWeighted,
 	})
@@ -1136,21 +1179,21 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	isCluster := func(matchCluster string) func(*Allocation) bool {
 		return func(a *Allocation) bool {
-			cluster, err := a.Properties.GetCluster()
-			return err == nil && cluster == matchCluster
+			cluster := a.Properties.Cluster
+			return cluster == matchCluster
 		}
 	}
 
 	isNamespace := func(matchNamespace string) func(*Allocation) bool {
 		return func(a *Allocation) bool {
-			namespace, err := a.Properties.GetNamespace()
-			return err == nil && namespace == matchNamespace
+			namespace := a.Properties.Namespace
+			return namespace == matchNamespace
 		}
 	}
 
 	// 5a Filter by cluster with separate idle
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ClusterProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationClusterProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 		ShareIdle:   ShareNone,
 	})
@@ -1163,7 +1206,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 5b Filter by cluster with shared idle
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ClusterProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationClusterProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 		ShareIdle:   ShareWeighted,
 	})
@@ -1175,7 +1218,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 5c Filter by cluster, agg by namespace, with separate idle
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 		ShareIdle:   ShareNone,
 	})
@@ -1189,7 +1232,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 5d Filter by namespace, agg by cluster, with separate idle
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{ClusterProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationClusterProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareIdle:   ShareNone,
 	})
@@ -1205,7 +1248,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// 6a SplitIdle
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{SplitIdle: true})
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{SplitIdle: true})
 	assertAllocationSetTotals(t, as, "6a", err, numNamespaces+numSplitIdle, activeTotalCost+idleTotalCost)
 	assertAllocationTotals(t, as, "6a", map[string]float64{
 		"namespace1":                           28.00,
@@ -1220,7 +1263,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// Should match values from unfiltered aggregation (3a)
 	// namespace2: 46.3125 = 36.00 + 5.0*(3.0/6.0) + 15.0*(3.0/16.0) + 5.0*(3.0/6.0) + 5.0*(3.0/6.0)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareIdle:   ShareWeighted,
 	})
@@ -1234,7 +1277,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// Should match values from unfiltered aggregation (3b)
 	// namespace2: 51.0000 = 36.00 + 5.0*(1.0/2.0) + 15.0*(1.0/2.0) + 5.0*(1.0/2.0) + 5.0*(1.0/2.0)
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareIdle:   ShareEven,
 	})
@@ -1251,7 +1294,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// idle:       30.0000
 	// Then namespace 2 is filtered.
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
 		SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 		ShareSplit:        ShareWeighted,
@@ -1272,7 +1315,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// namespace2: 54.667 = 36.00 + (28.00)*(36.00/54.00)
 	// idle:       30.0000
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
 		ShareSplit:  ShareWeighted,
@@ -1320,7 +1363,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	//   shared cost    14.2292 = (42.6875)*(18.0/54.0)
 	//
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
 		ShareSplit: ShareWeighted,
 		ShareIdle:  ShareWeighted,
@@ -1370,7 +1413,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// Then, filter for namespace2: 74.7708
 	//
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
 		ShareSplit:  ShareWeighted,
@@ -1411,7 +1454,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	//
 	// Then namespace 2 is filtered.
 	as = generateAllocationSet(start)
-	err = as.AggregateBy(Properties{NamespaceProp: ""}, &AllocationAggregationOptions{
+	err = as.AggregateBy([]string{AllocationNamespaceProp}, &AllocationAggregationOptions{
 		FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
 		ShareSplit:        ShareWeighted,
 		ShareIdle:         ShareWeighted,

+ 294 - 0
pkg/kubecost/allocationprops.go

@@ -0,0 +1,294 @@
+package kubecost
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+const (
+	AllocationNilProp            string = ""
+	AllocationClusterProp        string = "cluster"
+	AllocationNodeProp           string = "node"
+	AllocationContainerProp      string = "container"
+	AllocationControllerProp     string = "controller"
+	AllocationControllerKindProp string = "controllerKind"
+	AllocationNamespaceProp      string = "namespace"
+	AllocationPodProp            string = "pod"
+	AllocationProviderIDProp     string = "providerID"
+	AllocationServiceProp        string = "service"
+	AllocationLabelProp          string = "label"
+	AllocationAnnotationProp     string = "annotation"
+	AllocationDeploymentProp     string = "deployment"
+	AllocationStatefulSetProp    string = "statefulset"
+	AllocationDaemonSetProp      string = "daemonset"
+	AllocationJobProp            string = "job"
+)
+
+func ParseProperty(text string) (string, error) {
+	switch strings.TrimSpace(strings.ToLower(text)) {
+	case "cluster":
+		return AllocationClusterProp, nil
+	case "node":
+		return AllocationNodeProp, nil
+	case "container":
+		return AllocationContainerProp, nil
+	case "controller":
+		return AllocationControllerProp, nil
+	case "controllerkind":
+		return AllocationControllerKindProp, nil
+	case "namespace":
+		return AllocationNamespaceProp, nil
+	case "pod":
+		return AllocationPodProp, nil
+	case "providerid":
+		return AllocationProviderIDProp, nil
+	case "service":
+		return AllocationServiceProp, nil
+	case "label":
+		return AllocationLabelProp, nil
+	case "annotation":
+		return AllocationAnnotationProp, nil
+	case "deployment":
+		return AllocationDeploymentProp, nil
+	case "daemonset":
+		return AllocationDaemonSetProp, nil
+	case "statefulset":
+		return AllocationStatefulSetProp, nil
+	case "job":
+		return AllocationJobProp, nil
+	}
+	return AllocationNilProp, fmt.Errorf("invalid allocation property: %s", text)
+}
+
+// AllocationProperties describes a set of Kubernetes objects.
+type AllocationProperties struct {
+	Cluster        string                `json:"cluster,omitempty"`
+	Node           string                `json:"node,omitempty"`
+	Container      string                `json:"container,omitempty"`
+	Controller     string                `json:"controller,omitempty"`
+	ControllerKind string                `json:"controllerKind,omitempty"`
+	Namespace      string                `json:"namespace,omitempty"`
+	Pod            string                `json:"pod,omitempty"`
+	Services       []string              `json:"services,omitempty"`
+	ProviderID     string                `json:"providerID,omitempty"`
+	Labels         AllocationLabels      `json:"allocationLabels,omitempty"`
+	Annotations    AllocationAnnotations `json:"allocationAnnotations,omitempty"`
+}
+
+// AllocationLabels is a schema-free mapping of key/value pairs that can be
+// attributed to an Allocation
+type AllocationLabels map[string]string
+
+// AllocationAnnotations is a schema-free mapping of key/value pairs that can be
+// attributed to an Allocation
+type AllocationAnnotations map[string]string
+
+func (p *AllocationProperties) Clone() *AllocationProperties {
+	if p == nil {
+		return nil
+	}
+
+	clone := &AllocationProperties{}
+	clone.Cluster = p.Cluster
+	clone.Node = p.Node
+	clone.Container = p.Container
+	clone.Controller = p.Controller
+	clone.ControllerKind = p.ControllerKind
+	clone.Namespace = p.Namespace
+	clone.Pod = p.Pod
+	clone.ProviderID = p.ProviderID
+
+	var services []string
+	for _, s := range p.Services {
+		services = append(services, s)
+	}
+	clone.Services = services
+
+	labels := make(map[string]string)
+	for k, v := range p.Labels {
+		labels[k] = v
+	}
+	clone.Labels = labels
+
+	annotations := make(map[string]string)
+	for k, v := range p.Annotations {
+		annotations[k] = v
+	}
+	clone.Annotations = annotations
+
+	return clone
+}
+
+func (p *AllocationProperties) Equal(that *AllocationProperties) bool {
+	if p == nil || that == nil {
+		return false
+	}
+
+	if p.Cluster != that.Cluster {
+		return false
+	}
+
+	if p.Node != that.Node {
+		return false
+	}
+
+	if p.Container != that.Container {
+		return false
+	}
+
+	if p.Controller != that.Controller {
+		return false
+	}
+
+	if p.ControllerKind != that.ControllerKind {
+		return false
+	}
+
+	if p.Namespace != that.Namespace {
+		return false
+	}
+
+	if p.Pod != that.Pod {
+		return false
+	}
+
+	if p.ProviderID != that.ProviderID {
+		return false
+	}
+
+	pLabels := p.Labels
+	thatLabels := that.Labels
+	if len(pLabels) == len(thatLabels) {
+		for k, pv := range pLabels {
+			tv, ok := thatLabels[k]
+			if !ok || tv != pv {
+				return false
+			}
+		}
+	} else {
+		return false
+	}
+
+	pAnnotations := p.Annotations
+	thatAnnotations := that.Annotations
+	if len(pAnnotations) == len(thatAnnotations) {
+		for k, pv := range pAnnotations {
+			tv, ok := thatAnnotations[k]
+			if !ok || tv != pv {
+				return false
+			}
+		}
+	} else {
+		return false
+	}
+
+	pServices := p.Services
+	thatServices := that.Services
+	if len(pServices) == len(thatServices) {
+		sort.Strings(pServices)
+		sort.Strings(thatServices)
+		for i, pv := range pServices {
+			tv := thatServices[i]
+			if tv != pv {
+				return false
+			}
+		}
+	} else {
+		return false
+	}
+
+	return true
+}
+
+// Intersection returns an *AllocationProperties which contains all matching fields between the calling and parameter AllocationProperties
+// nillable slices and maps are left as nil
+func (p *AllocationProperties) Intersection(that *AllocationProperties) *AllocationProperties {
+	if p == nil || that == nil {
+		return nil
+	}
+	intersectionProps := &AllocationProperties{}
+	if p.Cluster == that.Cluster {
+		intersectionProps.Cluster = p.Cluster
+	}
+	if p.Node == that.Node {
+		intersectionProps.Node = p.Node
+	}
+	if p.Container == that.Container {
+		intersectionProps.Container = p.Container
+	}
+	if p.Controller == that.Controller {
+		intersectionProps.Controller = p.Controller
+	}
+	if p.ControllerKind == that.ControllerKind {
+		intersectionProps.ControllerKind = p.ControllerKind
+	}
+	if p.Namespace == that.Namespace {
+		intersectionProps.Namespace = p.Namespace
+	}
+	if p.Pod == that.Pod {
+		intersectionProps.Pod = p.Pod
+	}
+	if p.ProviderID == that.ProviderID {
+		intersectionProps.ProviderID = p.ProviderID
+	}
+	return intersectionProps
+}
+
+func (p *AllocationProperties) String() string {
+	if p == nil {
+		return "<nil>"
+	}
+
+	strs := []string{}
+
+	if p.Cluster != "" {
+		strs = append(strs, "Cluster:"+p.Cluster)
+	}
+
+	if p.Node != "" {
+		strs = append(strs, "Node:"+p.Node)
+	}
+
+	if p.Container != "" {
+		strs = append(strs, "Container:"+p.Container)
+	}
+
+	if p.Controller != "" {
+		strs = append(strs, "Controller:"+p.Controller)
+	}
+
+	if p.ControllerKind != "" {
+		strs = append(strs, "ControllerKind:"+p.ControllerKind)
+	}
+
+	if p.Namespace != "" {
+		strs = append(strs, "Namespace:"+p.Namespace)
+	}
+
+	if p.Pod != "" {
+		strs = append(strs, "Pod:"+p.Pod)
+	}
+
+	if p.ProviderID != "" {
+		strs = append(strs, "ProviderID:"+p.ProviderID)
+	}
+
+	if len(p.Services) > 0 {
+		strs = append(strs, "Services:"+strings.Join(p.Services, ";"))
+	}
+
+	var labelStrs []string
+	for k, prop := range p.Labels {
+		labelStrs = append(labelStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("Labels:{%s}", strings.Join(strs, ",")))
+
+	var AnnotationStrs []string
+	for k, prop := range p.Annotations {
+		AnnotationStrs = append(AnnotationStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("Annotations:{%s}", strings.Join(strs, ",")))
+
+	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
+}

+ 61 - 0
pkg/kubecost/allocationprops_test.go

@@ -0,0 +1,61 @@
+package kubecost
+
+// TODO niko/etl
+// func TestParseProperty(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperty_String(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_Clone(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_Intersection(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetCluster(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetCluster(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetContainer(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetContainer(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetController(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetController(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetControllerKind(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetControllerKind(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetLabels(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetLabels(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetNamespace(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetNamespace(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetPod(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetPod(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_GetServices(t *testing.T) {}
+
+// TODO niko/etl
+// func TestProperties_SetServices(t *testing.T) {}

+ 23 - 22
pkg/kubecost/asset.go

@@ -137,7 +137,7 @@ func AssetToExternalAllocation(asset Asset, aggregateBy []string, externalLabels
 	match := false
 
 	// props records the relevant Properties to set on the resultant Allocation
-	props := Properties{}
+	props := AllocationProperties{}
 
 	for _, aggBy := range aggregateBy {
 		// labelName should be derived from the mapping of properties to
@@ -171,35 +171,36 @@ func AssetToExternalAllocation(asset Asset, aggregateBy []string, externalLabels
 				match = true
 
 				// Set the corresponding label in props
-				labels, err := props.GetLabels()
-				if err != nil {
+				labels := props.Labels
+				if labels == nil {
 					labels = map[string]string{}
 				}
+
 				labels[labelName] = value
-				props.SetLabels(labels)
+				props.Labels = labels
 			} else {
 				names = append(names, value)
 				match = true
 
 				// Set the corresponding property on props
 				switch aggBy {
-				case ClusterProp.String():
-					props.SetCluster(value)
-				case NodeProp.String():
-					props.SetNode(value)
-				case NamespaceProp.String():
-					props.SetNamespace(value)
-				case ControllerKindProp.String():
-					props.SetControllerKind(value)
-				case ControllerProp.String():
-					props.SetController(value)
-				case PodProp.String():
-					props.SetPod(value)
-				case ContainerProp.String():
-					props.SetContainer(value)
-				case ServiceProp.String():
+				case AllocationClusterProp:
+					props.Cluster = value
+				case AllocationNodeProp:
+					props.Node = value
+				case AllocationNamespaceProp:
+					props.Namespace = value
+				case AllocationControllerKindProp:
+					props.ControllerKind = value
+				case AllocationControllerProp:
+					props.Controller = value
+				case AllocationPodProp:
+					props.Pod = value
+				case AllocationContainerProp:
+					props.Container = value
+				case AllocationServiceProp:
 					// TODO: external allocation: how to do this? multi-service?
-					props.SetServices([]string{value})
+					props.Services = []string{value}
 				}
 			}
 		} else {
@@ -221,7 +222,7 @@ func AssetToExternalAllocation(asset Asset, aggregateBy []string, externalLabels
 	// TODO: external allocation: resource totals?
 	return &Allocation{
 		Name:         strings.Join(names, "/"),
-		Properties:   props,
+		Properties:   &props,
 		Window:       asset.Window().Clone(),
 		Start:        asset.Start(),
 		End:          asset.End(),
@@ -1915,7 +1916,7 @@ func (n *Node) Clone() Asset {
 		RAMBreakdown: n.RAMBreakdown.Clone(),
 		CPUCost:      n.CPUCost,
 		GPUCost:      n.GPUCost,
-		GPUCount:      n.GPUCount,
+		GPUCount:     n.GPUCount,
 		RAMCost:      n.RAMCost,
 		Preemptible:  n.Preemptible,
 		Discount:     n.Discount,

+ 8 - 8
pkg/kubecost/asset_test.go

@@ -1089,8 +1089,8 @@ func TestAssetToExternalAllocation(t *testing.T) {
 	if alloc.Name != "monitoring/__external__" {
 		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/__external__", alloc.Name)
 	}
-	if ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
-		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	if ns := alloc.Properties.Namespace; ns != "monitoring" {
+		t.Fatalf("expected external allocation with AllocationProperties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
 	}
 	if alloc.ExternalCost != 10.00 {
 		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)
@@ -1107,11 +1107,11 @@ func TestAssetToExternalAllocation(t *testing.T) {
 	if alloc.Name != "monitoring/env=prod/__external__" {
 		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/env=prod/__external__", alloc.Name)
 	}
-	if ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
-		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	if ns := alloc.Properties.Namespace; ns != "monitoring" {
+		t.Fatalf("expected external allocation with AllocationProperties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
 	}
-	if ls, err := alloc.Properties.GetLabels(); err != nil || ls["env"] != "prod" {
-		t.Fatalf("expected external allocation with Properties.Labels[\"env\"] '%s'; got '%s' (%s)", "prod", ls["env"], err)
+	if ls := alloc.Properties.Labels; len(ls) == 0 || ls["env"] != "prod" {
+		t.Fatalf("expected external allocation with AllocationProperties.Labels[\"env\"] '%s'; got '%s' (%s)", "prod", ls["env"], err)
 	}
 	if alloc.ExternalCost != 10.00 {
 		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)
@@ -1128,8 +1128,8 @@ func TestAssetToExternalAllocation(t *testing.T) {
 	if alloc.Name != "monitoring/__unallocated__/__external__" {
 		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/__unallocated__/__external__", alloc.Name)
 	}
-	if ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
-		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	if ns := alloc.Properties.Namespace; ns != "monitoring" {
+		t.Fatalf("expected external allocation with AllocationProperties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
 	}
 	if alloc.ExternalCost != 10.00 {
 		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)

+ 6 - 1
pkg/kubecost/bingen.go

@@ -20,5 +20,10 @@ package kubecost
 // @bingen:generate:Allocation
 // @bingen:generate:AllocationSet
 // @bingen:generate:AllocationSetRange
+// @bingen:generate:AllocationProperties
+// @bingen:generate:AllocationProperty
+// @bingen:generate:AllocationLabels
+// @bingen:generate:AllocationAnnotations
+// @bingen:generate:RawAllocationOnlyData
 
-//go:generate bingen -package=kubecost -version=9 -buffer=github.com/kubecost/cost-model/pkg/util
+//go:generate bingen -package=kubecost -version=11 -buffer=github.com/kubecost/cost-model/pkg/util

+ 346 - 38
pkg/kubecost/kubecost_codecs.go

@@ -25,7 +25,7 @@ const (
 	GeneratorPackageName string = "kubecost"
 
 	// CodecVersion is the version passed into the generator
-	CodecVersion uint8 = 9
+	CodecVersion uint8 = 11
 )
 
 //--------------------------------------------------------------------------
@@ -35,22 +35,24 @@ const (
 // Generated type map for resolving interface implementations to
 // to concrete types
 var typeMap map[string]reflect.Type = map[string]reflect.Type{
-	"Allocation":         reflect.TypeOf((*Allocation)(nil)).Elem(),
-	"AllocationSet":      reflect.TypeOf((*AllocationSet)(nil)).Elem(),
-	"AllocationSetRange": reflect.TypeOf((*AllocationSetRange)(nil)).Elem(),
-	"Any":                reflect.TypeOf((*Any)(nil)).Elem(),
-	"AssetProperties":    reflect.TypeOf((*AssetProperties)(nil)).Elem(),
-	"AssetSet":           reflect.TypeOf((*AssetSet)(nil)).Elem(),
-	"AssetSetRange":      reflect.TypeOf((*AssetSetRange)(nil)).Elem(),
-	"Breakdown":          reflect.TypeOf((*Breakdown)(nil)).Elem(),
-	"Cloud":              reflect.TypeOf((*Cloud)(nil)).Elem(),
-	"ClusterManagement":  reflect.TypeOf((*ClusterManagement)(nil)).Elem(),
-	"Disk":               reflect.TypeOf((*Disk)(nil)).Elem(),
-	"LoadBalancer":       reflect.TypeOf((*LoadBalancer)(nil)).Elem(),
-	"Network":            reflect.TypeOf((*Network)(nil)).Elem(),
-	"Node":               reflect.TypeOf((*Node)(nil)).Elem(),
-	"SharedAsset":        reflect.TypeOf((*SharedAsset)(nil)).Elem(),
-	"Window":             reflect.TypeOf((*Window)(nil)).Elem(),
+	"Allocation":            reflect.TypeOf((*Allocation)(nil)).Elem(),
+	"AllocationProperties":  reflect.TypeOf((*AllocationProperties)(nil)).Elem(),
+	"AllocationSet":         reflect.TypeOf((*AllocationSet)(nil)).Elem(),
+	"AllocationSetRange":    reflect.TypeOf((*AllocationSetRange)(nil)).Elem(),
+	"Any":                   reflect.TypeOf((*Any)(nil)).Elem(),
+	"AssetProperties":       reflect.TypeOf((*AssetProperties)(nil)).Elem(),
+	"AssetSet":              reflect.TypeOf((*AssetSet)(nil)).Elem(),
+	"AssetSetRange":         reflect.TypeOf((*AssetSetRange)(nil)).Elem(),
+	"Breakdown":             reflect.TypeOf((*Breakdown)(nil)).Elem(),
+	"Cloud":                 reflect.TypeOf((*Cloud)(nil)).Elem(),
+	"ClusterManagement":     reflect.TypeOf((*ClusterManagement)(nil)).Elem(),
+	"Disk":                  reflect.TypeOf((*Disk)(nil)).Elem(),
+	"LoadBalancer":          reflect.TypeOf((*LoadBalancer)(nil)).Elem(),
+	"Network":               reflect.TypeOf((*Network)(nil)).Elem(),
+	"Node":                  reflect.TypeOf((*Node)(nil)).Elem(),
+	"RawAllocationOnlyData": reflect.TypeOf((*RawAllocationOnlyData)(nil)).Elem(),
+	"SharedAsset":           reflect.TypeOf((*SharedAsset)(nil)).Elem(),
+	"Window":                reflect.TypeOf((*Window)(nil)).Elem(),
 }
 
 //--------------------------------------------------------------------------
@@ -116,15 +118,21 @@ func (target *Allocation) MarshalBinary() (data []byte, err error) {
 	buff.WriteUInt8(CodecVersion) // version
 
 	buff.WriteString(target.Name) // write string
-	// --- [begin][write][reference](Properties) ---
-	a, errA := target.Properties.MarshalBinary()
-	if errA != nil {
-		return nil, errA
-	}
-	buff.WriteInt(len(a))
-	buff.WriteBytes(a)
-	// --- [end][write][reference](Properties) ---
+	if target.Properties == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][struct](AllocationProperties) ---
+		a, errA := target.Properties.MarshalBinary()
+		if errA != nil {
+			return nil, errA
+		}
+		buff.WriteInt(len(a))
+		buff.WriteBytes(a)
+		// --- [end][write][struct](AllocationProperties) ---
 
+	}
 	// --- [begin][write][struct](Window) ---
 	b, errB := target.Window.MarshalBinary()
 	if errB != nil {
@@ -168,6 +176,21 @@ func (target *Allocation) MarshalBinary() (data []byte, err error) {
 	buff.WriteFloat64(target.RAMCost)                // write float64
 	buff.WriteFloat64(target.SharedCost)             // write float64
 	buff.WriteFloat64(target.ExternalCost)           // write float64
+	if target.RawAllocationOnly == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][struct](RawAllocationOnlyData) ---
+		e, errE := target.RawAllocationOnly.MarshalBinary()
+		if errE != nil {
+			return nil, errE
+		}
+		buff.WriteInt(len(e))
+		buff.WriteBytes(e)
+		// --- [end][write][struct](RawAllocationOnlyData) ---
+
+	}
 	return buff.Bytes(), nil
 }
 
@@ -198,17 +221,21 @@ func (target *Allocation) UnmarshalBinary(data []byte) (err error) {
 	a := buff.ReadString() // read string
 	target.Name = a
 
-	// --- [begin][read][reference](Properties) ---
-	b := &Properties{}
-	c := buff.ReadInt()    // byte array length
-	d := buff.ReadBytes(c) // byte array
-	errA := b.UnmarshalBinary(d)
-	if errA != nil {
-		return errA
-	}
-	target.Properties = *b
-	// --- [end][read][reference](Properties) ---
+	if buff.ReadUInt8() == uint8(0) {
+		target.Properties = nil
+	} else {
+		// --- [begin][read][struct](AllocationProperties) ---
+		b := &AllocationProperties{}
+		c := buff.ReadInt()    // byte array length
+		d := buff.ReadBytes(c) // byte array
+		errA := b.UnmarshalBinary(d)
+		if errA != nil {
+			return errA
+		}
+		target.Properties = b
+		// --- [end][read][struct](AllocationProperties) ---
 
+	}
 	// --- [begin][read][struct](Window) ---
 	e := &Window{}
 	f := buff.ReadInt()    // byte array length
@@ -290,6 +317,222 @@ func (target *Allocation) UnmarshalBinary(data []byte) (err error) {
 	gg := buff.ReadFloat64() // read float64
 	target.ExternalCost = gg
 
+	if buff.ReadUInt8() == uint8(0) {
+		target.RawAllocationOnly = nil
+	} else {
+		// --- [begin][read][struct](RawAllocationOnlyData) ---
+		hh := &RawAllocationOnlyData{}
+		kk := buff.ReadInt()     // byte array length
+		ll := buff.ReadBytes(kk) // byte array
+		errE := hh.UnmarshalBinary(ll)
+		if errE != nil {
+			return errE
+		}
+		target.RawAllocationOnly = hh
+		// --- [end][read][struct](RawAllocationOnlyData) ---
+
+	}
+	return nil
+}
+
+//--------------------------------------------------------------------------
+//  AllocationProperties
+//--------------------------------------------------------------------------
+
+// MarshalBinary serializes the internal properties of this AllocationProperties instance
+// into a byte array
+func (target *AllocationProperties) MarshalBinary() (data []byte, err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := util.NewBuffer()
+	buff.WriteUInt8(CodecVersion) // version
+
+	buff.WriteString(target.Cluster)        // write string
+	buff.WriteString(target.Node)           // write string
+	buff.WriteString(target.Container)      // write string
+	buff.WriteString(target.Controller)     // write string
+	buff.WriteString(target.ControllerKind) // write string
+	buff.WriteString(target.Namespace)      // write string
+	buff.WriteString(target.Pod)            // write string
+	if target.Services == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][slice]([]string) ---
+		buff.WriteInt(len(target.Services)) // array length
+		for i := 0; i < len(target.Services); i++ {
+			buff.WriteString(target.Services[i]) // write string
+		}
+		// --- [end][write][slice]([]string) ---
+
+	}
+	buff.WriteString(target.ProviderID) // write string
+	// --- [begin][write][alias](AllocationLabels) ---
+	if map[string]string(target.Labels) == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][map](map[string]string) ---
+		buff.WriteInt(len(map[string]string(target.Labels))) // map length
+		for v, z := range map[string]string(target.Labels) {
+			buff.WriteString(v) // write string
+			buff.WriteString(z) // write string
+		}
+		// --- [end][write][map](map[string]string) ---
+
+	}
+	// --- [end][write][alias](AllocationLabels) ---
+
+	// --- [begin][write][alias](AllocationAnnotations) ---
+	if map[string]string(target.Annotations) == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][map](map[string]string) ---
+		buff.WriteInt(len(map[string]string(target.Annotations))) // map length
+		for vv, zz := range map[string]string(target.Annotations) {
+			buff.WriteString(vv) // write string
+			buff.WriteString(zz) // write string
+		}
+		// --- [end][write][map](map[string]string) ---
+
+	}
+	// --- [end][write][alias](AllocationAnnotations) ---
+
+	return buff.Bytes(), nil
+}
+
+// UnmarshalBinary uses the data passed byte array to set all the internal properties of
+// the AllocationProperties type
+func (target *AllocationProperties) UnmarshalBinary(data []byte) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := util.NewBufferFromBytes(data)
+
+	// Codec Version Check
+	version := buff.ReadUInt8()
+	if version != CodecVersion {
+		return fmt.Errorf("Invalid Version Unmarshaling AllocationProperties. Expected %d, got %d", CodecVersion, version)
+	}
+
+	a := buff.ReadString() // read string
+	target.Cluster = a
+
+	b := buff.ReadString() // read string
+	target.Node = b
+
+	c := buff.ReadString() // read string
+	target.Container = c
+
+	d := buff.ReadString() // read string
+	target.Controller = d
+
+	e := buff.ReadString() // read string
+	target.ControllerKind = e
+
+	f := buff.ReadString() // read string
+	target.Namespace = f
+
+	g := buff.ReadString() // read string
+	target.Pod = g
+
+	if buff.ReadUInt8() == uint8(0) {
+		target.Services = nil
+	} else {
+		// --- [begin][read][slice]([]string) ---
+		k := buff.ReadInt() // array len
+		h := make([]string, k)
+		for i := 0; i < k; i++ {
+			var l string
+			m := buff.ReadString() // read string
+			l = m
+
+			h[i] = l
+		}
+		target.Services = h
+		// --- [end][read][slice]([]string) ---
+
+	}
+	n := buff.ReadString() // read string
+	target.ProviderID = n
+
+	// --- [begin][read][alias](AllocationLabels) ---
+	var o map[string]string
+	if buff.ReadUInt8() == uint8(0) {
+		o = nil
+	} else {
+		// --- [begin][read][map](map[string]string) ---
+		q := buff.ReadInt() // map len
+		p := make(map[string]string, q)
+		for j := 0; j < q; j++ {
+			var v string
+			r := buff.ReadString() // read string
+			v = r
+
+			var z string
+			s := buff.ReadString() // read string
+			z = s
+
+			p[v] = z
+		}
+		o = p
+		// --- [end][read][map](map[string]string) ---
+
+	}
+	target.Labels = AllocationLabels(o)
+	// --- [end][read][alias](AllocationLabels) ---
+
+	// --- [begin][read][alias](AllocationAnnotations) ---
+	var t map[string]string
+	if buff.ReadUInt8() == uint8(0) {
+		t = nil
+	} else {
+		// --- [begin][read][map](map[string]string) ---
+		w := buff.ReadInt() // map len
+		u := make(map[string]string, w)
+		for ii := 0; ii < w; ii++ {
+			var vv string
+			x := buff.ReadString() // read string
+			vv = x
+
+			var zz string
+			y := buff.ReadString() // read string
+			zz = y
+
+			u[vv] = zz
+		}
+		t = u
+		// --- [end][read][map](map[string]string) ---
+
+	}
+	target.Annotations = AllocationAnnotations(t)
+	// --- [end][read][alias](AllocationAnnotations) ---
+
 	return nil
 }
 
@@ -2447,6 +2690,7 @@ func (target *Node) MarshalBinary() (data []byte, err error) {
 	}
 	buff.WriteFloat64(target.CPUCost)     // write float64
 	buff.WriteFloat64(target.GPUCost)     // write float64
+	buff.WriteFloat64(target.GPUCount)    // write float64
 	buff.WriteFloat64(target.RAMCost)     // write float64
 	buff.WriteFloat64(target.Discount)    // write float64
 	buff.WriteFloat64(target.Preemptible) // write float64
@@ -2600,13 +2844,77 @@ func (target *Node) UnmarshalBinary(data []byte) (err error) {
 	target.GPUCost = gg
 
 	hh := buff.ReadFloat64() // read float64
-	target.RAMCost = hh
+	target.GPUCount = hh
 
 	kk := buff.ReadFloat64() // read float64
-	target.Discount = kk
+	target.RAMCost = kk
 
 	ll := buff.ReadFloat64() // read float64
-	target.Preemptible = ll
+	target.Discount = ll
+
+	mm := buff.ReadFloat64() // read float64
+	target.Preemptible = mm
+
+	return nil
+}
+
+//--------------------------------------------------------------------------
+//  RawAllocationOnlyData
+//--------------------------------------------------------------------------
+
+// MarshalBinary serializes the internal properties of this RawAllocationOnlyData instance
+// into a byte array
+func (target *RawAllocationOnlyData) MarshalBinary() (data []byte, err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := util.NewBuffer()
+	buff.WriteUInt8(CodecVersion) // version
+
+	buff.WriteFloat64(target.CPUCoreUsageMax)  // write float64
+	buff.WriteFloat64(target.RAMBytesUsageMax) // write float64
+	return buff.Bytes(), nil
+}
+
+// UnmarshalBinary uses the data passed byte array to set all the internal properties of
+// the RawAllocationOnlyData type
+func (target *RawAllocationOnlyData) UnmarshalBinary(data []byte) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := util.NewBufferFromBytes(data)
+
+	// Codec Version Check
+	version := buff.ReadUInt8()
+	if version != CodecVersion {
+		return fmt.Errorf("Invalid Version Unmarshaling RawAllocationOnlyData. Expected %d, got %d", CodecVersion, version)
+	}
+
+	a := buff.ReadFloat64() // read float64
+	target.CPUCoreUsageMax = a
+
+	b := buff.ReadFloat64() // read float64
+	target.RAMBytesUsageMax = b
 
 	return nil
 }

+ 31 - 31
pkg/kubecost/kubecost_codecs_test.go

@@ -450,76 +450,76 @@ func TestNode_BinaryEncoding(t *testing.T) {
 }
 
 func TestProperties_BinaryEncoding(t *testing.T) {
-	var p0, p1 *Properties
+	var p0, p1 *AllocationProperties
 	var bs []byte
 	var err error
 
 	// empty properties
-	p0 = &Properties{}
+	p0 = &AllocationProperties{}
 	bs, err = p0.MarshalBinary()
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
-	p1 = &Properties{}
+	p1 = &AllocationProperties{}
 	err = p1.UnmarshalBinary(bs)
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
 	if !p0.Equal(p1) {
-		t.Fatalf("Properties.Binary: expected %s; found %s", p0, p1)
+		t.Fatalf("AllocationProperties.Binary: expected %s; found %s", p0, p1)
 	}
 
 	// complete properties
-	p0 = &Properties{}
-	p0.SetCluster("cluster1")
-	p0.SetContainer("container-abc-1")
-	p0.SetController("daemonset-abc")
-	p0.SetControllerKind("daemonset")
-	p0.SetNamespace("namespace1")
-	p0.SetNode("node1")
-	p0.SetPod("daemonset-abc-123")
-	p0.SetLabels(map[string]string{
+	p0 = &AllocationProperties{}
+	p0.Cluster = "cluster1"
+	p0.Container = "container-abc-1"
+	p0.Controller = "daemonset-abc"
+	p0.ControllerKind = "daemonset"
+	p0.Namespace = "namespace1"
+	p0.Node = "node1"
+	p0.Pod = "daemonset-abc-123"
+	p0.Labels = map[string]string{
 		"app":  "cost-analyzer",
 		"tier": "frontend",
-	})
-	p0.SetServices([]string{"kubecost-frontend"})
+	}
+	p0.Services = []string{"kubecost-frontend"}
 	bs, err = p0.MarshalBinary()
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
-	p1 = &Properties{}
+	p1 = &AllocationProperties{}
 	err = p1.UnmarshalBinary(bs)
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
 	if !p0.Equal(p1) {
-		t.Fatalf("Properties.Binary: expected %s; found %s", p0, p1)
+		t.Fatalf("AllocationProperties.Binary: expected %s; found %s", p0, p1)
 	}
 
 	// incomplete properties
-	p0 = &Properties{}
-	p0.SetCluster("cluster1")
-	p0.SetController("daemonset-abc")
-	p0.SetControllerKind("daemonset")
-	p0.SetNamespace("namespace1")
-	p0.SetServices([]string{})
+	p0 = &AllocationProperties{}
+	p0.Cluster = ("cluster1")
+	p0.Controller = "daemonset-abc"
+	p0.ControllerKind = "daemonset"
+	p0.Namespace = "namespace1"
+	p0.Services = []string{}
 	bs, err = p0.MarshalBinary()
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
-	p1 = &Properties{}
+	p1 = &AllocationProperties{}
 	err = p1.UnmarshalBinary(bs)
 	if err != nil {
-		t.Fatalf("Properties.Binary: unexpected error: %s", err)
+		t.Fatalf("AllocationProperties.Binary: unexpected error: %s", err)
 	}
 
 	if !p0.Equal(p1) {
-		t.Fatalf("Properties.Binary: expected %s; found %s", p0, p1)
+		t.Fatalf("AllocationProperties.Binary: expected %s; found %s", p0, p1)
 	}
 }
 

+ 0 - 698
pkg/kubecost/properties.go

@@ -1,698 +0,0 @@
-package kubecost
-
-import (
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/kubecost/cost-model/pkg/util"
-)
-
-type Property string
-
-const (
-	NilProp            Property = ""
-	ClusterProp        Property = "cluster"
-	NodeProp           Property = "node"
-	ContainerProp      Property = "container"
-	ControllerProp     Property = "controller"
-	ControllerKindProp Property = "controllerKind"
-	LabelProp          Property = "label"
-	AnnotationProp     Property = "annotation"
-	NamespaceProp      Property = "namespace"
-	PodProp            Property = "pod"
-	ServiceProp        Property = "service"
-)
-
-var availableProperties []Property = []Property{
-	NilProp,
-	ClusterProp,
-	NodeProp,
-	ContainerProp,
-	ControllerProp,
-	ControllerKindProp,
-	LabelProp,
-	AnnotationProp,
-	NamespaceProp,
-	PodProp,
-	ServiceProp,
-}
-
-func ParseProperty(prop string) Property {
-	for _, property := range availableProperties {
-		if strings.ToLower(string(property)) == strings.ToLower(prop) {
-			return property
-		}
-	}
-	return NilProp
-}
-
-func (p Property) String() string {
-	return string(p)
-}
-
-type PropertyValue struct {
-	Property Property
-	Value    interface{}
-}
-
-// Properties describes a set of Kubernetes objects.
-// TODO:CLEANUP make this a struct smdh
-type Properties map[Property]interface{}
-
-// TODO niko/etl make sure Services deep copy works correctly
-func (p *Properties) Clone() Properties {
-	if p == nil {
-		return nil
-	}
-
-	clone := make(Properties, len(*p))
-	for k, v := range *p {
-		clone[k] = v
-	}
-	return clone
-}
-
-func (p *Properties) Equal(that *Properties) bool {
-	if p == nil || that == nil {
-		return false
-	}
-
-	if p.Length() != that.Length() {
-		return false
-	}
-
-	pCluster, _ := p.GetCluster()
-	thatCluster, _ := that.GetCluster()
-	if pCluster != thatCluster {
-		return false
-	}
-
-	pNode, _ := p.GetNode()
-	thatNode, _ := that.GetNode()
-	if pNode != thatNode {
-		return false
-	}
-
-	pContainer, _ := p.GetContainer()
-	thatContainer, _ := that.GetContainer()
-	if pContainer != thatContainer {
-		return false
-	}
-
-	pController, _ := p.GetController()
-	thatController, _ := that.GetController()
-	if pController != thatController {
-		return false
-	}
-
-	pControllerKind, _ := p.GetControllerKind()
-	thatControllerKind, _ := that.GetControllerKind()
-	if pControllerKind != thatControllerKind {
-		return false
-	}
-
-	pNamespace, _ := p.GetNamespace()
-	thatNamespace, _ := that.GetNamespace()
-	if pNamespace != thatNamespace {
-		return false
-	}
-
-	pPod, _ := p.GetPod()
-	thatPod, _ := that.GetPod()
-	if pPod != thatPod {
-		return false
-	}
-
-	pLabels, _ := p.GetLabels()
-	thatLabels, _ := that.GetLabels()
-	if len(pLabels) != len(thatLabels) {
-		for k, pv := range pLabels {
-			tv, ok := thatLabels[k]
-			if !ok || tv != pv {
-				return false
-			}
-		}
-		return false
-	}
-
-	pAnnotations, _ := p.GetAnnotations()
-	thatAnnotations, _ := that.GetAnnotations()
-	if len(pAnnotations) != len(thatAnnotations) {
-		for k, pv := range pAnnotations {
-			tv, ok := thatAnnotations[k]
-			if !ok || tv != pv {
-				return false
-			}
-		}
-		return false
-	}
-
-	pServices, _ := p.GetServices()
-	thatServices, _ := that.GetServices()
-	if len(pServices) != len(thatServices) {
-		sort.Strings(pServices)
-		sort.Strings(thatServices)
-		for i, pv := range pServices {
-			tv := thatServices[i]
-			if tv != pv {
-				return false
-			}
-		}
-		return false
-	}
-
-	return true
-}
-
-func (p *Properties) Intersection(that Properties) Properties {
-	spec := &Properties{}
-
-	sCluster, sErr := p.GetCluster()
-	tCluster, tErr := that.GetCluster()
-	if sErr == nil && tErr == nil && sCluster == tCluster {
-		spec.SetCluster(sCluster)
-	}
-
-	sNode, sErr := p.GetNode()
-	tNode, tErr := that.GetNode()
-	if sErr == nil && tErr == nil && sNode == tNode {
-		spec.SetNode(sNode)
-	}
-
-	sContainer, sErr := p.GetContainer()
-	tContainer, tErr := that.GetContainer()
-	if sErr == nil && tErr == nil && sContainer == tContainer {
-		spec.SetContainer(sContainer)
-	}
-
-	sController, sErr := p.GetController()
-	tController, tErr := that.GetController()
-	if sErr == nil && tErr == nil && sController == tController {
-		spec.SetController(sController)
-	}
-
-	sControllerKind, sErr := p.GetControllerKind()
-	tControllerKind, tErr := that.GetControllerKind()
-	if sErr == nil && tErr == nil && sControllerKind == tControllerKind {
-		spec.SetControllerKind(sControllerKind)
-	}
-
-	sNamespace, sErr := p.GetNamespace()
-	tNamespace, tErr := that.GetNamespace()
-	if sErr == nil && tErr == nil && sNamespace == tNamespace {
-		spec.SetNamespace(sNamespace)
-	}
-
-	sPod, sErr := p.GetPod()
-	tPod, tErr := that.GetPod()
-	if sErr == nil && tErr == nil && sPod == tPod {
-		spec.SetPod(sPod)
-	}
-
-	// TODO niko/etl intersection of services and labels and annotations
-
-	return *spec
-}
-
-// Length returns the number of Properties
-func (p *Properties) Length() int {
-	if p == nil {
-		return 0
-	}
-	return len(*p)
-}
-
-func (p *Properties) String() string {
-	if p == nil {
-		return "<nil>"
-	}
-
-	strs := []string{}
-	for key, prop := range *p {
-		strs = append(strs, fmt.Sprintf("%s:%s", key, prop))
-	}
-	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
-}
-
-// AggregationStrings converts a Properties object into a slice of strings
-// representing a request to aggregate by certain properties.
-// NOTE: today, the ordering of the properties *has to match the ordering
-// of the allocaiton function generateKey*
-func (p *Properties) AggregationStrings() []string {
-	if p == nil {
-		return []string{}
-	}
-
-	aggStrs := []string{}
-	if p.HasCluster() {
-		aggStrs = append(aggStrs, ClusterProp.String())
-	}
-	if p.HasNode() {
-		aggStrs = append(aggStrs, NodeProp.String())
-	}
-	if p.HasNamespace() {
-		aggStrs = append(aggStrs, NamespaceProp.String())
-	}
-	if p.HasControllerKind() {
-		aggStrs = append(aggStrs, ControllerKindProp.String())
-	}
-	if p.HasController() {
-		aggStrs = append(aggStrs, ControllerProp.String())
-	}
-	if p.HasPod() {
-		aggStrs = append(aggStrs, PodProp.String())
-	}
-	if p.HasContainer() {
-		aggStrs = append(aggStrs, ContainerProp.String())
-	}
-	if p.HasService() {
-		aggStrs = append(aggStrs, ServiceProp.String())
-	}
-	if p.HasLabel() {
-		// e.g. expect format map[string]string{
-		// 	 "env":""
-		// 	 "app":"",
-		// }
-		// for aggregating by "label:app,label:env"
-		labels, _ := p.GetLabels()
-		labelAggStrs := []string{}
-		for labelName := range labels {
-			labelAggStrs = append(labelAggStrs, fmt.Sprintf("label:%s", labelName))
-		}
-		if len(labelAggStrs) > 0 {
-			// Enforce alphabetical ordering, then append to aggStrs
-			sort.Strings(labelAggStrs)
-			for _, labelName := range labelAggStrs {
-				aggStrs = append(aggStrs, labelName)
-			}
-		}
-	}
-	return aggStrs
-}
-
-func (p *Properties) Get(prop Property) (string, error) {
-	if raw, ok := (*p)[prop]; ok {
-		if result, ok := raw.(string); ok {
-			return result, nil
-		}
-		return "", fmt.Errorf("%s is not a string", prop)
-	}
-	return "", fmt.Errorf("%s not set", prop)
-}
-
-func (p *Properties) Has(prop Property) bool {
-	_, ok := (*p)[prop]
-	return ok
-}
-
-func (p *Properties) Set(prop Property, value string) {
-	(*p)[prop] = value
-}
-
-func (p *Properties) GetCluster() (string, error) {
-	if raw, ok := (*p)[ClusterProp]; ok {
-		if cluster, ok := raw.(string); ok {
-			return cluster, nil
-		}
-		return "", fmt.Errorf("ClusterProp is not a string")
-	}
-	return "", fmt.Errorf("ClusterProp not set")
-}
-
-func (p *Properties) HasCluster() bool {
-	_, ok := (*p)[ClusterProp]
-	return ok
-}
-
-func (p *Properties) SetCluster(cluster string) {
-	(*p)[ClusterProp] = cluster
-}
-
-func (p *Properties) GetNode() (string, error) {
-	if raw, ok := (*p)[NodeProp]; ok {
-		if node, ok := raw.(string); ok {
-			return node, nil
-		}
-		return "", fmt.Errorf("NodeProp is not a string")
-	}
-	return "", fmt.Errorf("NodeProp not set")
-}
-
-func (p *Properties) HasNode() bool {
-	_, ok := (*p)[NodeProp]
-	return ok
-}
-
-func (p *Properties) SetNode(node string) {
-	(*p)[NodeProp] = node
-}
-
-func (p *Properties) GetContainer() (string, error) {
-	if raw, ok := (*p)[ContainerProp]; ok {
-		if container, ok := raw.(string); ok {
-			return container, nil
-		}
-		return "", fmt.Errorf("ContainerProp is not a string")
-	}
-	return "", fmt.Errorf("ContainerProp not set")
-}
-
-func (p *Properties) HasContainer() bool {
-	_, ok := (*p)[ContainerProp]
-	return ok
-}
-
-func (p *Properties) SetContainer(container string) {
-	(*p)[ContainerProp] = container
-}
-
-func (p *Properties) GetController() (string, error) {
-	if raw, ok := (*p)[ControllerProp]; ok {
-		if controller, ok := raw.(string); ok {
-			return controller, nil
-		}
-		return "", fmt.Errorf("ControllerProp is not a string")
-	}
-	return "", fmt.Errorf("ControllerProp not set")
-}
-
-func (p *Properties) HasController() bool {
-	_, ok := (*p)[ControllerProp]
-	return ok
-}
-
-func (p *Properties) SetController(controller string) {
-	(*p)[ControllerProp] = controller
-}
-
-func (p *Properties) GetControllerKind() (string, error) {
-	if raw, ok := (*p)[ControllerKindProp]; ok {
-		if controllerKind, ok := raw.(string); ok {
-			return controllerKind, nil
-		}
-		return "", fmt.Errorf("ControllerKindProp is not a string")
-	}
-	return "", fmt.Errorf("ControllerKindProp not set")
-}
-
-func (p *Properties) HasControllerKind() bool {
-	_, ok := (*p)[ControllerKindProp]
-	return ok
-}
-
-func (p *Properties) SetControllerKind(controllerKind string) {
-	(*p)[ControllerKindProp] = controllerKind
-}
-
-func (p *Properties) GetLabels() (map[string]string, error) {
-	if raw, ok := (*p)[LabelProp]; ok {
-		if labels, ok := raw.(map[string]string); ok {
-			return labels, nil
-		}
-		return map[string]string{}, fmt.Errorf("LabelProp is not a map[string]string")
-	}
-	return map[string]string{}, fmt.Errorf("LabelProp not set")
-}
-
-func (p *Properties) HasLabel() bool {
-	_, ok := (*p)[LabelProp]
-	return ok
-}
-
-func (p *Properties) SetLabels(labels map[string]string) {
-	(*p)[LabelProp] = labels
-}
-
-func (p *Properties) GetAnnotations() (map[string]string, error) {
-	if raw, ok := (*p)[AnnotationProp]; ok {
-		if annotations, ok := raw.(map[string]string); ok {
-			return annotations, nil
-		}
-		return map[string]string{}, fmt.Errorf("AnnotationProp is not a map[string]string")
-	}
-	return map[string]string{}, fmt.Errorf("AnnotationProp not set")
-}
-
-func (p *Properties) HasAnnotations() bool {
-	_, ok := (*p)[AnnotationProp]
-	return ok
-}
-
-func (p *Properties) SetAnnotations(annotations map[string]string) {
-	(*p)[AnnotationProp] = annotations
-}
-
-func (p *Properties) GetNamespace() (string, error) {
-	if raw, ok := (*p)[NamespaceProp]; ok {
-		if namespace, ok := raw.(string); ok {
-			return namespace, nil
-		}
-		return "", fmt.Errorf("NamespaceProp is not a string")
-	}
-	return "", fmt.Errorf("NamespaceProp not set")
-}
-
-func (p *Properties) HasNamespace() bool {
-	_, ok := (*p)[NamespaceProp]
-	return ok
-}
-
-func (p *Properties) SetNamespace(namespace string) {
-	(*p)[NamespaceProp] = namespace
-}
-
-func (p *Properties) GetPod() (string, error) {
-	if raw, ok := (*p)[PodProp]; ok {
-		if pod, ok := raw.(string); ok {
-			return pod, nil
-		}
-		return "", fmt.Errorf("PodProp is not a string")
-	}
-	return "", fmt.Errorf("PodProp not set")
-}
-
-func (p *Properties) HasPod() bool {
-	_, ok := (*p)[PodProp]
-	return ok
-}
-
-func (p *Properties) SetPod(pod string) {
-	(*p)[PodProp] = pod
-}
-
-func (p *Properties) GetServices() ([]string, error) {
-	if raw, ok := (*p)[ServiceProp]; ok {
-		if services, ok := raw.([]string); ok {
-			return services, nil
-		}
-		return []string{}, fmt.Errorf("ServiceProp is not a string")
-	}
-	return []string{}, fmt.Errorf("ServiceProp not set")
-}
-
-func (p *Properties) HasService() bool {
-	_, ok := (*p)[ServiceProp]
-	return ok
-}
-
-func (p *Properties) SetServices(services []string) {
-	(*p)[ServiceProp] = services
-}
-
-func (p *Properties) MarshalBinary() (data []byte, err error) {
-	buff := util.NewBuffer()
-	buff.WriteUInt8(CodecVersion) // version
-
-	// ClusterProp
-	cluster, err := p.GetCluster()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1)) // write non-nil byte
-		buff.WriteString(cluster) // write string
-	}
-
-	// NodeProp
-	node, err := p.GetNode()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1)) // write non-nil byte
-		buff.WriteString(node)    // write string
-	}
-
-	// ContainerProp
-	container, err := p.GetContainer()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))   // write non-nil byte
-		buff.WriteString(container) // write string
-	}
-
-	// ControllerProp
-	controller, err := p.GetController()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))    // write non-nil byte
-		buff.WriteString(controller) // write string
-	}
-
-	// ControllerKindProp
-	controllerKind, err := p.GetControllerKind()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))        // write non-nil byte
-		buff.WriteString(controllerKind) // write string
-	}
-
-	// NamespaceProp
-	namespace, err := p.GetNamespace()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))   // write non-nil byte
-		buff.WriteString(namespace) // write string
-	}
-
-	// PodProp
-	pod, err := p.GetPod()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1)) // write non-nil byte
-		buff.WriteString(pod)     // write string
-	}
-
-	// LabelProp
-	labels, err := p.GetLabels()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))  // write non-nil byte
-		buff.WriteInt(len(labels)) // map length
-		for k, v := range labels {
-			buff.WriteString(k) // write string
-			buff.WriteString(v) // write string
-		}
-	}
-
-	// AnnotationProp
-	annotations, err := p.GetAnnotations()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))       // write non-nil byte
-		buff.WriteInt(len(annotations)) // map length
-		for k, v := range annotations {
-			buff.WriteString(k) // write string
-			buff.WriteString(v) // write string
-		}
-	}
-
-	// ServiceProp
-	services, err := p.GetServices()
-	if err != nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1))    // write non-nil byte
-		buff.WriteInt(len(services)) // slice length
-		for _, v := range services {
-			buff.WriteString(v) // write string
-		}
-	}
-
-	return buff.Bytes(), nil
-}
-
-func (p *Properties) UnmarshalBinary(data []byte) error {
-	buff := util.NewBufferFromBytes(data)
-	v := buff.ReadUInt8() // version
-	if v != CodecVersion {
-		return fmt.Errorf("Invalid Version. Expected %d, got %d", CodecVersion, v)
-	}
-
-	*p = Properties{}
-
-	// ClusterProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		cluster := buff.ReadString() // read string
-		p.SetCluster(cluster)
-	}
-
-	// NodeProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		node := buff.ReadString() // read string
-		p.SetNode(node)
-	}
-
-	// ContainerProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		container := buff.ReadString() // read string
-		p.SetContainer(container)
-	}
-
-	// ControllerProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		controller := buff.ReadString() // read string
-		p.SetController(controller)
-	}
-
-	// ControllerKindProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		controllerKind := buff.ReadString() // read string
-		p.SetControllerKind(controllerKind)
-	}
-
-	// NamespaceProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		namespace := buff.ReadString() // read string
-		p.SetNamespace(namespace)
-	}
-
-	// PodProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		pod := buff.ReadString() // read string
-		p.SetPod(pod)
-	}
-
-	// LabelProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		length := buff.ReadInt() // read map len
-		labels := make(map[string]string, length)
-		for idx := 0; idx < length; idx++ {
-			key := buff.ReadString()
-			val := buff.ReadString()
-			labels[key] = val
-		}
-		p.SetLabels(labels)
-	}
-
-	// AnnotationProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		length := buff.ReadInt() // read map len
-		annotations := make(map[string]string, length)
-		for idx := 0; idx < length; idx++ {
-			key := buff.ReadString()
-			val := buff.ReadString()
-			annotations[key] = val
-		}
-		p.SetAnnotations(annotations)
-	}
-
-	// ServiceProp
-	if buff.ReadUInt8() == 1 { // read nil byte
-		length := buff.ReadInt() // read map len
-		services := make([]string, length)
-		for idx := 0; idx < length; idx++ {
-			val := buff.ReadString()
-			services[idx] = val
-		}
-		p.SetServices(services)
-	}
-
-	return nil
-}

+ 0 - 115
pkg/kubecost/properties_test.go

@@ -1,115 +0,0 @@
-package kubecost
-
-import "testing"
-
-// TODO niko/etl
-// func TestParseProperty(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperty_String(t *testing.T) {}
-
-func TestProperties_AggregationString(t *testing.T) {
-	var props *Properties
-	var aggStrs []string
-
-	// nil Properties should produce and empty slice
-	aggStrs = props.AggregationStrings()
-	if aggStrs == nil || len(aggStrs) > 0 {
-		t.Fatalf("expected empty slice; got %v", aggStrs)
-	}
-
-	// empty Properties should product an empty slice
-	props = &Properties{}
-	aggStrs = props.AggregationStrings()
-	if aggStrs == nil || len(aggStrs) > 0 {
-		t.Fatalf("expected empty slice; got %v", aggStrs)
-	}
-
-	// Properties with single, simple property set
-	props = &Properties{}
-	props.SetNamespace("")
-	aggStrs = props.AggregationStrings()
-	if len(aggStrs) != 1 || aggStrs[0] != "namespace" {
-		t.Fatalf("expected [\"namespace\"]; got %v", aggStrs)
-	}
-
-	// Properties with mutiple properties, including labels
-	// Note: order matters!
-	props = &Properties{}
-	props.SetNamespace("")
-	props.SetLabels(map[string]string{
-		"env": "",
-		"app": "",
-	})
-	props.SetCluster("")
-	aggStrs = props.AggregationStrings()
-	if len(aggStrs) != 4 {
-		t.Fatalf("expected length %d; got lenfth %d", 4, len(aggStrs))
-	}
-	if aggStrs[0] != "cluster" {
-		t.Fatalf("expected aggStrs[0] == \"%s\"; got \"%s\"", "cluster", aggStrs[0])
-	}
-	if aggStrs[1] != "namespace" {
-		t.Fatalf("expected aggStrs[1] == \"%s\"; got \"%s\"", "namespace", aggStrs[1])
-	}
-	if aggStrs[2] != "label:app" {
-		t.Fatalf("expected aggStrs[2] == \"%s\"; got \"%s\"", "label:app", aggStrs[2])
-	}
-	if aggStrs[3] != "label:env" {
-		t.Fatalf("expected aggStrs[3] == \"%s\"; got \"%s\"", "label:env", aggStrs[3])
-	}
-}
-
-// TODO niko/etl
-// func TestProperties_Clone(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_Intersection(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetCluster(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetCluster(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetContainer(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetContainer(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetController(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetController(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetControllerKind(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetControllerKind(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetLabels(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetLabels(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetNamespace(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetNamespace(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetPod(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetPod(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_GetServices(t *testing.T) {}
-
-// TODO niko/etl
-// func TestProperties_SetServices(t *testing.T) {}

+ 1 - 1
ui/src/services/allocation.js

@@ -1,7 +1,7 @@
 import axios from 'axios';
 
 class AllocationService {
-  BASE_URL = 'http://localhost:9090/model/allocation';
+  BASE_URL = 'http://localhost:9090/allocation';
 
   async fetchAllocation(win, aggregate, options) {
     const { accumulate, filters, } = options;