Browse Source

Merge branch 'develop' into scaleway

Ajay Tripathy 3 years ago
parent
commit
647d40410a

+ 3 - 3
CONTRIBUTING.md

@@ -4,13 +4,13 @@ Thanks for your help improving the OpenCost project! There are many ways to cont
 
 * contributing or providing feedback on the [OpenCost Spec](https://github.com/opencost/opencost/tree/develop/spec)
 * contributing documentation 
-* joining the discussion on Slack or in [OpenCost community discussions](https://drive.google.com/drive/folders/1hXlcyFPePB7t3z6lyVzdxmdfrbzeT1Jz)
+* joining the discussion on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or in the [OpenCost community discussions](https://drive.google.com/drive/folders/1hXlcyFPePB7t3z6lyVzdxmdfrbzeT1Jz) folder
 * committing software via the workflow below
 
 ## Getting Help
 
 If you have a question about OpenCost or have encountered problems using it,
-you can start by asking a question on [Slack](https://join.slack.com/t/kubecost/shared_invite/enQtNTA2MjQ1NDUyODE5LWFjYzIzNWE4MDkzMmUyZGU4NjkwMzMyMjIyM2E0NGNmYjExZjBiNjk1YzY5ZDI0ZTNhZDg4NjlkMGRkYzFlZTU) or via email at [support@kubecost.com](support@kubecost.com)
+you can start by asking a question on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or via email at [support@kubecost.com](support@kubecost.com)
 
 ## Workflow
 
@@ -96,4 +96,4 @@ Please write a commit message with Fixes Issue # if there is an outstanding issu
 
 Please run go fmt on the project directory. Lint can be okay (for example, comments on exported functions are nice but not required on the server).
 
-Please email us (support@kubecost.com) or reach out to us on [Slack](https://join.slack.com/t/kubecost/shared_invite/enQtNTA2MjQ1NDUyODE5LWFjYzIzNWE4MDkzMmUyZGU4NjkwMzMyMjIyM2E0NGNmYjExZjBiNjk1YzY5ZDI0ZTNhZDg4NjlkMGRkYzFlZTU) if you need help or have any questions!
+Please email us [support@kubecost.com](support@kubecost.com) or reach out to us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel if you need help or have any questions!

+ 5 - 32
README.md

@@ -4,7 +4,8 @@
 
 OpenCost models give teams visibility into current and historical Kubernetes spend and resource allocation. These models provide cost transparency in Kubernetes environments that support multiple applications, teams, departments, etc.
 
-OpenCost was originally developed and [open sourced](https://github.com/opencost/opencost/issues/1224) by [Kubecost](https://kubecost.com). This project combines a [specification](/spec/) as well as a Golang implementation of these detailed requirements.
+
+OpenCost was originally developed and open sourced by [Kubecost](https://kubecost.com). This project combines a [specification](/spec/) as well as a Golang implementation of these detailed requirements.
 
 ![OpenCost allocation UI](/allocation-drilldown.gif)
 
@@ -22,7 +23,7 @@ To see the full functionality of OpenCost you can view [OpenCost features](https
 
 You can deploy OpenCost on any Kubernetes 1.8+ cluster in a matter of minutes, if not seconds!
 
-Visit the full documentation for [recommended install options](https://docs.kubecost.com/install). Compared to building from source, installing from Helm is faster and includes all necessary dependencies.
+Visit the full documentation for [recommended install options](https://www.opencost.io/docs/install). Compared to building from source, installing from Helm is faster and includes all necessary dependencies.
 
 ## Usage
 
@@ -38,36 +39,8 @@ and contributing changes.
 
 ## Community
 
-If you need any support or have any questions on contributing to the project, you can reach us on [Slack](https://join.slack.com/t/kubecost/shared_invite/enQtNTA2MjQ1NDUyODE5LWFjYzIzNWE4MDkzMmUyZGU4NjkwMzMyMjIyM2E0NGNmYjExZjBiNjk1YzY5ZDI0ZTNhZDg4NjlkMGRkYzFlZTU) or via email at [team@kubecost.com](team@kubecost.com).
+If you need any support or have any questions on contributing to the project, you can reach us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or via email at [team@kubecost.com](team@kubecost.com).
 
 ## FAQ
 
-### _How do you measure the cost of CPU/RAM/GPU/storage for a container, pod, deployment, etc._
-
-The OpenCost cost model collects pricing data from major cloud providers, e.g. GCP, Azure and AWS, to provide the real-time cost of running workloads. Based on data from these APIs, each container/pod inherits a cost per CPU-hour, GPU-hour, Storage Gb-hour and cost per RAM Gb-hour based on the node where it was running or the class of storage provisioned. This means containers of the same size, as measured by the max of requests or usage, could be charged different resource rates if they are scheduled in separate regions, on nodes with different usage types (on-demand vs preemptible), etc.
-
-For on-prem clusters, these resource prices can be configured directly with custom pricing sheets (more below).
-
-Measuring the CPU/RAM/GPU cost of a deployment, service, namespace, etc is the aggregation of its individual container costs.
-
-### _How do you determine RAM/CPU/GPU costs for a node when this data isn’t provided by a cloud provider?_
-
-When explicit RAM, CPU or GPU prices are not provided by your cloud provider, the OpenCost model falls back to the ratio of base CPU, GPU and RAM price inputs supplied. The default values for these parameters are based on the marginal resource rates of the cloud provider, but they can be customized within OpenCost.
-
-These base RAM/CPU/GPU prices are normalized to ensure the sum of each component is equal to the total price of the node provisioned, based on billing rates from your provider. When the sum of RAM/CPU/GPU costs is greater (or less) than the price of the node, then the ratio between the input prices is held constant.
-
-As an example, let's imagine a node with 1 GPU, 1 CPU and 1 Gb of RAM that costs $35/mo. If your base GPU price is $30, base CPU price is $30 and RAM Gb price is $10, then these inputs will be normalized to $15 for GPU, $15 for CPU and $5 for RAM so that the sum equals the cost of the node. Note that the price of a GPU, as well as the price of a CPU remain 3x the price of a Gb of RAM.
-
-    NodeHourlyCost = NORMALIZED_GPU_PRICE * # of GPUS + NORMALIZED_CPU_PRICE * # of CPUS + NORMALIZED_RAM_PRICE * # of RAM Gb
-
-### _How do you allocate a specific amount of RAM/CPU to an individual pod or container?_
-
-Resources are allocated based on the time-weighted maximum of resource Requests and Usage over the measured period. For example, a pod with no usage and 1 CPU requested for 12 hours out of a 24 hour window would be allocated 12 CPU hours. For pods with BestEffort quality of service (i.e. no requests) allocation is done solely on resource usage.
-
-### _How do I set my AWS Spot estimates for cost allocation?_
-
-Modify [spotCPU](https://github.com/opencost/opencost/blob/master/configs/default.json#L5) and [spotRAM](https://github.com/opencost/opencost/blob/master/configs/default.json#L7) in default.json to the level of recent market prices. Allocation will use these prices, but it does not take into account what you are actually charged by AWS. Alternatively, you can provide an AWS key to allow access to the Spot data feed. This will provide accurate Spot price reconciliation.
-
-### _Do I need a GCP billing API key?_
-
-We supply a global key with a low limit for evaluation, but you will want to supply your own before moving to production.
+You can view [OpenCost documentation](https://www.opencost.io/docs/FAQ) for a list of commonly asked questions.

+ 1 - 1
go.mod

@@ -29,7 +29,7 @@ require (
 	github.com/json-iterator/go v1.1.12
 	github.com/jszwec/csvutil v1.2.1
 	github.com/julienschmidt/httprouter v1.3.0
-	github.com/kubecost/events v0.0.4
+	github.com/kubecost/events v0.0.6
 	github.com/lib/pq v1.2.0
 	github.com/microcosm-cc/bluemonday v1.0.16
 	github.com/minio/minio-go/v7 v7.0.15

+ 2 - 2
go.sum

@@ -405,8 +405,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kubecost/events v0.0.4 h1:iQJyG8q+4OjzGZTbLD1DOhT4NyO1/bzQn8HcasKOUzQ=
-github.com/kubecost/events v0.0.4/go.mod h1:i3DyCVatehxq6tAbvBrARuafjkX2DECPk9OWxiaRIhY=
+github.com/kubecost/events v0.0.6 h1:ql1ZUnLfheD2hHm/otsHZ8BOYt87rY5e9sPFHges4ec=
+github.com/kubecost/events v0.0.6/go.mod h1:i3DyCVatehxq6tAbvBrARuafjkX2DECPk9OWxiaRIhY=
 github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
 github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
 github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=

+ 76 - 46
pkg/cloud/azureprovider.go

@@ -37,6 +37,7 @@ const (
 	AzureDiskStandardStorageClass    = "standard_hdd"
 	defaultSpotLabel                 = "kubernetes.azure.com/scalesetpriority"
 	defaultSpotLabelValue            = "spot"
+	AzureStorageUpdateType           = "AzureStorage"
 )
 
 var (
@@ -509,18 +510,8 @@ func (ask *AzureServiceKey) IsValid() bool {
 }
 
 // Loads the azure authentication via configuration or a secret set at install time.
-func (az *Azure) getAzureAuth(forceReload bool, cp *CustomPricing) (subscriptionID, clientID, clientSecret, tenantID string) {
-	// 1. Check config values first (set from frontend UI)
-	if cp.AzureSubscriptionID != "" && cp.AzureClientID != "" && cp.AzureClientSecret != "" && cp.AzureTenantID != "" {
-		subscriptionID = cp.AzureSubscriptionID
-		clientID = cp.AzureClientID
-		clientSecret = cp.AzureClientSecret
-		tenantID = cp.AzureTenantID
-
-		return
-	}
-
-	// 2. Check for secret
+func (az *Azure) getAzureRateCardAuth(forceReload bool, cp *CustomPricing) (subscriptionID, clientID, clientSecret, tenantID string) {
+	// 1. Check for secret (secret values will always be used if they are present)
 	s, _ := az.loadAzureAuthSecret(forceReload)
 	if s != nil && s.IsValid() {
 		subscriptionID = s.SubscriptionID
@@ -529,38 +520,62 @@ func (az *Azure) getAzureAuth(forceReload bool, cp *CustomPricing) (subscription
 		tenantID = s.ServiceKey.Tenant
 		return
 	}
+	// 2. Check config values (set though endpoint)
+	if cp.AzureSubscriptionID != "" && cp.AzureClientID != "" && cp.AzureClientSecret != "" && cp.AzureTenantID != "" {
+		subscriptionID = cp.AzureSubscriptionID
+		clientID = cp.AzureClientID
+		clientSecret = cp.AzureClientSecret
+		tenantID = cp.AzureTenantID
+		return
+	}
 
 	// 3. Empty values
 	return "", "", "", ""
 }
 
 // GetAzureStorageConfig retrieves storage config from secret and sets default values
-func (az *Azure) GetAzureStorageConfig(forceReload bool) (*AzureStorageConfig, error) {
-	// retrieve config for default subscription id
-	defaultSubscriptionID := ""
-	config, err := az.GetConfig()
-	if err == nil {
-		defaultSubscriptionID = config.AzureSubscriptionID
+func (az *Azure) GetAzureStorageConfig(forceReload bool, cp *CustomPricing) (*AzureStorageConfig, error) {
+	// default subscription id
+	defaultSubscriptionID := cp.AzureSubscriptionID
+
+	// 1. Check Config for storage set up
+	asc := &AzureStorageConfig{
+		SubscriptionId: cp.AzureStorageSubscriptionID,
+		AccountName:    cp.AzureStorageAccount,
+		AccessKey:      cp.AzureStorageAccessKey,
+		ContainerName:  cp.AzureStorageContainer,
+		ContainerPath:  cp.AzureContainerPath,
+		AzureCloud:     cp.AzureCloud,
+	}
+
+	// check for required fields
+	if asc != nil && asc.AccessKey != "" && asc.AccountName != "" && asc.ContainerName != "" && asc.SubscriptionId != "" {
+		az.serviceAccountChecks.set("hasStorage", &ServiceAccountCheck{
+			Message: "Azure Storage Config exists",
+			Status:  true,
+		})
+		return asc, nil
 	}
 
-	// 1. Check for secret
-	s, err := az.loadAzureStorageConfig(forceReload)
+	// 2. Check for secret
+	asc, err := az.loadAzureStorageConfig(forceReload)
 	if err != nil {
 		log.Errorf("Error, %s", err.Error())
 	}
-	if s != nil && s.AccessKey != "" && s.AccountName != "" && s.ContainerName != "" {
+	// To support already configured users, subscriptionID may not be set in secret in which case, the subscriptionID
+	// for the rate card API is used
+	if asc.SubscriptionId == "" {
+		asc.SubscriptionId = defaultSubscriptionID
+	}
+	// check for required fields
+	if asc != nil && asc.AccessKey != "" && asc.AccountName != "" && asc.ContainerName != "" && asc.SubscriptionId == "" {
 		az.serviceAccountChecks.set("hasStorage", &ServiceAccountCheck{
 			Message: "Azure Storage Config exists",
 			Status:  true,
 		})
-
-		// To support already configured users, subscriptionID may not be set in secret in which case, the subscriptionID
-		// for the rate card API is used
-		if s.SubscriptionId == "" {
-			s.SubscriptionId = defaultSubscriptionID
-		}
-		return s, nil
+		return asc, nil
 	}
+
 	az.serviceAccountChecks.set("hasStorage", &ServiceAccountCheck{
 		Message: "Azure Storage Config exists",
 		Status:  false,
@@ -744,7 +759,7 @@ func (az *Azure) DownloadPricingData() error {
 	}
 
 	// Load the service provider keys
-	subscriptionID, clientID, clientSecret, tenantID := az.getAzureAuth(true, config)
+	subscriptionID, clientID, clientSecret, tenantID := az.getAzureRateCardAuth(false, config)
 	config.AzureSubscriptionID = subscriptionID
 	config.AzureClientID = clientID
 	config.AzureClientSecret = clientSecret
@@ -1165,27 +1180,42 @@ func (az *Azure) UpdateConfigFromConfigMap(a map[string]string) (*CustomPricing,
 }
 
 func (az *Azure) UpdateConfig(r io.Reader, updateType string) (*CustomPricing, error) {
-	defer az.DownloadPricingData()
-
 	return az.Config.Update(func(c *CustomPricing) error {
-		a := make(map[string]interface{})
-		err := json.NewDecoder(r).Decode(&a)
-		if err != nil {
-			return err
-		}
-		for k, v := range a {
-			kUpper := strings.Title(k) // Just so we consistently supply / receive the same values, uppercase the first letter.
-			vstr, ok := v.(string)
-			if ok {
-				err := SetCustomPricingField(c, kUpper, vstr)
-				if err != nil {
-					return err
+		if updateType == AzureStorageUpdateType {
+			asc := &AzureStorageConfig{}
+			err := json.NewDecoder(r).Decode(&asc)
+			if err != nil {
+				return err
+			}
+
+			c.AzureStorageSubscriptionID = asc.SubscriptionId
+			c.AzureStorageAccount = asc.AccountName
+			if asc.AccessKey != "" {
+				c.AzureStorageAccessKey = asc.AccessKey
+			}
+			c.AzureStorageContainer = asc.ContainerName
+			c.AzureContainerPath = asc.ContainerPath
+			c.AzureCloud = asc.AzureCloud
+		} else {
+			defer az.DownloadPricingData()
+			a := make(map[string]interface{})
+			err := json.NewDecoder(r).Decode(&a)
+			if err != nil {
+				return err
+			}
+			for k, v := range a {
+				kUpper := strings.Title(k) // Just so we consistently supply / receive the same values, uppercase the first letter.
+				vstr, ok := v.(string)
+				if ok {
+					err := SetCustomPricingField(c, kUpper, vstr)
+					if err != nil {
+						return err
+					}
+				} else {
+					return fmt.Errorf("type error while updating config for %s", kUpper)
 				}
-			} else {
-				return fmt.Errorf("type error while updating config for %s", kUpper)
 			}
 		}
-
 		if env.IsRemoteEnabled() {
 			err := UpdateClusterMeta(env.GetClusterID(), c.ClusterName)
 			if err != nil {

+ 6 - 0
pkg/cloud/provider.go

@@ -179,6 +179,12 @@ type CustomPricing struct {
 	AzureTenantID                string `json:"azureTenantID"`
 	AzureBillingRegion           string `json:"azureBillingRegion"`
 	AzureOfferDurableID          string `json:"azureOfferDurableID"`
+	AzureStorageSubscriptionID   string `json:"azureStorageSubscriptionID"`
+	AzureStorageAccount          string `json:"azureStorageAccount"`
+	AzureStorageAccessKey        string `json:"azureStorageAccessKey"`
+	AzureStorageContainer        string `json:"azureStorageContainer"`
+	AzureContainerPath           string `json:"azureContainerPath"`
+	AzureCloud                   string `json:"azureCloud"`
 	CurrencyCode                 string `json:"currencyCode"`
 	Discount                     string `json:"discount"`
 	NegotiatedDiscount           string `json:"negotiatedDiscount"`

+ 1 - 1
pkg/costmodel/aggregation.go

@@ -2201,7 +2201,7 @@ func (a *Accesses) ComputeAllocationHandlerSummary(w http.ResponseWriter, r *htt
 
 	sasl := []*kubecost.SummaryAllocationSet{}
 	for _, as := range asr.Slice() {
-		sas := kubecost.NewSummaryAllocationSet(as, []kubecost.AllocationMatchFunc{}, []kubecost.AllocationMatchFunc{}, false, false)
+		sas := kubecost.NewSummaryAllocationSet(as, nil, []kubecost.AllocationMatchFunc{}, false, false)
 		sasl = append(sasl, sas)
 	}
 	sasr := kubecost.NewSummaryAllocationSetRange(sasl...)

+ 5 - 3
pkg/costmodel/allocation.go

@@ -293,8 +293,10 @@ func (cm *CostModel) computeAllocation(start, end time.Time, resolution time.Dur
 	}
 
 	// TODO:CLEANUP remove "max batch" idea and clusterStart/End
-	cm.buildPodMap(window, resolution, env.GetETLMaxPrometheusQueryDuration(), podMap, clusterStart, clusterEnd, ingestPodUID, podUIDKeyMap)
-
+	err := cm.buildPodMap(window, resolution, env.GetETLMaxPrometheusQueryDuration(), podMap, clusterStart, clusterEnd, ingestPodUID, podUIDKeyMap)
+	if err != nil {
+		log.Errorf("CostModel.ComputeAllocation: failed to build pod map: %s", err.Error())
+	}
 	// (2) Run and apply remaining queries
 
 	// Query for the duration between start and end
@@ -476,7 +478,7 @@ func (cm *CostModel) computeAllocation(start, end time.Time, resolution time.Dur
 
 	if ctx.HasErrors() {
 		for _, err := range ctx.Errors() {
-			log.Errorf("CostModel.ComputeAllocation: %s", err)
+			log.Errorf("CostModel.ComputeAllocation: query context error %s", err)
 		}
 
 		return allocSet, ctx.ErrorCollection()

+ 12 - 0
pkg/costmodel/router.go

@@ -615,6 +615,18 @@ func (a *Accesses) UpdateBigQueryInfoConfigs(w http.ResponseWriter, r *http.Requ
 	return
 }
 
+func (a *Accesses) UpdateAzureStorageConfigs(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	data, err := a.CloudProvider.UpdateConfig(r.Body, cloud.AzureStorageUpdateType)
+	if err != nil {
+		w.Write(WrapData(data, err))
+		return
+	}
+	w.Write(WrapData(data, err))
+	return
+}
+
 func (a *Accesses) UpdateConfigByKey(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*")

+ 7 - 0
pkg/env/costmodelenv.go

@@ -17,6 +17,7 @@ const (
 	AWSClusterIDEnvVar       = "AWS_CLUSTER_ID"
 
 	KubecostNamespaceEnvVar        = "KUBECOST_NAMESPACE"
+	PodNameEnvVar                  = "POD_NAME"
 	ClusterIDEnvVar                = "CLUSTER_ID"
 	ClusterProfileEnvVar           = "CLUSTER_PROFILE"
 	PrometheusServerEndpointEnvVar = "PROMETHEUS_SERVER_ENDPOINT"
@@ -210,6 +211,12 @@ func GetKubecostNamespace() string {
 	return Get(KubecostNamespaceEnvVar, "kubecost")
 }
 
+// GetPodName returns the name of the current running pod. If this environment variable is not set,
+// empty string is returned.
+func GetPodName() string {
+	return Get(PodNameEnvVar, "")
+}
+
 // GetClusterProfile returns the environment variable value for ClusterProfileEnvVar which
 // represents the cluster profile configured for
 func GetClusterProfile() string {

+ 19 - 24
pkg/kubecost/allocation.go

@@ -830,7 +830,7 @@ func NewAllocationSet(start, end time.Time, allocs ...*Allocation) *AllocationSe
 // simple flag for sharing idle resources.
 type AllocationAggregationOptions struct {
 	AllocationTotalsStore AllocationTotalsStore
-	FilterFuncs           []AllocationMatchFunc
+	Filter                AllocationFilter
 	IdleByNode            bool
 	LabelConfig           *LabelConfig
 	MergeUnallocated      bool
@@ -906,13 +906,19 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		options.ShareIdle = ShareNone
 	}
 
+	// Pre-flatten the filter so we can just check == nil to see if there are
+	// filters.
+	if options.Filter != nil {
+		options.Filter = options.Filter.Flattened()
+	}
+
 	var allocatedTotalsMap map[string]map[string]float64
 
 	// If aggregateBy is nil, we don't aggregate anything. On the other hand,
 	// an empty slice implies that we should aggregate everything. See
 	// generateKey for why that makes sense.
 	shouldAggregate := aggregateBy != nil
-	shouldFilter := len(options.FilterFuncs) > 0
+	shouldFilter := options.Filter != nil
 	shouldShare := len(options.SharedHourlyCosts) > 0 || len(options.ShareFuncs) > 0
 	if !shouldAggregate && !shouldFilter && !shouldShare && options.ShareIdle == ShareNone {
 		// There is nothing for AggregateBy to do, so simply return nil
@@ -1063,7 +1069,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// Note that this can happen for any field, not just cluster, so we again
 	// need to track this on a per-cluster or per-node, per-allocation, per-resource basis.
 	var idleFiltrationCoefficients map[string]map[string]map[string]float64
-	if len(options.FilterFuncs) > 0 && options.ShareIdle == ShareNone {
+	if shouldFilter && options.ShareIdle == ShareNone {
 		idleFiltrationCoefficients, _, err = computeIdleCoeffs(options, as, shareSet)
 		if err != nil {
 			return fmt.Errorf("error computing idle filtration coefficients: %s", err)
@@ -1115,12 +1121,10 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 		skip := false
 
-		// (3) If any of the filter funcs fail, immediately skip the allocation.
-		for _, ff := range options.FilterFuncs {
-			if !ff(alloc) {
-				skip = true
-				break
-			}
+		// (3) If the allocation does not match the filter, immediately skip the
+		// allocation.
+		if options.Filter != nil {
+			skip = !options.Filter.Matches(alloc)
 		}
 		if skip {
 			// If we are tracking idle filtration coefficients, delete the
@@ -1305,11 +1309,8 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// aggregate if an exact match is found.
 	for _, alloc := range externalSet.allocations {
 		skip := false
-		for _, ff := range options.FilterFuncs {
-			if !ff(alloc) {
-				skip = true
-				break
-			}
+		if options.Filter != nil {
+			skip = !options.Filter.Matches(alloc)
 		}
 		if !skip {
 			key := alloc.generateKey(aggregateBy, options.LabelConfig)
@@ -1342,11 +1343,8 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		for _, idleAlloc := range idleSet.allocations {
 			// if the idle does not apply to the non-filtered values, skip it
 			skip := false
-			for _, ff := range options.FilterFuncs {
-				if !ff(idleAlloc) {
-					skip = true
-					break
-				}
+			if options.Filter != nil {
+				skip = !options.Filter.Matches(idleAlloc)
 			}
 			if skip {
 				continue
@@ -1481,11 +1479,8 @@ func computeShareCoeffs(aggregateBy []string, options *AllocationAggregationOpti
 		// is removed. (Otherwise, all the shared cost will get redistributed
 		// over the unfiltered results, inflating their shared costs.)
 		filtered := false
-		for _, ff := range options.FilterFuncs {
-			if !ff(alloc) {
-				filtered = true
-				break
-			}
+		if options.Filter != nil {
+			filtered = !options.Filter.Matches(alloc)
 		}
 		if filtered {
 			name = "__filtered__"

+ 30 - 33
pkg/kubecost/allocation_test.go

@@ -750,13 +750,6 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	}
 
 	// Filters
-	isCluster := func(matchCluster string) func(*Allocation) bool {
-		return func(a *Allocation) bool {
-			cluster := a.Properties.Cluster
-			return cluster == matchCluster
-		}
-	}
-
 	isNamespace := func(matchNamespace string) func(*Allocation) bool {
 		return func(a *Allocation) bool {
 			namespace := a.Properties.Namespace
@@ -1190,8 +1183,12 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
-				ShareIdle:   ShareNone,
+				Filter: AllocationFilterCondition{
+					Field: FilterClusterID,
+					Op:    FilterEquals,
+					Value: "cluster1",
+				},
+				ShareIdle: ShareNone,
 			},
 			numResults: 1 + numIdle,
 			totalCost:  66.0,
@@ -1208,8 +1205,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
-				ShareIdle:   ShareWeighted,
+				Filter:    AllocationFilterCondition{Field: FilterClusterID, Op: FilterEquals, Value: "cluster1"},
+				ShareIdle: ShareWeighted,
 			},
 			numResults: 1,
 			totalCost:  66.0,
@@ -1225,8 +1222,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
-				ShareIdle:   ShareNone,
+				Filter:    AllocationFilterCondition{Field: FilterClusterID, Op: FilterEquals, Value: "cluster1"},
+				ShareIdle: ShareNone,
 			},
 			numResults: 2 + numIdle,
 			totalCost:  66.0,
@@ -1244,8 +1241,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareIdle:   ShareNone,
+				Filter:    AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareIdle: ShareNone,
 			},
 			numResults: numClusters + numIdle,
 			totalCost:  46.31,
@@ -1287,8 +1284,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareIdle:   ShareWeighted,
+				Filter:    AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareIdle: ShareWeighted,
 			},
 			numResults: 1,
 			totalCost:  46.31,
@@ -1312,7 +1309,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
+				Filter:            AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 				ShareSplit:        ShareWeighted,
 			},
@@ -1331,9 +1328,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
-				ShareSplit:  ShareWeighted,
+				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				ShareSplit: ShareWeighted,
 			},
 			numResults: 1 + numIdle,
 			totalCost:  79.6667, // should be 74.7708, but I'm punting -- too difficult (NK)
@@ -1350,10 +1347,10 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
-				ShareSplit:  ShareWeighted,
-				ShareIdle:   ShareWeighted,
+				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				ShareSplit: ShareWeighted,
+				ShareIdle:  ShareWeighted,
 			},
 			numResults: 1,
 			totalCost:  74.77083,
@@ -1456,10 +1453,10 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
-				ShareSplit:  ShareWeighted,
-				ShareIdle:   ShareWeighted,
+				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				ShareSplit: ShareWeighted,
+				ShareIdle:  ShareWeighted,
 			},
 			numResults: 1,
 			totalCost:  74.77,
@@ -1502,7 +1499,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
+				Filter:            AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
 				ShareSplit:        ShareWeighted,
 				ShareIdle:         ShareWeighted,
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
@@ -1568,9 +1565,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
-				ShareIdle:   ShareWeighted,
-				IdleByNode:  true,
+				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				ShareIdle:  ShareWeighted,
+				IdleByNode: true,
 			},
 			numResults: 1,
 			totalCost:  46.31,

+ 11 - 0
pkg/kubecost/allocationfilter.go

@@ -417,3 +417,14 @@ func (or AllocationFilterOr) Matches(a *Allocation) bool {
 
 	return false
 }
+
+// AllocationFilterNone is a filter that matches no allocations. This is useful
+// for applications like authorization, where a user/group/role may be disallowed
+// from viewing Allocation data entirely.
+type AllocationFilterNone struct{}
+
+func (afn AllocationFilterNone) String() string { return "(none)" }
+
+func (afn AllocationFilterNone) Flattened() AllocationFilter { return afn }
+
+func (afn AllocationFilterNone) Matches(a *Allocation) bool { return false }

+ 135 - 0
pkg/kubecost/allocationfilter_test.go

@@ -615,6 +615,121 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 	}
 }
 
+func Test_AllocationFilterNone_Matches(t *testing.T) {
+	cases := []struct {
+		name string
+		a    *Allocation
+	}{
+		{
+			name: "nil",
+			a:    nil,
+		},
+		{
+			name: "nil properties",
+			a: &Allocation{
+				Properties: nil,
+			},
+		},
+		{
+			name: "empty properties",
+			a: &Allocation{
+				Properties: &AllocationProperties{},
+			},
+		},
+		{
+			name: "ClusterID",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Cluster: "cluster-one",
+				},
+			},
+		},
+		{
+			name: "Node",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Node: "node123",
+				},
+			},
+		},
+		{
+			name: "Namespace",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Namespace: "kube-system",
+				},
+			},
+		},
+		{
+			name: "ControllerKind",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					ControllerKind: "deployment", // We generally store controller kinds as all lowercase
+				},
+			},
+		},
+		{
+			name: "ControllerName",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Controller: "kc-cost-analyzer",
+				},
+			},
+		},
+		{
+			name: "Pod",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Pod: "pod-123 UID-ABC",
+				},
+			},
+		},
+		{
+			name: "Container",
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Container: "cost-model",
+				},
+			},
+		},
+		{
+			name: `label`,
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Labels: map[string]string{
+						"app": "foo",
+					},
+				},
+			},
+		},
+		{
+			name: `annotation`,
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Annotations: map[string]string{
+						"prom_modified_name": "testing123",
+					},
+				},
+			},
+		},
+		{
+			name: `services`,
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Services: []string{"serv1", "serv2"},
+				},
+			},
+		},
+	}
+
+	for _, c := range cases {
+		result := AllocationFilterNone{}.Matches(c.a)
+
+		if result {
+			t.Errorf("%s: should have been rejected", c.name)
+		}
+	}
+}
 func Test_AllocationFilterAnd_Matches(t *testing.T) {
 	cases := []struct {
 		name   string
@@ -723,6 +838,21 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 			}},
 			expected: false,
 		},
+		{
+			name: `(and none) matches nothing`,
+			a: &Allocation{
+				Properties: &AllocationProperties{
+					Namespace: "kube-system",
+					Labels: map[string]string{
+						"app": "bar",
+					},
+				},
+			},
+			filter: AllocationFilterAnd{[]AllocationFilter{
+				AllocationFilterNone{},
+			}},
+			expected: false,
+		},
 	}
 
 	for _, c := range cases {
@@ -983,6 +1113,11 @@ func Test_AllocationFilter_Flattened(t *testing.T) {
 				},
 			}},
 		},
+		{
+			name:     "AllocationFilterNone",
+			input:    AllocationFilterNone{},
+			expected: AllocationFilterNone{},
+		},
 	}
 
 	for _, c := range cases {

+ 20 - 5
pkg/kubecost/allocationprops.go

@@ -317,7 +317,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			}
 		case agg == AllocationDepartmentProp:
 			labels := p.Labels
-			if labels == nil {
+			annotations := p.Annotations
+			if labels == nil && annotations == nil {
 				names = append(names, UnallocatedSuffix)
 			} else {
 				labelNames := strings.Split(labelConfig.DepartmentLabel, ",")
@@ -325,6 +326,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 					labelName = labelConfig.Sanitize(labelName)
 					if labelValue, ok := labels[labelName]; ok {
 						names = append(names, labelValue)
+					} else if annotationValue, ok := annotations[labelName]; ok {
+						names = append(names, annotationValue)
 					} else {
 						names = append(names, UnallocatedSuffix)
 					}
@@ -332,7 +335,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			}
 		case agg == AllocationEnvironmentProp:
 			labels := p.Labels
-			if labels == nil {
+			annotations := p.Annotations
+			if labels == nil && annotations == nil {
 				names = append(names, UnallocatedSuffix)
 			} else {
 				labelNames := strings.Split(labelConfig.EnvironmentLabel, ",")
@@ -340,6 +344,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 					labelName = labelConfig.Sanitize(labelName)
 					if labelValue, ok := labels[labelName]; ok {
 						names = append(names, labelValue)
+					} else if annotationValue, ok := annotations[labelName]; ok {
+						names = append(names, annotationValue)
 					} else {
 						names = append(names, UnallocatedSuffix)
 					}
@@ -347,7 +353,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			}
 		case agg == AllocationOwnerProp:
 			labels := p.Labels
-			if labels == nil {
+			annotations := p.Annotations
+			if labels == nil && annotations == nil {
 				names = append(names, UnallocatedSuffix)
 			} else {
 				labelNames := strings.Split(labelConfig.OwnerLabel, ",")
@@ -355,6 +362,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 					labelName = labelConfig.Sanitize(labelName)
 					if labelValue, ok := labels[labelName]; ok {
 						names = append(names, labelValue)
+					} else if annotationValue, ok := annotations[labelName]; ok {
+						names = append(names, annotationValue)
 					} else {
 						names = append(names, UnallocatedSuffix)
 					}
@@ -362,7 +371,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			}
 		case agg == AllocationProductProp:
 			labels := p.Labels
-			if labels == nil {
+			annotations := p.Annotations
+			if labels == nil && annotations == nil {
 				names = append(names, UnallocatedSuffix)
 			} else {
 				labelNames := strings.Split(labelConfig.ProductLabel, ",")
@@ -370,6 +380,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 					labelName = labelConfig.Sanitize(labelName)
 					if labelValue, ok := labels[labelName]; ok {
 						names = append(names, labelValue)
+					} else if annotationValue, ok := annotations[labelName]; ok {
+						names = append(names, annotationValue)
 					} else {
 						names = append(names, UnallocatedSuffix)
 					}
@@ -377,7 +389,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 			}
 		case agg == AllocationTeamProp:
 			labels := p.Labels
-			if labels == nil {
+			annotations := p.Annotations
+			if labels == nil && annotations == nil {
 				names = append(names, UnallocatedSuffix)
 			} else {
 				labelNames := strings.Split(labelConfig.TeamLabel, ",")
@@ -385,6 +398,8 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 					labelName = labelConfig.Sanitize(labelName)
 					if labelValue, ok := labels[labelName]; ok {
 						names = append(names, labelValue)
+					} else if annotationValue, ok := annotations[labelName]; ok {
+						names = append(names, annotationValue)
 					} else {
 						names = append(names, UnallocatedSuffix)
 					}

+ 92 - 0
pkg/kubecost/allocationprops_test.go

@@ -0,0 +1,92 @@
+package kubecost
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestGenerateKey(t *testing.T) {
+
+	cases := map[string]struct {
+		aggregate       []string
+		allocationProps *AllocationProperties
+		expected        string
+	}{
+		"aggregate by owner without owner labels": {
+			aggregate: []string{"owner"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"app": "cost-analyzer"},
+				Annotations: map[string]string{"owner": "test owner 123"},
+			},
+			expected: "test owner 123",
+		},
+		"aggregate by owner without labels": {
+			aggregate: []string{"owner"},
+			allocationProps: &AllocationProperties{
+				Annotations: map[string]string{"owner": "test owner 123"},
+			},
+			expected: "test owner 123",
+		},
+		"aggregate by owner with owner label and annotation": {
+			aggregate: []string{"owner"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"owner": "owner-label"},
+				Annotations: map[string]string{"owner": "owner-annotation"},
+			},
+			expected: "owner-label",
+		},
+		"aggregate by environment with environment label and annotation": {
+			aggregate: []string{"environment"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"env": "environment-label"},
+				Annotations: map[string]string{"env": "environment-annotation"},
+			},
+			expected: "environment-label",
+		},
+		"aggregate by department with department label and annotation": {
+			aggregate: []string{"department"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"department": "department-label"},
+				Annotations: map[string]string{"department": "department-annotation"},
+			},
+			expected: "department-label",
+		},
+		"aggregate by team with team label and annotation": {
+			aggregate: []string{"team"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"team": "team-label"},
+				Annotations: map[string]string{"team": "team-annotation"},
+			},
+			expected: "team-label",
+		},
+		"aggregate by product with product label and annotation": {
+			aggregate: []string{"product"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"app": "product-label"},
+				Annotations: map[string]string{"app": "product-annotation"},
+			},
+			expected: "product-label",
+		},
+		"aggregate by product and owner with multiple labels and annotations": {
+			aggregate: []string{"product", "owner"},
+			allocationProps: &AllocationProperties{
+				Labels:      map[string]string{"app": "product-label", "owner": "owner-label", "team": "team-label"},
+				Annotations: map[string]string{"app": "product-annotation", "owner": "owner-annotation", "team": "team-annotation"},
+			},
+			expected: "product-label/owner-label",
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+
+			lc := NewLabelConfig()
+
+			result := tc.allocationProps.GenerateKey(tc.aggregate, lc)
+
+			if !reflect.DeepEqual(result, tc.expected) {
+				t.Fatalf("expected %+v; got %+v", tc.expected, result)
+			}
+		})
+	}
+}

+ 24 - 13
pkg/kubecost/asset.go

@@ -3,6 +3,7 @@ package kubecost
 import (
 	"encoding"
 	"fmt"
+	"math"
 	"strings"
 	"sync"
 	"time"
@@ -856,8 +857,8 @@ type ClusterManagement struct {
 	labels     AssetLabels
 	properties *AssetProperties
 	window     Window
-	adjustment float64
 	Cost       float64
+	adjustment float64 // @bingen:field[version=16]
 }
 
 // NewClusterManagement creates and returns a new ClusterManagement instance
@@ -2893,36 +2894,46 @@ type DiffKind string
 const (
 	DiffAdded   DiffKind = "added"
 	DiffRemoved          = "removed"
+	DiffChanged          = "changed"
 )
 
 // Diff stores an object and a string that denotes whether that object was
 // added or removed from a set of those objects
 type Diff[T any] struct {
-	Entity T
+	Before T
+	After  T
 	Kind   DiffKind
 }
 
-// DiffAsset takes two AssetSets and returns a slice of Diffs by checking
-// the keys of each AssetSet. If a key is not found, a Diff is generated
-// and added to the slice.
-func DiffAsset(before, after *AssetSet) []Diff[Asset] {
-	changedItems := []Diff[Asset]{}
+// DiffAsset takes two AssetSets and returns a map of keys to Diffs by checking
+// the keys of each AssetSet. If a key is not found or is found with a different total cost,
+// a Diff is generated and added to the map. A found asset will only be added to the map if the new
+// total cost is greater than ratioCostChange * the old total cost
+func DiffAsset(before, after *AssetSet, ratioCostChange float64) (map[string]Diff[Asset], error) {
+	if ratioCostChange < 0.0 {
+		return nil, fmt.Errorf("Percent cost change cannot be less than 0")
+	}
+
+	changedItems := map[string]Diff[Asset]{}
 
 	for assetKey1, asset1 := range before.assets {
-		if _, ok := after.assets[assetKey1]; !ok {
-			d := Diff[Asset]{asset1, DiffRemoved}
-			changedItems = append(changedItems, d)
+		if asset2, ok := after.assets[assetKey1]; !ok {
+			d := Diff[Asset]{asset1, nil, DiffRemoved}
+			changedItems[assetKey1] = d
+		} else if math.Abs(asset1.TotalCost()-asset2.TotalCost()) > ratioCostChange*asset1.TotalCost() { //check if either value exceeds the other by more than pctCostChange
+			d := Diff[Asset]{asset1, asset2, DiffChanged}
+			changedItems[assetKey1] = d
 		}
 	}
 
 	for assetKey2, asset2 := range after.assets {
 		if _, ok := before.assets[assetKey2]; !ok {
-			d := Diff[Asset]{asset2, DiffAdded}
-			changedItems = append(changedItems, d)
+			d := Diff[Asset]{nil, asset2, DiffAdded}
+			changedItems[assetKey2] = d
 		}
 	}
 
-	return changedItems
+	return changedItems, nil
 }
 
 // AssetSetRange is a thread-safe slice of AssetSets. It is meant to

+ 1 - 1
pkg/kubecost/bingen.go

@@ -24,7 +24,7 @@ package kubecost
 // @bingen:generate:Window
 
 // Asset Version Set: Includes Asset pipeline specific resources
-// @bingen:set[name=Assets,version=15]
+// @bingen:set[name=Assets,version=16]
 // @bingen:generate:Any
 // @bingen:generate:Asset
 // @bingen:generate:AssetLabels

+ 77 - 57
pkg/kubecost/diff_test.go

@@ -4,105 +4,125 @@ import (
 	"reflect"
 	"testing"
 	"time"
-
-	"golang.org/x/exp/slices"
 )
 
 func TestDiff(t *testing.T) {
 
 	start := time.Now().AddDate(0, 0, -1)
-	end :=  time.Now()
+	end := time.Now()
 	window1 := NewWindow(&start, &end)
 
 	node1 := NewNode("node1", "cluster1", "123abc", start, end, window1)
+	node1.CPUCost = 10
+	node1b := node1.Clone().(*Node)
+	node1b.CPUCost = 20
+	node1Key, _ := key(node1, nil)
 	node2 := NewNode("node2", "cluster1", "123abc", start, end, window1)
+	node2.CPUCost = 100
+	node2b := node2.Clone().(*Node)
+	node2b.CPUCost = 105
+	node2Key, _ := key(node2, nil)
 	node3 := NewNode("node3", "cluster1", "123abc", start, end, window1)
+	node3Key, _ := key(node3, nil)
 	node4 := NewNode("node4", "cluster1", "123abc", start, end, window1)
+	node4Key, _ := key(node4, nil)
 	disk1 := NewDisk("disk1", "cluster1", "123abc", start, end, window1)
+	disk1Key, _ := key(disk1, nil)
 	disk2 := NewDisk("disk2", "cluster1", "123abc", start, end, window1)
+	disk2Key, _ := key(disk2, nil)
 
 	cases := map[string]struct {
 		inputAssetsBefore []Asset
 		inputAssetsAfter  []Asset
-		expected          []Diff[Asset]
+		costChangeRatio   float64
+		expected          map[string]Diff[Asset]
 	}{
-		"added node":            {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{node1, node2, node3}, 
-			expected:          []Diff[Asset]{{node3, DiffAdded}},
+		"added node": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{node1, node2, node3},
+			expected:          map[string]Diff[Asset]{node3Key: {nil, node3, DiffAdded}},
 		},
-		"multiple adds":         {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{node1, node2, node3, node4}, 
-			expected:          []Diff[Asset]{{node3, DiffAdded}, {node4, DiffAdded}},
+		"multiple adds": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{node1, node2, node3, node4},
+			expected:          map[string]Diff[Asset]{node3Key: {nil, node3, DiffAdded}, node4Key: {nil, node4, DiffAdded}},
 		},
-		"removed node":          {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{node2}, 
-			expected:          []Diff[Asset]{{node1, DiffRemoved}},
+		"removed node": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{node2},
+			expected:          map[string]Diff[Asset]{node1Key: {node1, nil, DiffRemoved}},
 		},
-		"multiple removes":      {
-			inputAssetsBefore: []Asset{node1, node2, node3}, 
-			inputAssetsAfter:  []Asset{node2}, 
-			expected:          []Diff[Asset]{{node1, DiffRemoved}, {node3, DiffRemoved}},
+		"multiple removes": {
+			inputAssetsBefore: []Asset{node1, node2, node3},
+			inputAssetsAfter:  []Asset{node2},
+			expected:          map[string]Diff[Asset]{node1Key: {node1, nil, DiffRemoved}, node3Key: {node3, nil, DiffRemoved}},
 		},
-		"remove all":            {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{}, 
-			expected:          []Diff[Asset]{{node1, DiffRemoved}, {node2, DiffRemoved}},
+		"remove all": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{},
+			expected:          map[string]Diff[Asset]{node1Key: {node1, nil, DiffRemoved}, node2Key: {node2, nil, DiffRemoved}},
 		},
-		"add and remove":        {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{node2, node3}, 
-			expected:          []Diff[Asset]{{node1, DiffRemoved}, {node3, DiffAdded}},
+		"add and remove": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{node2, node3},
+			expected:          map[string]Diff[Asset]{node1Key: {node1, nil, DiffRemoved}, node3Key: {nil, node3, DiffAdded}},
 		},
-		"no change":             {
-			inputAssetsBefore: []Asset{node1, node2}, 
-			inputAssetsAfter:  []Asset{node1, node2}, 
-			expected:          []Diff[Asset]{},
+		"no change": {
+			inputAssetsBefore: []Asset{node1, node2},
+			inputAssetsAfter:  []Asset{node1, node2},
+			expected:          map[string]Diff[Asset]{},
 		},
-		"order switch":          {
-			inputAssetsBefore: []Asset{node2, node1}, 
-			inputAssetsAfter:  []Asset{node1, node2}, 
-			expected:          []Diff[Asset]{},
+		"order switch": {
+			inputAssetsBefore: []Asset{node2, node1},
+			inputAssetsAfter:  []Asset{node1, node2},
+			expected:          map[string]Diff[Asset]{},
 		},
-		"disk add":              {
-			inputAssetsBefore: []Asset{disk1, node1}, 
-			inputAssetsAfter:  []Asset{disk1, node1, disk2}, 
-			expected:          []Diff[Asset]{{disk2, DiffAdded}},
+		"disk add": {
+			inputAssetsBefore: []Asset{disk1, node1},
+			inputAssetsAfter:  []Asset{disk1, node1, disk2},
+			expected:          map[string]Diff[Asset]{disk2Key: {nil, disk2, DiffAdded}},
 		},
-		"disk and node add":     {
-			inputAssetsBefore: []Asset{disk1, node1}, 
-			inputAssetsAfter:  []Asset{disk1, node1, disk2, node2}, 
-			expected:          []Diff[Asset]{{disk2, DiffAdded}, {node2, DiffAdded}},
+		"disk and node add": {
+			inputAssetsBefore: []Asset{disk1, node1},
+			inputAssetsAfter:  []Asset{disk1, node1, disk2, node2},
+			expected:          map[string]Diff[Asset]{disk2Key: {nil, disk2, DiffAdded}, node2Key: {nil, node2, DiffAdded}},
 		},
 		"disk and node removed": {
-			inputAssetsBefore: []Asset{disk1, node1, disk2, node2}, 
-			inputAssetsAfter:  []Asset{disk2, node2}, 
-			expected:          []Diff[Asset]{{disk1, DiffRemoved}, {node1, DiffRemoved}},
+			inputAssetsBefore: []Asset{disk1, node1, disk2, node2},
+			inputAssetsAfter:  []Asset{disk2, node2},
+			expected:          map[string]Diff[Asset]{disk1Key: {disk1, nil, DiffRemoved}, node1Key: {node1, nil, DiffRemoved}},
+		},
+		"cost change more than 10%": {
+			inputAssetsBefore: []Asset{node1},
+			inputAssetsAfter:  []Asset{node1b},
+			costChangeRatio:   0.1,
+			expected:          map[string]Diff[Asset]{node1Key: {node1, node1b, DiffChanged}},
+		},
+		"cost change less than 10%": {
+			inputAssetsBefore: []Asset{node2},
+			inputAssetsAfter:  []Asset{node2b},
+			costChangeRatio:   0.1,
+			expected:          map[string]Diff[Asset]{},
 		},
 	}
 
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
 			as1 := NewAssetSet(start, end, tc.inputAssetsBefore...)
+
 			as2 := NewAssetSet(start, end, tc.inputAssetsAfter...)
 
-			result := DiffAsset(as1.Clone(), as2.Clone())
+			result, err := DiffAsset(as1.Clone(), as2.Clone(), tc.costChangeRatio)
 
-			slices.SortFunc(result, func(a, b Diff[Asset]) bool {
-				return a.Entity.Properties().Name < b.Entity.Properties().Name
-			})
+			if err != nil {
+				t.Fatalf("error; got %s", err)
+			}
 
-			slices.SortFunc(tc.expected, func(a, b Diff[Asset]) bool {
-				return a.Entity.Properties().Name < b.Entity.Properties().Name
-			})
-	
 			if !reflect.DeepEqual(result, tc.expected) {
 				t.Fatalf("expected %+v; got %+v", tc.expected, result)
 			}
-			
+
 		})
 	}
 
-}
+}

+ 13 - 14
pkg/kubecost/kubecost_codecs.go

@@ -13,12 +13,11 @@ package kubecost
 
 import (
 	"fmt"
+	util "github.com/opencost/opencost/pkg/util"
 	"reflect"
 	"strings"
 	"sync"
 	"time"
-
-	util "github.com/opencost/opencost/pkg/util"
 )
 
 const (
@@ -34,17 +33,17 @@ const (
 )
 
 const (
-	// DefaultCodecVersion is used for any resources listed in the Default version set
-	DefaultCodecVersion uint8 = 15
-
-	// AssetsCodecVersion is used for any resources listed in the Assets version set
-	AssetsCodecVersion uint8 = 15
-
 	// AllocationCodecVersion is used for any resources listed in the Allocation version set
 	AllocationCodecVersion uint8 = 15
 
 	// AuditCodecVersion is used for any resources listed in the Audit version set
 	AuditCodecVersion uint8 = 1
+
+	// DefaultCodecVersion is used for any resources listed in the Default version set
+	DefaultCodecVersion uint8 = 15
+
+	// AssetsCodecVersion is used for any resources listed in the Assets version set
+	AssetsCodecVersion uint8 = 16
 )
 
 //--------------------------------------------------------------------------
@@ -5260,8 +5259,8 @@ func (target *ClusterManagement) MarshalBinaryWithContext(ctx *EncodingContext)
 	}
 	// --- [end][write][struct](Window) ---
 
-	buff.WriteFloat64(target.adjustment) // write float64
 	buff.WriteFloat64(target.Cost)       // write float64
+	buff.WriteFloat64(target.adjustment) // write float64
 	return nil
 }
 
@@ -5399,18 +5398,18 @@ func (target *ClusterManagement) UnmarshalBinaryWithContext(ctx *DecodingContext
 
 	if uint8(0) /* field version */ <= version {
 		n := buff.ReadFloat64() // read float64
-		target.adjustment = n
+		target.Cost = n
 
 	} else {
-		target.adjustment = float64(0) // default
+		target.Cost = float64(0) // default
 	}
 
-	if uint8(0) /* field version */ <= version {
+	if uint8(16) /* field version */ <= version {
 		o := buff.ReadFloat64() // read float64
-		target.Cost = o
+		target.adjustment = o
 
 	} else {
-		target.Cost = float64(0) // default
+		target.adjustment = float64(0) // default
 	}
 
 	return nil

+ 1 - 1
pkg/kubecost/query.go

@@ -39,7 +39,7 @@ type AllocationQueryOptions struct {
 	AggregateBy             []string
 	Compute                 bool
 	DisableAggregatedStores bool
-	FilterFuncs             []AllocationMatchFunc
+	Filter                  AllocationFilter
 	IdleByNode              bool
 	IncludeExternal         bool
 	IncludeIdle             bool

+ 21 - 22
pkg/kubecost/summaryallocation.go

@@ -301,15 +301,21 @@ type SummaryAllocationSet struct {
 // required for unfortunate reasons to do with performance and legacy order-of-
 // operations details, as well as the fact that reconciliation has been
 // pushed down to the conversion step between Allocation and SummaryAllocation.
-func NewSummaryAllocationSet(as *AllocationSet, ffs, kfs []AllocationMatchFunc, reconcile, reconcileNetwork bool) *SummaryAllocationSet {
+func NewSummaryAllocationSet(as *AllocationSet, filter AllocationFilter, kfs []AllocationMatchFunc, reconcile, reconcileNetwork bool) *SummaryAllocationSet {
 	if as == nil {
 		return nil
 	}
 
+	// Pre-flatten the filter so we can just check == nil to see if there are
+	// filters.
+	if filter != nil {
+		filter = filter.Flattened()
+	}
+
 	// If we can know the exact size of the map, use it. If filters or sharing
 	// functions are present, we can't know the size, so we make a default map.
 	var sasMap map[string]*SummaryAllocation
-	if len(ffs) == 0 && len(kfs) == 0 {
+	if filter == nil && len(kfs) == 0 {
 		// No filters, so make the map of summary allocations exactly the size
 		// of the origin allocation set.
 		sasMap = make(map[string]*SummaryAllocation, len(as.allocations))
@@ -342,16 +348,8 @@ func NewSummaryAllocationSet(as *AllocationSet, ffs, kfs []AllocationMatchFunc,
 
 		// If the allocation does not pass any of the given filter functions,
 		// do not insert it into the set.
-		shouldFilter := false
-		for _, ff := range ffs {
-			if !ff(alloc) {
-				shouldFilter = true
-				break
-			}
-		}
-		if shouldFilter {
+		if filter != nil && !filter.Matches(alloc) {
 			continue
-
 		}
 
 		err := sas.Insert(NewSummaryAllocation(alloc, reconcile, reconcileNetwork))
@@ -475,6 +473,12 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 		options.LabelConfig = NewLabelConfig()
 	}
 
+	// Pre-flatten the filter so we can just check == nil to see if there are
+	// filters.
+	if options.Filter != nil {
+		options.Filter = options.Filter.Flattened()
+	}
+
 	// Check if we have any work to do; if not, then early return. If
 	// aggregateBy is nil, we don't aggregate anything. On the other hand,
 	// an empty slice implies that we should aggregate everything. (See
@@ -672,7 +676,7 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 	// recorded by idle-key (cluster or node, depending on the IdleByNode
 	// option). Instantiating this map is a signal to record the totals.
 	var allocTotalsAfterFilters map[string]*AllocationTotals
-	if len(resultSet.idleKeys) > 0 && len(options.FilterFuncs) > 0 {
+	if len(resultSet.idleKeys) > 0 && options.Filter != nil {
 		allocTotalsAfterFilters = make(map[string]*AllocationTotals, len(resultSet.idleKeys))
 	}
 
@@ -961,11 +965,9 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 		// be filtered or not.
 		// TODO:CLEANUP do something about external cost, this stinks
 		ea := &Allocation{Properties: sa.Properties}
-		for _, ff := range options.FilterFuncs {
-			if !ff(ea) {
-				skip = true
-				break
-			}
+
+		if options.Filter != nil {
+			skip = !options.Filter.Matches(ea)
 		}
 
 		if !skip {
@@ -987,11 +989,8 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 		// be filtered or not.
 		// TODO:CLEANUP do something about external cost, this stinks
 		ia := &Allocation{Properties: isa.Properties}
-		for _, ff := range options.FilterFuncs {
-			if !ff(ia) {
-				skip = true
-				break
-			}
+		if options.Filter != nil {
+			skip = !options.Filter.Matches(ia)
 		}
 		if skip {
 			continue

+ 14 - 3
spec/opencost-specv01.md

@@ -158,8 +158,7 @@ Below are example inputs when measuring asset costs over a designated time windo
 
 ## Workload Costs
 
-
-Workloads are defined as entities to which Asset Costs are committed. Some resources solely have Usage Costs, but others have Allocation Costs independent of actual usage. Workload Costs should be understood as _max(request, usage)_ when Assets have Resource Allocation Costs, e.g. CPU or GPU. This formula effectively assigns costs that have been directly reserved or allocated by _kube-scheduler_. Workload Costs should be calculated at the lowest level possible, e.g. _container_ level[^2], and then they can be aggregated by any dimension, e.g. pod, deployment, statefulset, label, annotation, namespace, etc.
+Workloads are defined as entities to which Asset Costs are committed. Some resources solely have Usage Costs, but others have Allocation Costs independent of actual usage. Workload Costs should be understood as _max(request, usage)_ when Assets have Resource Allocation Costs, e.g. CPU or GPU. This formula effectively assigns costs that have been directly reserved or allocated by _kube-scheduler_. Workload Costs should be calculated at the lowest level possible, i.e. _container_ level[^2], and then they can be aggregated by any dimension.
 
 
 <table>
@@ -207,7 +206,19 @@ Workloads are defined as entities to which Asset Costs are committed. Some resou
   </tr>
 </table>
 
-
+The following workload cost aggregations are supported in a complete implementation in the OpenCost Spec: 
+
+* container
+* pod
+* deployment
+* statefulset
+* job
+* controller name
+* controller kind
+* label
+* annotation
+* namespace
+* cluster
 
 ## Shared Costs
 

+ 60 - 10
ui/package-lock.json

@@ -492,6 +492,55 @@
       "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
       "dev": true
     },
+    "@jridgewell/gen-mapping": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+      "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/set-array": "^1.0.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "@jridgewell/resolve-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+      "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+      "dev": true
+    },
+    "@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true
+    },
+    "@jridgewell/source-map": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+      "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "@jridgewell/sourcemap-codec": {
+      "version": "1.4.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+      "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+      "dev": true
+    },
+    "@jridgewell/trace-mapping": {
+      "version": "0.3.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
+      "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
     "@material-ui/core": {
       "version": "4.11.3",
       "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.11.3.tgz",
@@ -1744,6 +1793,12 @@
       "integrity": "sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==",
       "dev": true
     },
+    "acorn": {
+      "version": "8.7.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
+      "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+      "dev": true
+    },
     "ansi-escapes": {
       "version": "4.3.2",
       "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -6079,13 +6134,14 @@
       }
     },
     "terser": {
-      "version": "5.10.0",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
-      "integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
+      "version": "5.14.2",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
+      "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
       "dev": true,
       "requires": {
+        "@jridgewell/source-map": "^0.3.2",
+        "acorn": "^8.5.0",
         "commander": "^2.20.0",
-        "source-map": "~0.7.2",
         "source-map-support": "~0.5.20"
       },
       "dependencies": {
@@ -6094,12 +6150,6 @@
           "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
           "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
           "dev": true
-        },
-        "source-map": {
-          "version": "0.7.3",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
-          "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
-          "dev": true
         }
       }
     },